discover.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. # -*- test-case-name: openid.test.test_discover -*-
  2. """Functions to discover OpenID endpoints from identifiers.
  3. """
  4. __all__ = [
  5. 'DiscoveryFailure',
  6. 'OPENID_1_0_NS',
  7. 'OPENID_1_0_TYPE',
  8. 'OPENID_1_1_TYPE',
  9. 'OPENID_2_0_TYPE',
  10. 'OPENID_IDP_2_0_TYPE',
  11. 'OpenIDServiceEndpoint',
  12. 'discover',
  13. ]
  14. import urlparse
  15. from openid import oidutil, fetchers, urinorm
  16. from openid import yadis
  17. from openid.yadis.etxrd import nsTag, XRDSError, XRD_NS_2_0
  18. from openid.yadis.services import applyFilter as extractServices
  19. from openid.yadis.discover import discover as yadisDiscover
  20. from openid.yadis.discover import DiscoveryFailure
  21. from openid.yadis import xrires, filters
  22. from openid.yadis import xri
  23. from openid.consumer import html_parse
  24. OPENID_1_0_NS = 'http://openid.net/xmlns/1.0'
  25. OPENID_IDP_2_0_TYPE = 'http://specs.openid.net/auth/2.0/server'
  26. OPENID_2_0_TYPE = 'http://specs.openid.net/auth/2.0/signon'
  27. OPENID_1_1_TYPE = 'http://openid.net/signon/1.1'
  28. OPENID_1_0_TYPE = 'http://openid.net/signon/1.0'
  29. from openid.message import OPENID1_NS as OPENID_1_0_MESSAGE_NS
  30. from openid.message import OPENID2_NS as OPENID_2_0_MESSAGE_NS
  31. class OpenIDServiceEndpoint(object):
  32. """Object representing an OpenID service endpoint.
  33. @ivar identity_url: the verified identifier.
  34. @ivar canonicalID: For XRI, the persistent identifier.
  35. """
  36. # OpenID service type URIs, listed in order of preference. The
  37. # ordering of this list affects yadis and XRI service discovery.
  38. openid_type_uris = [
  39. OPENID_IDP_2_0_TYPE,
  40. OPENID_2_0_TYPE,
  41. OPENID_1_1_TYPE,
  42. OPENID_1_0_TYPE,
  43. ]
  44. def __init__(self):
  45. self.claimed_id = None
  46. self.server_url = None
  47. self.type_uris = []
  48. self.local_id = None
  49. self.canonicalID = None
  50. self.used_yadis = False # whether this came from an XRDS
  51. self.display_identifier = None
  52. def usesExtension(self, extension_uri):
  53. return extension_uri in self.type_uris
  54. def preferredNamespace(self):
  55. if (OPENID_IDP_2_0_TYPE in self.type_uris or
  56. OPENID_2_0_TYPE in self.type_uris):
  57. return OPENID_2_0_MESSAGE_NS
  58. else:
  59. return OPENID_1_0_MESSAGE_NS
  60. def supportsType(self, type_uri):
  61. """Does this endpoint support this type?
  62. I consider C{/server} endpoints to implicitly support C{/signon}.
  63. """
  64. return (
  65. (type_uri in self.type_uris) or
  66. (type_uri == OPENID_2_0_TYPE and self.isOPIdentifier())
  67. )
  68. def getDisplayIdentifier(self):
  69. """Return the display_identifier if set, else return the claimed_id.
  70. """
  71. if self.display_identifier is not None:
  72. return self.display_identifier
  73. if self.claimed_id is None:
  74. return None
  75. else:
  76. return urlparse.urldefrag(self.claimed_id)[0]
  77. def compatibilityMode(self):
  78. return self.preferredNamespace() != OPENID_2_0_MESSAGE_NS
  79. def isOPIdentifier(self):
  80. return OPENID_IDP_2_0_TYPE in self.type_uris
  81. def parseService(self, yadis_url, uri, type_uris, service_element):
  82. """Set the state of this object based on the contents of the
  83. service element."""
  84. self.type_uris = type_uris
  85. self.server_url = uri
  86. self.used_yadis = True
  87. if not self.isOPIdentifier():
  88. # XXX: This has crappy implications for Service elements
  89. # that contain both 'server' and 'signon' Types. But
  90. # that's a pathological configuration anyway, so I don't
  91. # think I care.
  92. self.local_id = findOPLocalIdentifier(service_element,
  93. self.type_uris)
  94. self.claimed_id = yadis_url
  95. def getLocalID(self):
  96. """Return the identifier that should be sent as the
  97. openid.identity parameter to the server."""
  98. # I looked at this conditional and thought "ah-hah! there's the bug!"
  99. # but Python actually makes that one big expression somehow, i.e.
  100. # "x is x is x" is not the same thing as "(x is x) is x".
  101. # That's pretty weird, dude. -- kmt, 1/07
  102. if (self.local_id is self.canonicalID is None):
  103. return self.claimed_id
  104. else:
  105. return self.local_id or self.canonicalID
  106. def fromBasicServiceEndpoint(cls, endpoint):
  107. """Create a new instance of this class from the endpoint
  108. object passed in.
  109. @return: None or OpenIDServiceEndpoint for this endpoint object"""
  110. type_uris = endpoint.matchTypes(cls.openid_type_uris)
  111. # If any Type URIs match and there is an endpoint URI
  112. # specified, then this is an OpenID endpoint
  113. if type_uris and endpoint.uri is not None:
  114. openid_endpoint = cls()
  115. openid_endpoint.parseService(
  116. endpoint.yadis_url,
  117. endpoint.uri,
  118. endpoint.type_uris,
  119. endpoint.service_element)
  120. else:
  121. openid_endpoint = None
  122. return openid_endpoint
  123. fromBasicServiceEndpoint = classmethod(fromBasicServiceEndpoint)
  124. def fromHTML(cls, uri, html):
  125. """Parse the given document as HTML looking for an OpenID <link
  126. rel=...>
  127. @rtype: [OpenIDServiceEndpoint]
  128. """
  129. discovery_types = [
  130. (OPENID_2_0_TYPE, 'openid2.provider', 'openid2.local_id'),
  131. (OPENID_1_1_TYPE, 'openid.server', 'openid.delegate'),
  132. ]
  133. link_attrs = html_parse.parseLinkAttrs(html)
  134. services = []
  135. for type_uri, op_endpoint_rel, local_id_rel in discovery_types:
  136. op_endpoint_url = html_parse.findFirstHref(
  137. link_attrs, op_endpoint_rel)
  138. if op_endpoint_url is None:
  139. continue
  140. service = cls()
  141. service.claimed_id = uri
  142. service.local_id = html_parse.findFirstHref(
  143. link_attrs, local_id_rel)
  144. service.server_url = op_endpoint_url
  145. service.type_uris = [type_uri]
  146. services.append(service)
  147. return services
  148. fromHTML = classmethod(fromHTML)
  149. def fromXRDS(cls, uri, xrds):
  150. """Parse the given document as XRDS looking for OpenID services.
  151. @rtype: [OpenIDServiceEndpoint]
  152. @raises XRDSError: When the XRDS does not parse.
  153. @since: 2.1.0
  154. """
  155. return extractServices(uri, xrds, cls)
  156. fromXRDS = classmethod(fromXRDS)
  157. def fromDiscoveryResult(cls, discoveryResult):
  158. """Create endpoints from a DiscoveryResult.
  159. @type discoveryResult: L{DiscoveryResult}
  160. @rtype: list of L{OpenIDServiceEndpoint}
  161. @raises XRDSError: When the XRDS does not parse.
  162. @since: 2.1.0
  163. """
  164. if discoveryResult.isXRDS():
  165. method = cls.fromXRDS
  166. else:
  167. method = cls.fromHTML
  168. return method(discoveryResult.normalized_uri,
  169. discoveryResult.response_text)
  170. fromDiscoveryResult = classmethod(fromDiscoveryResult)
  171. def fromOPEndpointURL(cls, op_endpoint_url):
  172. """Construct an OP-Identifier OpenIDServiceEndpoint object for
  173. a given OP Endpoint URL
  174. @param op_endpoint_url: The URL of the endpoint
  175. @rtype: OpenIDServiceEndpoint
  176. """
  177. service = cls()
  178. service.server_url = op_endpoint_url
  179. service.type_uris = [OPENID_IDP_2_0_TYPE]
  180. return service
  181. fromOPEndpointURL = classmethod(fromOPEndpointURL)
  182. def __str__(self):
  183. return ("<%s.%s "
  184. "server_url=%r "
  185. "claimed_id=%r "
  186. "local_id=%r "
  187. "canonicalID=%r "
  188. "used_yadis=%s "
  189. ">"
  190. % (self.__class__.__module__, self.__class__.__name__,
  191. self.server_url,
  192. self.claimed_id,
  193. self.local_id,
  194. self.canonicalID,
  195. self.used_yadis))
  196. def findOPLocalIdentifier(service_element, type_uris):
  197. """Find the OP-Local Identifier for this xrd:Service element.
  198. This considers openid:Delegate to be a synonym for xrd:LocalID if
  199. both OpenID 1.X and OpenID 2.0 types are present. If only OpenID
  200. 1.X is present, it returns the value of openid:Delegate. If only
  201. OpenID 2.0 is present, it returns the value of xrd:LocalID. If
  202. there is more than one LocalID tag and the values are different,
  203. it raises a DiscoveryFailure. This is also triggered when the
  204. xrd:LocalID and openid:Delegate tags are different.
  205. @param service_element: The xrd:Service element
  206. @type service_element: ElementTree.Node
  207. @param type_uris: The xrd:Type values present in this service
  208. element. This function could extract them, but higher level
  209. code needs to do that anyway.
  210. @type type_uris: [str]
  211. @raises DiscoveryFailure: when discovery fails.
  212. @returns: The OP-Local Identifier for this service element, if one
  213. is present, or None otherwise.
  214. @rtype: str or unicode or NoneType
  215. """
  216. # XXX: Test this function on its own!
  217. # Build the list of tags that could contain the OP-Local Identifier
  218. local_id_tags = []
  219. if (OPENID_1_1_TYPE in type_uris or
  220. OPENID_1_0_TYPE in type_uris):
  221. local_id_tags.append(nsTag(OPENID_1_0_NS, 'Delegate'))
  222. if OPENID_2_0_TYPE in type_uris:
  223. local_id_tags.append(nsTag(XRD_NS_2_0, 'LocalID'))
  224. # Walk through all the matching tags and make sure that they all
  225. # have the same value
  226. local_id = None
  227. for local_id_tag in local_id_tags:
  228. for local_id_element in service_element.findall(local_id_tag):
  229. if local_id is None:
  230. local_id = local_id_element.text
  231. elif local_id != local_id_element.text:
  232. format = 'More than one %r tag found in one service element'
  233. message = format % (local_id_tag,)
  234. raise DiscoveryFailure(message, None)
  235. return local_id
  236. def normalizeURL(url):
  237. """Normalize a URL, converting normalization failures to
  238. DiscoveryFailure"""
  239. try:
  240. normalized = urinorm.urinorm(url)
  241. except ValueError, why:
  242. raise DiscoveryFailure('Normalizing identifier: %s' % (why[0],), None)
  243. else:
  244. return urlparse.urldefrag(normalized)[0]
  245. def normalizeXRI(xri):
  246. """Normalize an XRI, stripping its scheme if present"""
  247. if xri.startswith("xri://"):
  248. xri = xri[6:]
  249. return xri
  250. def arrangeByType(service_list, preferred_types):
  251. """Rearrange service_list in a new list so services are ordered by
  252. types listed in preferred_types. Return the new list."""
  253. def enumerate(elts):
  254. """Return an iterable that pairs the index of an element with
  255. that element.
  256. For Python 2.2 compatibility"""
  257. return zip(range(len(elts)), elts)
  258. def bestMatchingService(service):
  259. """Return the index of the first matching type, or something
  260. higher if no type matches.
  261. This provides an ordering in which service elements that
  262. contain a type that comes earlier in the preferred types list
  263. come before service elements that come later. If a service
  264. element has more than one type, the most preferred one wins.
  265. """
  266. for i, t in enumerate(preferred_types):
  267. if preferred_types[i] in service.type_uris:
  268. return i
  269. return len(preferred_types)
  270. # Build a list with the service elements in tuples whose
  271. # comparison will prefer the one with the best matching service
  272. prio_services = [(bestMatchingService(s), orig_index, s)
  273. for (orig_index, s) in enumerate(service_list)]
  274. prio_services.sort()
  275. # Now that the services are sorted by priority, remove the sort
  276. # keys from the list.
  277. for i in range(len(prio_services)):
  278. prio_services[i] = prio_services[i][2]
  279. return prio_services
  280. def getOPOrUserServices(openid_services):
  281. """Extract OP Identifier services. If none found, return the
  282. rest, sorted with most preferred first according to
  283. OpenIDServiceEndpoint.openid_type_uris.
  284. openid_services is a list of OpenIDServiceEndpoint objects.
  285. Returns a list of OpenIDServiceEndpoint objects."""
  286. op_services = arrangeByType(openid_services, [OPENID_IDP_2_0_TYPE])
  287. openid_services = arrangeByType(openid_services,
  288. OpenIDServiceEndpoint.openid_type_uris)
  289. return op_services or openid_services
  290. def discoverYadis(uri):
  291. """Discover OpenID services for a URI. Tries Yadis and falls back
  292. on old-style <link rel='...'> discovery if Yadis fails.
  293. @param uri: normalized identity URL
  294. @type uri: str
  295. @return: (claimed_id, services)
  296. @rtype: (str, list(OpenIDServiceEndpoint))
  297. @raises DiscoveryFailure: when discovery fails.
  298. """
  299. # Might raise a yadis.discover.DiscoveryFailure if no document
  300. # came back for that URI at all. I don't think falling back
  301. # to OpenID 1.0 discovery on the same URL will help, so don't
  302. # bother to catch it.
  303. response = yadisDiscover(uri)
  304. yadis_url = response.normalized_uri
  305. body = response.response_text
  306. try:
  307. openid_services = OpenIDServiceEndpoint.fromXRDS(yadis_url, body)
  308. except XRDSError:
  309. # Does not parse as a Yadis XRDS file
  310. openid_services = []
  311. if not openid_services:
  312. # Either not an XRDS or there are no OpenID services.
  313. if response.isXRDS():
  314. # if we got the Yadis content-type or followed the Yadis
  315. # header, re-fetch the document without following the Yadis
  316. # header, with no Accept header.
  317. return discoverNoYadis(uri)
  318. # Try to parse the response as HTML.
  319. # <link rel="...">
  320. openid_services = OpenIDServiceEndpoint.fromHTML(yadis_url, body)
  321. return (yadis_url, getOPOrUserServices(openid_services))
  322. def discoverXRI(iname):
  323. endpoints = []
  324. iname = normalizeXRI(iname)
  325. try:
  326. canonicalID, services = xrires.ProxyResolver().query(
  327. iname, OpenIDServiceEndpoint.openid_type_uris)
  328. if canonicalID is None:
  329. raise XRDSError('No CanonicalID found for XRI %r' % (iname,))
  330. flt = filters.mkFilter(OpenIDServiceEndpoint)
  331. for service_element in services:
  332. endpoints.extend(flt.getServiceEndpoints(iname, service_element))
  333. except XRDSError:
  334. oidutil.log('xrds error on ' + iname)
  335. for endpoint in endpoints:
  336. # Is there a way to pass this through the filter to the endpoint
  337. # constructor instead of tacking it on after?
  338. endpoint.canonicalID = canonicalID
  339. endpoint.claimed_id = canonicalID
  340. endpoint.display_identifier = iname
  341. # FIXME: returned xri should probably be in some normal form
  342. return iname, getOPOrUserServices(endpoints)
  343. def discoverNoYadis(uri):
  344. http_resp = fetchers.fetch(uri)
  345. if http_resp.status not in (200, 206):
  346. raise DiscoveryFailure(
  347. 'HTTP Response status from identity URL host is not 200. '
  348. 'Got status %r' % (http_resp.status,), http_resp)
  349. claimed_id = http_resp.final_url
  350. openid_services = OpenIDServiceEndpoint.fromHTML(
  351. claimed_id, http_resp.body)
  352. return claimed_id, openid_services
  353. def discoverURI(uri):
  354. parsed = urlparse.urlparse(uri)
  355. if parsed[0] and parsed[1]:
  356. if parsed[0] not in ['http', 'https']:
  357. raise DiscoveryFailure('URI scheme is not HTTP or HTTPS', None)
  358. else:
  359. uri = 'http://' + uri
  360. uri = normalizeURL(uri)
  361. claimed_id, openid_services = discoverYadis(uri)
  362. claimed_id = normalizeURL(claimed_id)
  363. return claimed_id, openid_services
  364. def discover(identifier):
  365. if xri.identifierScheme(identifier) == "XRI":
  366. return discoverXRI(identifier)
  367. else:
  368. return discoverURI(identifier)