1 # -*- test-case-name: openid.test.test_discover -*-
2 """Functions to discover OpenID endpoints from identifiers.
11 'OPENID_IDP_2_0_TYPE',
12 'OpenIDServiceEndpoint',
18 from openid
import oidutil
, fetchers
, urinorm
20 from openid
import yadis
21 from openid
.yadis
.etxrd
import nsTag
, XRDSError
, XRD_NS_2_0
22 from openid
.yadis
.services
import applyFilter
as extractServices
23 from openid
.yadis
.discover
import discover
as yadisDiscover
24 from openid
.yadis
.discover
import DiscoveryFailure
25 from openid
.yadis
import xrires
, filters
26 from openid
.yadis
import xri
28 from openid
.consumer
import html_parse
30 OPENID_1_0_NS
= 'http://openid.net/xmlns/1.0'
31 OPENID_IDP_2_0_TYPE
= 'http://specs.openid.net/auth/2.0/server'
32 OPENID_2_0_TYPE
= 'http://specs.openid.net/auth/2.0/signon'
33 OPENID_1_1_TYPE
= 'http://openid.net/signon/1.1'
34 OPENID_1_0_TYPE
= 'http://openid.net/signon/1.0'
36 from openid
.message
import OPENID1_NS
as OPENID_1_0_MESSAGE_NS
37 from openid
.message
import OPENID2_NS
as OPENID_2_0_MESSAGE_NS
39 class OpenIDServiceEndpoint(object):
40 """Object representing an OpenID service endpoint.
42 @ivar identity_url: the verified identifier.
43 @ivar canonicalID: For XRI, the persistent identifier.
46 # OpenID service type URIs, listed in order of preference. The
47 # ordering of this list affects yadis and XRI service discovery.
57 self
.claimed_id
= None
58 self
.server_url
= None
61 self
.canonicalID
= None
62 self
.used_yadis
= False # whether this came from an XRDS
63 self
.display_identifier
= None
65 def usesExtension(self
, extension_uri
):
66 return extension_uri
in self
.type_uris
68 def preferredNamespace(self
):
69 if (OPENID_IDP_2_0_TYPE
in self
.type_uris
or
70 OPENID_2_0_TYPE
in self
.type_uris
):
71 return OPENID_2_0_MESSAGE_NS
73 return OPENID_1_0_MESSAGE_NS
75 def supportsType(self
, type_uri
):
76 """Does this endpoint support this type?
78 I consider C{/server} endpoints to implicitly support C{/signon}.
81 (type_uri
in self
.type_uris
) or
82 (type_uri
== OPENID_2_0_TYPE
and self
.isOPIdentifier())
85 def getDisplayIdentifier(self
):
86 """Return the display_identifier if set, else return the claimed_id.
88 if self
.display_identifier
is not None:
89 return self
.display_identifier
90 if self
.claimed_id
is None:
93 return urlparse
.urldefrag(self
.claimed_id
)[0]
95 def compatibilityMode(self
):
96 return self
.preferredNamespace() != OPENID_2_0_MESSAGE_NS
98 def isOPIdentifier(self
):
99 return OPENID_IDP_2_0_TYPE
in self
.type_uris
101 def parseService(self
, yadis_url
, uri
, type_uris
, service_element
):
102 """Set the state of this object based on the contents of the
104 self
.type_uris
= type_uris
105 self
.server_url
= uri
106 self
.used_yadis
= True
108 if not self
.isOPIdentifier():
109 # XXX: This has crappy implications for Service elements
110 # that contain both 'server' and 'signon' Types. But
111 # that's a pathological configuration anyway, so I don't
113 self
.local_id
= findOPLocalIdentifier(service_element
,
115 self
.claimed_id
= yadis_url
117 def getLocalID(self
):
118 """Return the identifier that should be sent as the
119 openid.identity parameter to the server."""
120 # I looked at this conditional and thought "ah-hah! there's the bug!"
121 # but Python actually makes that one big expression somehow, i.e.
122 # "x is x is x" is not the same thing as "(x is x) is x".
123 # That's pretty weird, dude. -- kmt, 1/07
124 if (self
.local_id
is self
.canonicalID
is None):
125 return self
.claimed_id
127 return self
.local_id
or self
.canonicalID
129 def fromBasicServiceEndpoint(cls
, endpoint
):
130 """Create a new instance of this class from the endpoint
133 @return: None or OpenIDServiceEndpoint for this endpoint object"""
134 type_uris
= endpoint
.matchTypes(cls
.openid_type_uris
)
136 # If any Type URIs match and there is an endpoint URI
137 # specified, then this is an OpenID endpoint
138 if type_uris
and endpoint
.uri
is not None:
139 openid_endpoint
= cls()
140 openid_endpoint
.parseService(
144 endpoint
.service_element
)
146 openid_endpoint
= None
148 return openid_endpoint
150 fromBasicServiceEndpoint
= classmethod(fromBasicServiceEndpoint
)
152 def fromHTML(cls
, uri
, html
):
153 """Parse the given document as HTML looking for an OpenID <link
156 @rtype: [OpenIDServiceEndpoint]
159 (OPENID_2_0_TYPE
, 'openid2.provider', 'openid2.local_id'),
160 (OPENID_1_1_TYPE
, 'openid.server', 'openid.delegate'),
163 link_attrs
= html_parse
.parseLinkAttrs(html
)
165 for type_uri
, op_endpoint_rel
, local_id_rel
in discovery_types
:
166 op_endpoint_url
= html_parse
.findFirstHref(
167 link_attrs
, op_endpoint_rel
)
168 if op_endpoint_url
is None:
172 service
.claimed_id
= uri
173 service
.local_id
= html_parse
.findFirstHref(
174 link_attrs
, local_id_rel
)
175 service
.server_url
= op_endpoint_url
176 service
.type_uris
= [type_uri
]
178 services
.append(service
)
182 fromHTML
= classmethod(fromHTML
)
185 def fromXRDS(cls
, uri
, xrds
):
186 """Parse the given document as XRDS looking for OpenID services.
188 @rtype: [OpenIDServiceEndpoint]
190 @raises XRDSError: When the XRDS does not parse.
194 return extractServices(uri
, xrds
, cls
)
196 fromXRDS
= classmethod(fromXRDS
)
199 def fromDiscoveryResult(cls
, discoveryResult
):
200 """Create endpoints from a DiscoveryResult.
202 @type discoveryResult: L{DiscoveryResult}
204 @rtype: list of L{OpenIDServiceEndpoint}
206 @raises XRDSError: When the XRDS does not parse.
210 if discoveryResult
.isXRDS():
211 method
= cls
.fromXRDS
213 method
= cls
.fromHTML
214 return method(discoveryResult
.normalized_uri
,
215 discoveryResult
.response_text
)
217 fromDiscoveryResult
= classmethod(fromDiscoveryResult
)
220 def fromOPEndpointURL(cls
, op_endpoint_url
):
221 """Construct an OP-Identifier OpenIDServiceEndpoint object for
222 a given OP Endpoint URL
224 @param op_endpoint_url: The URL of the endpoint
225 @rtype: OpenIDServiceEndpoint
228 service
.server_url
= op_endpoint_url
229 service
.type_uris
= [OPENID_IDP_2_0_TYPE
]
232 fromOPEndpointURL
= classmethod(fromOPEndpointURL
)
243 % (self
.__class
__.__module
__, self
.__class
__.__name
__,
252 def findOPLocalIdentifier(service_element
, type_uris
):
253 """Find the OP-Local Identifier for this xrd:Service element.
255 This considers openid:Delegate to be a synonym for xrd:LocalID if
256 both OpenID 1.X and OpenID 2.0 types are present. If only OpenID
257 1.X is present, it returns the value of openid:Delegate. If only
258 OpenID 2.0 is present, it returns the value of xrd:LocalID. If
259 there is more than one LocalID tag and the values are different,
260 it raises a DiscoveryFailure. This is also triggered when the
261 xrd:LocalID and openid:Delegate tags are different.
263 @param service_element: The xrd:Service element
264 @type service_element: ElementTree.Node
266 @param type_uris: The xrd:Type values present in this service
267 element. This function could extract them, but higher level
268 code needs to do that anyway.
269 @type type_uris: [str]
271 @raises DiscoveryFailure: when discovery fails.
273 @returns: The OP-Local Identifier for this service element, if one
274 is present, or None otherwise.
275 @rtype: str or unicode or NoneType
277 # XXX: Test this function on its own!
279 # Build the list of tags that could contain the OP-Local Identifier
281 if (OPENID_1_1_TYPE
in type_uris
or
282 OPENID_1_0_TYPE
in type_uris
):
283 local_id_tags
.append(nsTag(OPENID_1_0_NS
, 'Delegate'))
285 if OPENID_2_0_TYPE
in type_uris
:
286 local_id_tags
.append(nsTag(XRD_NS_2_0
, 'LocalID'))
288 # Walk through all the matching tags and make sure that they all
289 # have the same value
291 for local_id_tag
in local_id_tags
:
292 for local_id_element
in service_element
.findall(local_id_tag
):
294 local_id
= local_id_element
.text
295 elif local_id
!= local_id_element
.text
:
296 format
= 'More than one %r tag found in one service element'
297 message
= format
% (local_id_tag
,)
298 raise DiscoveryFailure(message
, None)
302 def normalizeURL(url
):
303 """Normalize a URL, converting normalization failures to
306 normalized
= urinorm
.urinorm(url
)
307 except ValueError, why
:
308 raise DiscoveryFailure('Normalizing identifier: %s' % (why
[0],), None)
310 return urlparse
.urldefrag(normalized
)[0]
312 def arrangeByType(service_list
, preferred_types
):
313 """Rearrange service_list in a new list so services are ordered by
314 types listed in preferred_types. Return the new list."""
317 """Return an iterable that pairs the index of an element with
320 For Python 2.2 compatibility"""
321 return zip(range(len(elts
)), elts
)
323 def bestMatchingService(service
):
324 """Return the index of the first matching type, or something
325 higher if no type matches.
327 This provides an ordering in which service elements that
328 contain a type that comes earlier in the preferred types list
329 come before service elements that come later. If a service
330 element has more than one type, the most preferred one wins.
332 for i
, t
in enumerate(preferred_types
):
333 if preferred_types
[i
] in service
.type_uris
:
336 return len(preferred_types
)
338 # Build a list with the service elements in tuples whose
339 # comparison will prefer the one with the best matching service
340 prio_services
= [(bestMatchingService(s
), orig_index
, s
)
341 for (orig_index
, s
) in enumerate(service_list
)]
344 # Now that the services are sorted by priority, remove the sort
345 # keys from the list.
346 for i
in range(len(prio_services
)):
347 prio_services
[i
] = prio_services
[i
][2]
351 def getOPOrUserServices(openid_services
):
352 """Extract OP Identifier services. If none found, return the
353 rest, sorted with most preferred first according to
354 OpenIDServiceEndpoint.openid_type_uris.
356 openid_services is a list of OpenIDServiceEndpoint objects.
358 Returns a list of OpenIDServiceEndpoint objects."""
360 op_services
= arrangeByType(openid_services
, [OPENID_IDP_2_0_TYPE
])
362 openid_services
= arrangeByType(openid_services
,
363 OpenIDServiceEndpoint
.openid_type_uris
)
365 return op_services
or openid_services
367 def discoverYadis(uri
):
368 """Discover OpenID services for a URI. Tries Yadis and falls back
369 on old-style <link rel='...'> discovery if Yadis fails.
371 @param uri: normalized identity URL
374 @return: (claimed_id, services)
375 @rtype: (str, list(OpenIDServiceEndpoint))
377 @raises DiscoveryFailure: when discovery fails.
379 # Might raise a yadis.discover.DiscoveryFailure if no document
380 # came back for that URI at all. I don't think falling back
381 # to OpenID 1.0 discovery on the same URL will help, so don't
382 # bother to catch it.
383 response
= yadisDiscover(uri
)
385 yadis_url
= response
.normalized_uri
386 body
= response
.response_text
388 openid_services
= OpenIDServiceEndpoint
.fromXRDS(yadis_url
, body
)
390 # Does not parse as a Yadis XRDS file
393 if not openid_services
:
394 # Either not an XRDS or there are no OpenID services.
396 if response
.isXRDS():
397 # if we got the Yadis content-type or followed the Yadis
398 # header, re-fetch the document without following the Yadis
399 # header, with no Accept header.
400 return discoverNoYadis(uri
)
402 # Try to parse the response as HTML.
404 openid_services
= OpenIDServiceEndpoint
.fromHTML(yadis_url
, body
)
406 return (yadis_url
, getOPOrUserServices(openid_services
))
408 def discoverXRI(iname
):
411 canonicalID
, services
= xrires
.ProxyResolver().query(
412 iname
, OpenIDServiceEndpoint
.openid_type_uris
)
414 if canonicalID
is None:
415 raise XRDSError('No CanonicalID found for XRI %r' % (iname
,))
417 flt
= filters
.mkFilter(OpenIDServiceEndpoint
)
418 for service_element
in services
:
419 endpoints
.extend(flt
.getServiceEndpoints(iname
, service_element
))
421 oidutil
.log('xrds error on ' + iname
)
423 for endpoint
in endpoints
:
424 # Is there a way to pass this through the filter to the endpoint
425 # constructor instead of tacking it on after?
426 endpoint
.canonicalID
= canonicalID
427 endpoint
.claimed_id
= canonicalID
428 endpoint
.display_identifier
= iname
430 # FIXME: returned xri should probably be in some normal form
431 return iname
, getOPOrUserServices(endpoints
)
434 def discoverNoYadis(uri
):
435 http_resp
= fetchers
.fetch(uri
)
436 if http_resp
.status
not in (200, 206):
437 raise DiscoveryFailure(
438 'HTTP Response status from identity URL host is not 200. '
439 'Got status %r' % (http_resp
.status
,), http_resp
)
441 claimed_id
= http_resp
.final_url
442 openid_services
= OpenIDServiceEndpoint
.fromHTML(
443 claimed_id
, http_resp
.body
)
444 return claimed_id
, openid_services
446 def discoverURI(uri
):
447 parsed
= urlparse
.urlparse(uri
)
448 if parsed
[0] and parsed
[1]:
449 if parsed
[0] not in ['http', 'https']:
450 raise DiscoveryFailure('URI scheme is not HTTP or HTTPS', None)
452 uri
= 'http://' + uri
454 uri
= normalizeURL(uri
)
455 claimed_id
, openid_services
= discoverYadis(uri
)
456 claimed_id
= normalizeURL(claimed_id
)
457 return claimed_id
, openid_services
459 def discover(identifier
):
460 if xri
.identifierScheme(identifier
) == "XRI":
461 return discoverXRI(identifier
)
463 return discoverURI(identifier
)