# Functions to discover OpenID endpoints from identifiers. require 'uri' require 'openid/util' require 'openid/fetchers' require 'openid/urinorm' require 'openid/message' require 'openid/yadis/discovery' require 'openid/yadis/xrds' require 'openid/yadis/xri' require 'openid/yadis/services' require 'openid/yadis/filters' require 'openid/consumer/html_parse' require 'openid/yadis/xrires' module OpenID OPENID_1_0_NS = 'http://openid.net/xmlns/1.0' OPENID_IDP_2_0_TYPE = 'http://specs.openid.net/auth/2.0/server' OPENID_2_0_TYPE = 'http://specs.openid.net/auth/2.0/signon' OPENID_1_1_TYPE = 'http://openid.net/signon/1.1' OPENID_1_0_TYPE = 'http://openid.net/signon/1.0' OPENID_1_0_MESSAGE_NS = OPENID1_NS OPENID_2_0_MESSAGE_NS = OPENID2_NS # Object representing an OpenID service endpoint. class OpenIDServiceEndpoint # OpenID service type URIs, listed in order of preference. The # ordering of this list affects yadis and XRI service discovery. OPENID_TYPE_URIS = [ OPENID_IDP_2_0_TYPE, OPENID_2_0_TYPE, OPENID_1_1_TYPE, OPENID_1_0_TYPE, ] # the verified identifier. attr_accessor :claimed_id # For XRI, the persistent identifier. attr_accessor :canonical_id attr_accessor :server_url, :type_uris, :local_id, :used_yadis def initialize @claimed_id = nil @server_url = nil @type_uris = [] @local_id = nil @canonical_id = nil @used_yadis = false # whether this came from an XRDS @display_identifier = nil end def display_identifier return @display_identifier if @display_identifier return @claimed_id if @claimed_id.nil? begin parsed_identifier = URI.parse(@claimed_id) rescue URI::InvalidURIError raise ProtocolError, "Claimed identifier #{claimed_id} is not a valid URI" end return @claimed_id if not parsed_identifier.fragment disp = parsed_identifier disp.fragment = nil return disp.to_s end def display_identifier=(display_identifier) @display_identifier = display_identifier end def uses_extension(extension_uri) return @type_uris.member?(extension_uri) end def preferred_namespace if (@type_uris.member?(OPENID_IDP_2_0_TYPE) or @type_uris.member?(OPENID_2_0_TYPE)) return OPENID_2_0_MESSAGE_NS else return OPENID_1_0_MESSAGE_NS end end def supports_type(type_uri) # Does this endpoint support this type? # # I consider C{/server} endpoints to implicitly support C{/signon}. ( @type_uris.member?(type_uri) or (type_uri == OPENID_2_0_TYPE and is_op_identifier()) ) end def compatibility_mode return preferred_namespace() != OPENID_2_0_MESSAGE_NS end def is_op_identifier return @type_uris.member?(OPENID_IDP_2_0_TYPE) end def parse_service(yadis_url, uri, type_uris, service_element) # Set the state of this object based on the contents of the # service element. @type_uris = type_uris @server_url = uri @used_yadis = true if !is_op_identifier() # XXX: This has crappy implications for Service elements that # contain both 'server' and 'signon' Types. But that's a # pathological configuration anyway, so I don't think I care. @local_id = OpenID.find_op_local_identifier(service_element, @type_uris) @claimed_id = yadis_url end end def get_local_id # Return the identifier that should be sent as the # openid.identity parameter to the server. if @local_id.nil? and @canonical_id.nil? return @claimed_id else return (@local_id or @canonical_id) end end def self.from_basic_service_endpoint(endpoint) # Create a new instance of this class from the endpoint object # passed in. # # @return: nil or OpenIDServiceEndpoint for this endpoint object""" type_uris = endpoint.match_types(OPENID_TYPE_URIS) # If any Type URIs match and there is an endpoint URI specified, # then this is an OpenID endpoint if (!type_uris.nil? and !type_uris.empty?) and !endpoint.uri.nil? openid_endpoint = self.new openid_endpoint.parse_service( endpoint.yadis_url, endpoint.uri, endpoint.type_uris, endpoint.service_element) else openid_endpoint = nil end return openid_endpoint end def self.from_html(uri, html) # Parse the given document as HTML looking for an OpenID # # @rtype: [OpenIDServiceEndpoint] discovery_types = [ [OPENID_2_0_TYPE, 'openid2.provider', 'openid2.local_id'], [OPENID_1_1_TYPE, 'openid.server', 'openid.delegate'], ] link_attrs = OpenID.parse_link_attrs(html) services = [] discovery_types.each { |type_uri, op_endpoint_rel, local_id_rel| op_endpoint_url = OpenID.find_first_href(link_attrs, op_endpoint_rel) if !op_endpoint_url next end service = self.new service.claimed_id = uri service.local_id = OpenID.find_first_href(link_attrs, local_id_rel) service.server_url = op_endpoint_url service.type_uris = [type_uri] services << service } return services end def self.from_xrds(uri, xrds) # Parse the given document as XRDS looking for OpenID services. # # @rtype: [OpenIDServiceEndpoint] # # @raises L{XRDSError}: When the XRDS does not parse. return Yadis::apply_filter(uri, xrds, self) end def self.from_discovery_result(discoveryResult) # Create endpoints from a DiscoveryResult. # # @type discoveryResult: L{DiscoveryResult} # # @rtype: list of L{OpenIDServiceEndpoint} # # @raises L{XRDSError}: When the XRDS does not parse. if discoveryResult.is_xrds() meth = self.method('from_xrds') else meth = self.method('from_html') end return meth.call(discoveryResult.normalized_uri, discoveryResult.response_text) end def self.from_op_endpoint_url(op_endpoint_url) # Construct an OP-Identifier OpenIDServiceEndpoint object for # a given OP Endpoint URL # # @param op_endpoint_url: The URL of the endpoint # @rtype: OpenIDServiceEndpoint service = self.new service.server_url = op_endpoint_url service.type_uris = [OPENID_IDP_2_0_TYPE] return service end def to_s return sprintf("<%s server_url=%s claimed_id=%s " + "local_id=%s canonical_id=%s used_yadis=%s>", self.class, @server_url, @claimed_id, @local_id, @canonical_id, @used_yadis) end end def self.find_op_local_identifier(service_element, type_uris) # Find the OP-Local Identifier for this xrd:Service element. # # This considers openid:Delegate to be a synonym for xrd:LocalID # if both OpenID 1.X and OpenID 2.0 types are present. If only # OpenID 1.X is present, it returns the value of # openid:Delegate. If only OpenID 2.0 is present, it returns the # value of xrd:LocalID. If there is more than one LocalID tag and # the values are different, it raises a DiscoveryFailure. This is # also triggered when the xrd:LocalID and openid:Delegate tags are # different. # XXX: Test this function on its own! # Build the list of tags that could contain the OP-Local # Identifier local_id_tags = [] if type_uris.member?(OPENID_1_1_TYPE) or type_uris.member?(OPENID_1_0_TYPE) # local_id_tags << Yadis::nsTag(OPENID_1_0_NS, 'openid', 'Delegate') service_element.add_namespace('openid', OPENID_1_0_NS) local_id_tags << "openid:Delegate" end if type_uris.member?(OPENID_2_0_TYPE) # local_id_tags.append(Yadis::nsTag(XRD_NS_2_0, 'xrd', 'LocalID')) service_element.add_namespace('xrd', Yadis::XRD_NS_2_0) local_id_tags << "xrd:LocalID" end # Walk through all the matching tags and make sure that they all # have the same value local_id = nil local_id_tags.each { |local_id_tag| service_element.each_element(local_id_tag) { |local_id_element| if local_id.nil? local_id = local_id_element.text elsif local_id != local_id_element.text format = 'More than one %s tag found in one service element' message = sprintf(format, local_id_tag) raise DiscoveryFailure.new(message, nil) end } } return local_id end def self.normalize_xri(xri) # Normalize an XRI, stripping its scheme if present m = /^xri:\/\/(.*)/.match(xri) xri = m[1] if m return xri end def self.normalize_url(url) # Normalize a URL, converting normalization failures to # DiscoveryFailure begin normalized = URINorm.urinorm(url) rescue URI::Error => why raise DiscoveryFailure.new("Error normalizing #{url}: #{why.message}", nil) else defragged = URI::parse(normalized) defragged.fragment = nil return defragged.normalize.to_s end end def self.best_matching_service(service, preferred_types) # Return the index of the first matching type, or something higher # if no type matches. # # This provides an ordering in which service elements that contain # a type that comes earlier in the preferred types list come # before service elements that come later. If a service element # has more than one type, the most preferred one wins. preferred_types.each_with_index { |value, index| if service.type_uris.member?(value) return index end } return preferred_types.length end def self.arrange_by_type(service_list, preferred_types) # Rearrange service_list in a new list so services are ordered by # types listed in preferred_types. Return the new list. # Build a list with the service elements in tuples whose # comparison will prefer the one with the best matching service prio_services = [] service_list.each_with_index { |s, index| prio_services << [best_matching_service(s, preferred_types), index, s] } prio_services.sort! # Now that the services are sorted by priority, remove the sort # keys from the list. (0...prio_services.length).each { |i| prio_services[i] = prio_services[i][2] } return prio_services end def self.get_op_or_user_services(openid_services) # Extract OP Identifier services. If none found, return the rest, # sorted with most preferred first according to # OpenIDServiceEndpoint.openid_type_uris. # # openid_services is a list of OpenIDServiceEndpoint objects. # # Returns a list of OpenIDServiceEndpoint objects. op_services = arrange_by_type(openid_services, [OPENID_IDP_2_0_TYPE]) openid_services = arrange_by_type(openid_services, OpenIDServiceEndpoint::OPENID_TYPE_URIS) if !op_services.empty? return op_services else return openid_services end end def self.discover_yadis(uri) # Discover OpenID services for a URI. Tries Yadis and falls back # on old-style discovery if Yadis fails. # # @param uri: normalized identity URL # @type uri: str # # @return: (claimed_id, services) # @rtype: (str, list(OpenIDServiceEndpoint)) # # @raises DiscoveryFailure: when discovery fails. # Might raise a yadis.discover.DiscoveryFailure if no document # came back for that URI at all. I don't think falling back to # OpenID 1.0 discovery on the same URL will help, so don't bother # to catch it. response = Yadis.discover(uri) yadis_url = response.normalized_uri body = response.response_text begin openid_services = OpenIDServiceEndpoint.from_xrds(yadis_url, body) rescue Yadis::XRDSError # Does not parse as a Yadis XRDS file openid_services = [] end if openid_services.empty? # Either not an XRDS or there are no OpenID services. if response.is_xrds # if we got the Yadis content-type or followed the Yadis # header, re-fetch the document without following the Yadis # header, with no Accept header. return self.discover_no_yadis(uri) end # Try to parse the response as HTML. # openid_services = OpenIDServiceEndpoint.from_html(yadis_url, body) end return [yadis_url, self.get_op_or_user_services(openid_services)] end def self.discover_xri(iname) endpoints = [] iname = self.normalize_xri(iname) begin canonical_id, services = Yadis::XRI::ProxyResolver.new().query( iname, OpenIDServiceEndpoint::OPENID_TYPE_URIS) if canonical_id.nil? raise Yadis::XRDSError.new(sprintf('No CanonicalID found for XRI %s', iname)) end flt = Yadis.make_filter(OpenIDServiceEndpoint) services.each { |service_element| endpoints += flt.get_service_endpoints(iname, service_element) } rescue Yadis::XRDSError => why Util.log('xrds error on ' + iname + ': ' + why.to_s) end endpoints.each { |endpoint| # Is there a way to pass this through the filter to the endpoint # constructor instead of tacking it on after? endpoint.canonical_id = canonical_id endpoint.claimed_id = canonical_id endpoint.display_identifier = iname } # FIXME: returned xri should probably be in some normal form return [iname, self.get_op_or_user_services(endpoints)] end def self.discover_no_yadis(uri) http_resp = OpenID.fetch(uri) if http_resp.code != "200" and http_resp.code != "206" raise DiscoveryFailure.new( "HTTP Response status from identity URL host is not \"200\". "\ "Got status #{http_resp.code.inspect}", http_resp) end claimed_id = http_resp.final_url openid_services = OpenIDServiceEndpoint.from_html( claimed_id, http_resp.body) return [claimed_id, openid_services] end def self.discover_uri(uri) # Hack to work around URI parsing for URls with *no* scheme. if uri.index("://").nil? uri = 'http://' + uri end begin parsed = URI::parse(uri) rescue URI::InvalidURIError => why raise DiscoveryFailure.new("URI is not valid: #{why.message}", nil) end if !parsed.scheme.nil? and !parsed.scheme.empty? if !['http', 'https'].member?(parsed.scheme) raise DiscoveryFailure.new( "URI scheme #{parsed.scheme} is not HTTP or HTTPS", nil) end end uri = self.normalize_url(uri) claimed_id, openid_services = self.discover_yadis(uri) claimed_id = self.normalize_url(claimed_id) return [claimed_id, openid_services] end def self.discover(identifier) if Yadis::XRI::identifier_scheme(identifier) == :xri normalized_identifier, services = discover_xri(identifier) else return discover_uri(identifier) end end end