Commits

Anonymous committed efd9196

Update openid2rp to support direct verification.

Comments (0)

Files changed (2)

extensions/openid_login.py

     'Helper class for OpenID'
 
     # Session management: Recycle expired session objects
-    def get_session(self, provider, discovered=None):
-        sessions = self.db.openid_session.filter(None, {'provider_id':provider})
+    def get_session(self, url, stypes):
+        sessions = self.db.openid_session.filter(None, {'url':url})
         for session_id in sessions:
             # Match may not have been exact
-            if self.db.openid_session.get(session_id, 'provider_id') != provider:
-                continue
-            if discovered and discovered[1] != self.db.openid_session.get(session_id, 'url'):
-                # User has changed provider; don't reuse session
+            if self.db.openid_session.get(session_id, 'url') != url:
                 continue
             expires = self.db.openid_session.get(session_id, 'expires')
             if  expires > date.Date('.')+date.Interval("1:00"):
                 # valid for another hour
                 return self.db.openid_session.getnode(session_id)
-        # need to create new session, or recycle an expired one
-        if discovered:
-            stypes, url, op_local = discovered
-        else:
-            stypes, url, op_local = openid2rp.discover(provider)
         now = date.Date('.')
         session_data = openid2rp.associate(stypes, url)
         # check whether a session has expired a day ago
         else:
             session_id = self.db.openid_session.create(assoc_handle=session_data['assoc_handle'])
             session = self.db.openid_session.getnode(session_id)
-        session.provider_id = provider
         session.url = url
-        session.stypes = " ".join(stypes)
         session.mac_key = session_data['mac_key']
         session.expires = now + date.Interval(int(session_data['expires_in']))
         self.db.commit()
         return session
 
-    def authenticate(self, session, query):
+    def discover(self, url):
+        '''Return cached discovery results or None.'''
+        try:
+            discovered = self.db.openid_discovery.lookup(url)
+        except KeyError:
+            return None
+        discovered = self.db.openid_discovery.getnode(discovered)
+        op_local = discovered.op_local
+        if op_local == '':
+            op_local = None
+        return discovered.services.split(), discovered.op_endpoint, op_local
+
+    def store_discovered(self, url, stypes, op_endpoint, op_local):
+        if op_local is None:
+            op_local = ''
+        try:
+            discovered = self.db.openid_discovery.lookup(url)
+        except KeyError:
+            self.db.openid_discovery.create(url=url, services=" ".join(stypes),
+                                            op_endpoint=op_endpoint, op_local=op_local)
+        else:
+            discovered = self.db.openid_discovery.getnode(discovered)
+            discovered.services = " ".join(stypes)
+            discovered.op_endpoint = op_endpoint
+            discovered.op_local = op_local
+            self.db.commit()
+
+    def find_association(self, handle):
+        try:
+            session = self.db.openid_session.lookup(handle)
+            session = self.db.openid_session.getnode(session)
+            return session
+        except KeyError:
+            return None
+
+    def nonce_seen(self, nonce):
+        try:
+            self.db.openid_nonce.lookup(nonce)
+            return True
+        except KeyError:
+            return False
+
+    def authenticate(self, query):
         '''Authenticate an OpenID indirect response, and return the claimed ID'''
         try:
-            signed = openid2rp.authenticate(session, query)
+            signed, claimed = openid2rp.verify(query, self.discover,
+                                               self.find_association,
+                                               self.nonce_seen)
         except Exception, e:
             raise ValueError, "Authentication failed: "+str(e)
-        if openid2rp.is_op_endpoint(session.stypes):
-            # Provider-guided login: provider ought to report claimed ID
-            if 'openid.claimed_id' in query:
-                claimed = query['openid.claimed_id'][0]
-            else:
-                raise ValueError, 'incomplete response'
-            # OpenID 11.2: verify that provider is authorized to assert ID
-            discovered = openid2rp.discover(claimed)
-            if not discovered or discovered[1] != session.url:
-                raise ValueError, "Provider %s is not authorized to make assertions about %s" % (session.url, claimed)
-        else:
-            # User entered claimed ID, stored in session object
-            claimed = session.provider_id
-            if not openid2rp.is_compat_1x(session.stypes):
-                # can only check correct claimed ID for OpenID 2.0
-                if 'openid.claimed_id' not in query or claimed != query['openid.claimed_id'][0]:
-                    # assertion is not about an ID, or about a different ID; refuse to accept
-                    raise ValueError, "Provider did not assert your ID"
         return claimed
 
+    def store_nonce(self, query):
+        '''Store a nonce in the database.'''
+        if 'openid.response_nonce' in query:
+            nonce = query['openid.response_nonce'][0]
+            stamp = openid2rp.parse_nonce(nonce)
+            # Consume nonce; reuse expired nonces
+            old = self.db.openid_nonce.filter(None, {'created':';.-1d'})
+            stamp = date.Date(stamp)
+            if old:
+                self.db.openid_nonce.set(old[0], created=stamp, nonce=nonce)
+            else:
+                self.db.openid_nonce.create(created=stamp, nonce=nonce)
+            self.db.commit()
         
 class OpenidLogin(LoginAction, Openid):
     'Extended versoin of LoginAction, supporting OpenID identifiers in username field.'
         discovered = openid2rp.discover(claimed)
         if not discovered:
             raise ValueError, "OpenID provider discovery failed"
+        self.store_discovered(claimed, *discovered)
         stypes, url, op_local = discovered
-        session = self.get_session(claimed, discovered) # one session per claimed id
+        session = self.get_session(url, stypes)
         realm = self.base+"?@action=openid_return"
         return_to = realm + "&__came_from=%s" % urllib.quote(self.client.path)
-        url = openid2rp.request_authentication(session.stypes, session.url,
+        url = openid2rp.request_authentication(stypes, url,
                                             session.assoc_handle, return_to, realm=realm,
                                             claimed=claimed, op_local=op_local)
         raise Redirect, url
             self.client.error_message.append(self._('Unsupported provider'))
             return
         provider_id = providers[provider][2]
+        # For most providers, it would be reasonable to cache the discovery
+        # results. However, the risk of login breaking if a provider does change
+        # its service URL outweighs the cost of another HTTP request to perform
+        # the discovery during login.
+        services, op_endpoint, op_local = openid2rp.discover(provider_id)
+        session = self.get_session(op_endpoint, services)
         try:
-            session = self.get_session(provider_id)
+            session = self.get_session(op_endpoint, services)
         except NoCertificate:
             self.client.error_message.append(self._('Peer did not return certificate'))
             return
         realm = self.base+"?@action=openid_return"
         return_to = realm + "&__came_from=%s" % urllib.quote(self.client.path)
-        url = openid2rp.request_authentication(session.stypes, session.url,
+        url = openid2rp.request_authentication(services, op_endpoint,
                                             session.assoc_handle, return_to, realm=realm)
         raise Redirect, url
 
             ''' % self.base
             self.client.additional_headers['Content-Type'] = 'application/xrds+xml'
             return payload
-        if 'openid.response_nonce' in query:
-            nonce = query['openid.response_nonce'][0]
-            stamp = openid2rp.parse_nonce(nonce)
-            utc = calendar.timegm(stamp.utctimetuple())
-            if utc < time.time()-3600:
-                # Old nonce
-                raise ValueError, "Replay detected"
-            try:
-                self.db.openid_nonce.lookup(nonce)
-            except KeyError:
-                pass
-            else:
-                raise ValueError, "Replay detected"
-            # Consume nonce; reuse expired nonces
-            old = self.db.openid_nonce.filter(None, {'created':';.-1d'})
-            stamp = date.Date(stamp)
-            if old:
-                self.db.openid_nonce.set(old[0], created=stamp, nonce=nonce)
-            else:
-                self.db.openid_nonce.create(created=stamp, nonce=nonce)
-            self.db.commit()
         handle = query['openid.assoc_handle'][0]
         try:
             session = self.db.openid_session.lookup(handle)
         except KeyError:
             raise ValueError, 'Not authenticated (no session)'
         session = self.db.openid_session.getnode(session)
-        claimed = self.authenticate(session, query)
+        claimed = self.authenticate(query)
         if self.user != 'anonymous':
             # Existing user claims OpenID
 
             # ID must be currently unassigned
             if self.db.user.filter(None, {'openids':claimed}):
                 raise ValueError, 'OpenID already claimed'
+            # Consume nonce
+            self.store_nonce(query)
             openids = self.db.user.get(self.userid, 'openids')
             if openids:
                 openids += ' '
         # Check whether this is a successful login
         user = self.db.user.filter(None, {'openids':claimed})
         if user:
+            # Consume nonce
+            self.store_nonce(query)
             # there should be only one user with that ID
             assert len(user)==1
             self.client.userid = user[0]
         query = {}
         if 'openid.identity' not in self.form:
             raise ValueError, "OpenID fields missing"
-        try:
-            handle = self.form['openid.assoc_handle'].value
-            session = self.db.openid_session.lookup(handle)
-            session = self.db.openid_session.getnode(session)
-        except Exception, e:
-            raise ValueError, "Not authenticated (no session): "+str(e)
         # re-authenticate fields
         for key in self.form:
             if key.startswith("openid"):
                     query[key].append(value)
                 except KeyError:
                     query[key] = [value]
-        claimed = self.authenticate(session, query)
+        claimed = self.authenticate(query)
         # OpenID signature is still authentic, now pass it on to the base
         # register method; also fake password
+
+        # Consume nonce first
+        self.store_nonce(query)
+        
         self.form.value.append(cgi.MiniFieldStorage('openids', claimed))
         pwd = password.generatePassword()
         self.form.value.append(cgi.MiniFieldStorage('password', pwd))
 # This library implements OpenID Authentication 2.0,
 # in the role of a relying party
 
-import urlparse, urllib, httplib, time, cgi, htmllib, formatter
+import urlparse, urllib, httplib, time, cgi, HTMLParser
 import cStringIO, base64, hmac, hashlib, datetime, re, random
 import itertools, cPickle, sys
 
 if sys.version_info < (3,):
     def b(s):
         return s
+    # Convert byte to integer
+    b2i = ord
+    def bytes_from_ints(L):
+        return ''.join([chr(i) for i in L])
 else:
     def b(s):
-        return s.encode('ascii')
+        return s.encode('latin-1')
+    def b2i(char):
+        # 3.x: bytes are already sequences of integers
+        return char
+    bytes_from_ints = bytes
+
+class NotAuthenticated(Exception):
+    CONNECTION_REFUSED = 1
+    DIRECT_VERIFICATION_FAILED = 2
+    CANCELLED = 3
+    UNSUPPORTED_VERSION = 4
+    UNEXPECTED_MODE = 5
+    CLAIMED_ID_MISSING = 6
+    DISCOVERY_FAILED = 7
+    INCONSISTENT_IDS = 8
+    REPLAY_ATTACK = 9
+    MISSING_NONCE = 10
+    msgs = {
+        CONNECTION_REFUSED : 'OP refuses connection with status %d',
+        DIRECT_VERIFICATION_FAILED : 'OP doesn\'t assert that the signature of the verification request is valid',
+        CANCELLED : 'OP did not authenticate user (cancelled)',
+        UNSUPPORTED_VERSION : 'Unsupported OpenID version',
+        UNEXPECTED_MODE : 'Unexpected mode %s',
+        CLAIMED_ID_MISSING : 'Cannot determine claimed ID',
+        DISCOVERY_FAILED : 'Claimed ID %s cannot be rediscovered',
+        INCONSISTENT_IDS : 'Discovered and asserted endpoints differ',
+        REPLAY_ATTACK : 'Replay attack detected',
+        MISSING_NONCE : 'Nonce missing in OpenID 2 response',
+        }
+
+    def __init__(self, errno, *args):
+        msg = self.msgs[errno]
+        if args:
+            msg %= args
+        self.errno = errno
+        Exception.__init__(self, msg, errno, *args)
+
+    def __str__(self):
+        return self.args[0]
 
 def normalize_uri(uri):
     """Normalize an uri according to OpenID section 7.2. Return a pair
         res[k] = v
     return res
 
-class OpenIDParser(htmllib.HTMLParser):
+class OpenIDParser(HTMLParser.HTMLParser):
     def __init__(self):
-        htmllib.HTMLParser.__init__(self, formatter.NullFormatter())
+        HTMLParser.HTMLParser.__init__(self)
         self.links = {}
         self.xrds_location=None
 
-    def do_link(self, attrs):
-        attrs = dict(attrs)
-        try:
-            self.links[attrs['rel']] = attrs['href']
-        except KeyError:
-            pass
+    def handle_starttag(self, tag, attrs):
+        if tag == 'link':
+            attrs = dict(attrs)
+            try:
+                self.links[attrs['rel']] = attrs['href']
+            except KeyError:
+                pass
+        elif tag == 'meta':
+            attrs = dict(attrs)
+            # Yadis 6.2.5 option 1: meta tag
+            if attrs.get('http-equiv','').lower() == 'x-xrds-location':
+                self.xrds_location = attrs['content']
 
-    def do_meta(self, attrs):
-        attrs = dict(attrs)
-        # Yadis 6.2.5 option 1: meta tag
-        if attrs.get('http-equiv','').lower() == 'x-xrds-location':
-            self.xrds_location = attrs['content']
+def _extract_services(doc):
+    for svc in doc.findall(".//{xri://$xrd*($v*2.0)}Service"):
+        services = [x.text for x in svc.findall("{xri://$xrd*($v*2.0)}Type")]
+        if 'http://specs.openid.net/auth/2.0/server' in services:
+            # 7.3.2.1.1 OP Identifier Element
+            uri = svc.find("{xri://$xrd*($v*2.0)}URI")
+            if uri is not None:
+                op_local = None
+                op_endpoint = uri.text
+                break
+        elif 'http://specs.openid.net/auth/2.0/signon' in services:
+            # 7.3.2.1.2.  Claimed Identifier Element
+            op_local = svc.find("{xri://$xrd*($v*2.0)}LocalID")
+            if op_local is not None:
+                op_local = op_local.text
+            uri = svc.find("{xri://$xrd*($v*2.0)}URI")
+            if uri is not None:
+                op_endpoint = uri.text
+                break
+        elif 'http://openid.net/server/1.0' in services or \
+                'http://openid.net/server/1.1' in services or \
+                'http://openid.net/signon/1.0' in services or \
+                'http://openid.net/signon/1.1' in services:
+            # 14.2.1 says we also need to check for the 1.x types;
+            # XXX should check 1.x only if no 2.0 service is found
+            op_local = svc.find("{http://openid.net/xmlns/1.0}Delegate")
+            if op_local is not None:
+                op_local = op_local.text
+            uri = svc.find("{xri://$xrd*($v*2.0)}URI")
+            if uri is not None:
+                op_endpoint = uri.text
+                break
+    else:
+        return None # No OpenID 2.0 service found
+    return services, op_endpoint, op_local
 
 def discover(url):
     '''Perform service discovery on the OP URL.
 
     if content_type in ('text/html', 'application/xhtml+xml'):
         parser = OpenIDParser()
-        parser.feed(data)
+        parser.feed(data.decode('latin-1'))
         parser.close()
         # Yadis 6.2.5 option 1: meta tag
         if parser.xrds_location:
     elif content_type == 'application/xrds+xml':
         # Yadis 6.2.5 option 4
         doc = ElementTree.fromstring(data)
-        for svc in doc.findall(".//{xri://$xrd*($v*2.0)}Service"):
-            services = [x.text for x in svc.findall("{xri://$xrd*($v*2.0)}Type")]
-            if 'http://specs.openid.net/auth/2.0/server' in services:
-                # 7.3.2.1.1 OP Identifier Element
-                uri = svc.find("{xri://$xrd*($v*2.0)}URI")
-                if uri is not None:
-                    op_local = None
-                    op_endpoint = uri.text
-                    break
-            elif 'http://specs.openid.net/auth/2.0/signon' in services:
-                # 7.3.2.1.2.  Claimed Identifier Element
-                op_local = svc.find("{xri://$xrd*($v*2.0)}LocalID")
-                if op_local is not None:
-                    op_local = op_local.text
-                uri = svc.find("{xri://$xrd*($v*2.0)}URI")
-                if uri is not None:
-                    op_endpoint = uri.text
-                    break
-            elif 'http://openid.net/server/1.0' in services or \
-                 'http://openid.net/server/1.1' in services or \
-                 'http://openid.net/signon/1.0' in services or \
-                 'http://openid.net/signon/1.1' in services:
-                # 14.2.1 says we also need to check for the 1.x types;
-                # XXX should check 1.x only if no 2.0 service is found
-                op_local = svc.find("{http://openid.net/xmlns/1.0}Delegate")
-                if op_local is not None:
-                    op_local = op_local.text
-                uri = svc.find("{xri://$xrd*($v*2.0)}URI")
-                if uri is not None:
-                    op_endpoint = uri.text
-                    break
-        else:
-            return None # No OpenID 2.0 service found
+        return _extract_services(doc)
     else:
         # unknown content type
         return None
     return services, op_endpoint, op_local
 
+def resolve_xri(xri, proxy='xri.net'):
+    '''Perform XRI resolution of xri using a proxy resolver.
+    Return canonical ID, services, op endpoint, op local;
+    return None if an error occurred'''
+    xri = urllib.quote(xri, safe='=@*!+()')
+    # Ask explicitly for the specific service types, to
+    # avoid having to identify the correct XRD element in
+    # case the identifier is hierarchical
+    for stype in ('http://specs.openid.net/auth/2.0/signon',
+                  'http://openid.net/signon/1.0'):
+        conn = httplib.HTTPConnection(proxy)
+        try:
+            conn.connect()
+        except:
+            # DNS or TCP error
+            return None
+        conn.putrequest("GET", '/'+xri+'?_xrd_r=application/xrd+xml'
+                        +'&_xrd_t='+stype)
+        conn.putheader('Connection', 'Keep-Alive')
+        conn.endheaders()
+        res = conn.getresponse()
+        data = res.read()
+        doc = ElementTree.fromstring(data)
+        res = _extract_services(doc)
+        conn.close()
+        if res is not None:
+            break
+    else:
+        # No OpenID service found
+        return None
+    services, op_endpoint, op_local = res
+    canonical_id = doc.find(".//{xri://$xrd*($v*2.0)}CanonicalID")
+    if canonical_id is None:
+        return None
+    return canonical_id.text, services, op_endpoint, op_local
+
 def is_compat_1x(services):
     for uri in ('http://specs.openid.net/auth/2.0/signon',
                 'http://specs.openid.net/auth/2.0/server'):
     res = cPickle.dumps(l, 2)
     # Pickle result: proto 2, long1 (integer < 256 bytes)
     # number of bytes, little-endian integer, stop
-    assert res[:3] == '\x80\x02\x8a' 
+    assert res[:3] == b('\x80\x02\x8a')
     # btwoc ought to produce the shortest representation in two's
     # complement. Fortunately, pickle already does that.
-    return res[3+ord(res[3]):3:-1]
+    return res[3+b2i(res[3]):3:-1]
 
-def unbtwoc(b):
-    return cPickle.loads('\x80\x02\x8a'+chr(len(b))+b[::-1]+'.')
+def unbtwoc(B):
+    return cPickle.loads(b('\x80\x02\x8a')+bytes_from_ints([len(B)])+B[::-1]+(b'.'))
 
 # Appendix B; DH default prime
 dh_prime = """
 def string_xor(s1, s2):
     res = []
     for c1, c2 in itertools.izip(s1, s2):
-        res.append(chr(ord(c1) ^ ord(c2)))
-    return ''.join(res)
+        res.append(b2i(c1) ^ b2i(c2))
+    return bytes_from_ints(res)
 
 def associate(services, url):
     '''Create an association (OpenID section 8) between RP and OP.
         if data['openid.session_type'] == "no-encryption":
             data['openid.session_type'] = ''
         del data['openid.ns']
-    res = urllib.urlopen(url, urllib.urlencode(data))
+    res = urllib.urlopen(url, b(urllib.urlencode(data)))
+    if res.getcode() != 200:
+        raise ValueError, "OpenID provider refuses connection with status %d" % res.getcode()
     data = parse_response(res.read())
     if 'error' in data:
         raise ValueError, "associate failed: "+data['error']
     if url.startswith('http:'):
-        enc_mac_key = data.get('enc_mac_key')
+        enc_mac_key = b(data.get('enc_mac_key'))
         if not enc_mac_key:
             raise ValueError, "Provider protocol error: not using DH-SHA1"
-        enc_mac_key = base64.b64decode(data['enc_mac_key'])
-        dh_server_public = unbtwoc(base64.b64decode(data['dh_server_public']))
+        enc_mac_key = base64.b64decode(enc_mac_key)
+        dh_server_public = unbtwoc(base64.b64decode(b(data['dh_server_public'])))
         # shared secret: sha1(2^(server_priv*priv) mod prime) xor enc_mac_key
         shared_secret = btwoc(pow(dh_server_public, priv, dh_prime))
         shared_secret = hashlib.sha1(shared_secret).digest()
         if len(shared_secret) != len(enc_mac_key):
             raise ValueError, "incorrect DH key size"
         # Fake mac_key result
-        data['mac_key'] = base64.b64encode(string_xor(enc_mac_key, shared_secret))
+        data['mac_key'] = b(base64.b64encode(string_xor(enc_mac_key, shared_secret)))
     return data
 
 class _AX:
     if claimed is None:
         claimed = "http://specs.openid.net/auth/2.0/identifier_select"
     if op_local is None:
-        op_local = "http://specs.openid.net/auth/2.0/identifier_select"
+        op_local = claimed
     if realm is None:
         realm = return_to
     data = {
         if sreg_opt:
             data['openid.sreg.optional'] =  sreg11['openid.sreg11.optional'] =','.join(sreg_opt)
     if is_compat_1x(services):
+        # OpenID 1.1 does not communicate claimed_ids. Put them into the return URL
+        return_to += '&' if '?' in return_to else '?'
+        return_to += '&openid1=' + urllib.quote(claimed)
+        data['openid.return_to'] = return_to
         del data['openid.ns']
         del data['openid.claimed_id']
         del data['openid.realm']
-        data['openid.trust_root'] = return_to
+        trust_root = urlparse.urlparse(return_to)[:3] + (None,None,None)
+        data['openid.trust_root'] = urlparse.urlunparse(trust_root)
     ax_req, ax_opt = ax
     if "http://openid.net/srv/ax/1.0" in services and (ax_req or ax_opt):
         data.update({
     else:
         return url+"?"+urllib.urlencode(data)
 
-class NotAuthenticated(Exception):
-    pass
+# 11.4.2 Verifying Directly with the OpenID Provider
+def verify_signature_directly(op_endpoint, response):
+    '''Request that the OP verify the signature via Direct Verification'''
+
+    request = [('openid.mode', 'check_authentication')]
+    # Exact copies of all fields from the authentication response, except for
+    # "openid.mode"
+    request.extend((k, v) for k, (v,) in response.items() if 'openid.mode' != k)
+    res = urllib.urlopen(op_endpoint, urllib.urlencode(request))
+    if 200 != res.getcode():
+        raise NotAuthenticated(NotAuthenticated.CONNECTION_REFUSED, res.getcode())
+    response = parse_response(res.read())
+    if 'true' != response['is_valid']:
+        raise NotAuthenticated(NotAuthenticated.DIRECT_VERIFICATION_FAILED)
 
 def _prepare_response(response):
     if isinstance(response, str):
     if session['assoc_handle'] != response['openid.assoc_handle'][0]:
         raise ValueError('incorrect session')
     if response['openid.mode'][0] == 'cancel':
-        raise NotAuthenticated('provider did not authenticate user (cancelled)')
+        raise NotAuthenticated(NotAuthenticated.CANCELLED)
     if response['openid.mode'][0] != 'id_res':
         raise ValueError('invalid openid.mode')
     if  'openid.identity' not in response:
         query.append(value)
     query = b('').join(query)
 
-    mac_key = base64.decodestring(b(session['mac_key']))
+    mac_key = base64.decodestring(session['mac_key'])
     transmitted_sig = base64.decodestring(b(response['openid.sig'][0]))
     computed_sig = hmac.new(mac_key, query, hashlib.sha1).digest()
 
 
     return signed
 
+# td.total_seconds only works in 2.7
+def _total_seconds(td):
+    return td.days*24*3600 + td.seconds
+
+def verify(response, discovery_cache, find_association, nonce_seen):
+    response = _prepare_response(response)
+    if 'openid.ns' in response:
+        ns = response['openid.ns'][0]
+        if ns != 'http://specs.openid.net/auth/2.0':
+            raise NotAuthenticated(NotAuthenticate.UNSUPPORTED_VERSION)
+    else:
+        ns = None
+    mode = response['openid.mode'][0]
+    if mode == 'cancel':
+        raise NotAuthenticated(NotAuthenticated.CANCELLED)
+    if mode != 'id_res':
+        raise NotAuthenticated(NotAuthenticated.UNEXPECTED_MODE, mode)
+    # Establish claimed ID
+    if 'openid.claimed_id' in response:
+        claimed_id = response['openid.claimed_id'][0]
+        # 11.2. Drop Fragment from claimed_id
+        fragment = claimed_id.find('#')
+        if fragment != -1:
+            claimed_id = claimed_id[:fragment]
+    elif 'openid1' in response:
+        claimed_id = response['openid1'][0]
+    else:
+        raise NotAuthenticated(NotAuthenticated.CLAIMED_ID_MISSING)
+    discovered = discovery_cache(claimed_id)
+    if not discovered:
+        discovered = discover(claimed_id)
+        if not discovered:
+            raise NotAuthenticated(NotAuthenticated.DISCOVERY_FAILED, claimed_id)
+    services, op_endpoint, op_local = discovered
+    # For a provider-allocated claimed_id, there will be no op_local ID,
+    # and there is no point checking it.
+    if op_local and op_local != response['openid.identity'][0]:
+        raise NotAuthenticated('Discovered and asserted local identifiers differ')
+    # For OpenID 1.1, op_endpoint may not be included in the response
+    if ('openid.op_endpoint' in response and
+        op_endpoint != response['openid.op_endpoint'][0]):
+        raise NotAuthenticated(NotAuthenticated.INCONSISTENT_IDS)
+    # XXX verify protocol version, verify claimed_id wrt. original request,
+    # verify return_to URL
+    
+    # verify the signature
+    assoc_handle = response['openid.assoc_handle'][0]
+    session = find_association(assoc_handle)
+    if session:
+        signed = authenticate(session, response)
+    else:
+        verify_signature_directly(op_endpoint, response)
+        signed = response['openid.signed'][0].split(',')
+
+    # Check the nonce. OpenID 1.1 doesn't have them
+    if 'openid.response_nonce' in response:
+        nonce = response['openid.response_nonce'][0]
+        timestamp = parse_nonce(nonce)
+        if _total_seconds(datetime.datetime.utcnow() - timestamp) > 10:
+            # allow for at most 10s transmission time and time shift
+            raise NotAuthenticated(NotAuthenticated.REPLAY_ATTACK)
+        if nonce_seen(nonce):
+            raise NotAuthenticated(NotAuthenticated.REPLAY_ATTACK)
+    elif ns:
+        raise NotAuthenticated(NotAuthenticated.MISSING_NONCE)
+    return signed, claimed_id
+
 def parse_nonce(nonce):
-    '''Split a nonce into a (timestamp, ID) pair'''
+    '''Extract a datetime.datetime stamp from the nonce'''
     stamp = nonce.split('Z', 1)[0]
     stamp = time.strptime(stamp, "%Y-%m-%dT%H:%M:%S")[:6]
     stamp = datetime.datetime(*stamp)