This commit is contained in:
Iliyan Angelov
2025-09-14 23:24:25 +03:00
commit c67067a2a4
71311 changed files with 6800714 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
#-*- coding: utf-8 -*-
__all__ = [
'constants',
'discover',
'etxrd',
'filters',
'manager',
'parsehtml',
'services',
'xri',
'xrires',
]
version_info = (2, 0, 0)
__version__ = ".".join(str(x) for x in version_info)

View File

@@ -0,0 +1,137 @@
"""Functions for generating and parsing HTTP Accept: headers for
supporting server-directed content negotiation.
"""
def generateAcceptHeader(*elements):
"""Generate an accept header value
[str or (str, float)] -> str
"""
parts = []
for element in elements:
if type(element) is str:
qs = "1.0"
mtype = element
else:
mtype, q = element
q = float(q)
if q > 1 or q <= 0:
raise ValueError('Invalid preference factor: %r' % q)
qs = '%0.1f' % (q, )
parts.append((qs, mtype))
parts.sort()
chunks = []
for q, mtype in parts:
if q == '1.0':
chunks.append(mtype)
else:
chunks.append('%s; q=%s' % (mtype, q))
return ', '.join(chunks)
def parseAcceptHeader(value):
"""Parse an accept header, ignoring any accept-extensions
returns a list of tuples containing main MIME type, MIME subtype,
and quality markdown.
str -> [(str, str, float)]
"""
chunks = [chunk.strip() for chunk in value.split(',')]
accept = []
for chunk in chunks:
parts = [s.strip() for s in chunk.split(';')]
mtype = parts.pop(0)
if '/' not in mtype:
# This is not a MIME type, so ignore the bad data
continue
main, sub = mtype.split('/', 1)
for ext in parts:
if '=' in ext:
k, v = ext.split('=', 1)
if k == 'q':
try:
q = float(v)
break
except ValueError:
# Ignore poorly formed q-values
pass
else:
q = 1.0
accept.append((q, main, sub))
accept.sort()
accept.reverse()
return [(main, sub, q) for (q, main, sub) in accept]
def matchTypes(accept_types, have_types):
"""Given the result of parsing an Accept: header, and the
available MIME types, return the acceptable types with their
quality markdowns.
For example:
>>> acceptable = parseAcceptHeader('text/html, text/plain; q=0.5')
>>> matchTypes(acceptable, ['text/plain', 'text/html', 'image/jpeg'])
[('text/html', 1.0), ('text/plain', 0.5)]
Type signature: ([(str, str, float)], [str]) -> [(str, float)]
"""
if not accept_types:
# Accept all of them
default = 1
else:
default = 0
match_main = {}
match_sub = {}
for (main, sub, q) in accept_types:
if main == '*':
default = max(default, q)
continue
elif sub == '*':
match_main[main] = max(match_main.get(main, 0), q)
else:
match_sub[(main, sub)] = max(match_sub.get((main, sub), 0), q)
accepted_list = []
order_maintainer = 0
for mtype in have_types:
main, sub = mtype.split('/')
if (main, sub) in match_sub:
q = match_sub[(main, sub)]
else:
q = match_main.get(main, default)
if q:
accepted_list.append((1 - q, order_maintainer, q, mtype))
order_maintainer += 1
accepted_list.sort()
return [(mtype, q) for (_, _, q, mtype) in accepted_list]
def getAcceptable(accept_header, have_types):
"""Parse the accept header and return a list of available types in
preferred order. If a type is unacceptable, it will not be in the
resulting list.
This is a convenience wrapper around matchTypes and
parseAcceptHeader.
(str, [str]) -> [str]
"""
accepted = parseAcceptHeader(accept_header)
preferred = matchTypes(accepted, have_types)
return [mtype for (mtype, _) in preferred]

View File

@@ -0,0 +1,12 @@
__all__ = ['YADIS_HEADER_NAME', 'YADIS_CONTENT_TYPE', 'YADIS_ACCEPT_HEADER']
from openid.yadis.accept import generateAcceptHeader
YADIS_HEADER_NAME = 'X-XRDS-Location'
YADIS_CONTENT_TYPE = 'application/xrds+xml'
# A value suitable for using as an accept header when performing YADIS
# discovery, unless the application has special requirements
YADIS_ACCEPT_HEADER = generateAcceptHeader(
('text/html', 0.3),
('application/xhtml+xml', 0.5),
(YADIS_CONTENT_TYPE, 1.0), )

View File

@@ -0,0 +1,169 @@
# -*- test-case-name: openid.test.test_yadis_discover -*-
__all__ = ['discover', 'DiscoveryResult', 'DiscoveryFailure']
from io import StringIO
from openid import fetchers
from openid.yadis.constants import \
YADIS_HEADER_NAME, YADIS_CONTENT_TYPE, YADIS_ACCEPT_HEADER
from openid.yadis.parsehtml import MetaNotFound, findHTMLMeta
class DiscoveryFailure(Exception):
"""Raised when a YADIS protocol error occurs in the discovery process"""
identity_url = None
def __init__(self, message, http_response):
Exception.__init__(self, message)
self.http_response = http_response
class DiscoveryResult(object):
"""Contains the result of performing Yadis discovery on a URI"""
# The URI that was passed to the fetcher
request_uri = None
# The result of following redirects from the request_uri
normalized_uri = None
# The URI from which the response text was returned (set to
# None if there was no XRDS document found)
xrds_uri = None
# The content-type returned with the response_text
content_type = None
# The document returned from the xrds_uri
response_text = None
def __init__(self, request_uri):
"""Initialize the state of the object
sets all attributes to None except the request_uri
"""
self.request_uri = request_uri
def usedYadisLocation(self):
"""Was the Yadis protocol's indirection used?"""
if self.xrds_uri is None:
return False
return self.normalized_uri != self.xrds_uri
def isXRDS(self):
"""Is the response text supposed to be an XRDS document?"""
return (self.usedYadisLocation() or
self.content_type == YADIS_CONTENT_TYPE)
def discover(uri):
"""Discover services for a given URI.
@param uri: The identity URI as a well-formed http or https
URI. The well-formedness and the protocol are not checked, but
the results of this function are undefined if those properties
do not hold.
@return: DiscoveryResult object
@raises Exception: Any exception that can be raised by fetching a URL with
the given fetcher.
@raises DiscoveryFailure: When the HTTP response does not have a 200 code.
"""
result = DiscoveryResult(uri)
resp = fetchers.fetch(uri, headers={'Accept': YADIS_ACCEPT_HEADER})
if resp.status not in (200, 206):
raise DiscoveryFailure(
'HTTP Response status from identity URL host is not 200. '
'Got status %r' % (resp.status, ), resp)
# Note the URL after following redirects
result.normalized_uri = resp.final_url
# Attempt to find out where to go to discover the document
# or if we already have it
result.content_type = resp.headers.get('content-type')
result.xrds_uri = whereIsYadis(resp)
if result.xrds_uri and result.usedYadisLocation():
resp = fetchers.fetch(result.xrds_uri)
if resp.status not in (200, 206):
exc = DiscoveryFailure(
'HTTP Response status from Yadis host is not 200. '
'Got status %r' % (resp.status, ), resp)
exc.identity_url = result.normalized_uri
raise exc
result.content_type = resp.headers.get('content-type')
result.response_text = resp.body
return result
def whereIsYadis(resp):
"""Given a HTTPResponse, return the location of the Yadis document.
May be the URL just retrieved, another URL, or None if no suitable URL can
be found.
[non-blocking]
@returns: str or None
"""
# Attempt to find out where to go to discover the document
# or if we already have it
content_type = resp.headers.get('content-type')
# According to the spec, the content-type header must be an exact
# match, or else we have to look for an indirection.
if (content_type and
content_type.split(';', 1)[0].lower() == YADIS_CONTENT_TYPE):
return resp.final_url
else:
# Try the header
yadis_loc = resp.headers.get(YADIS_HEADER_NAME.lower())
if not yadis_loc:
# Parse as HTML if the header is missing.
#
# XXX: do we want to do something with content-type, like
# have a whitelist or a blacklist (for detecting that it's
# HTML)?
# Decode body by encoding of file
content_type = content_type or ''
encoding = content_type.rsplit(';', 1)
if (len(encoding) == 2 and
encoding[1].strip().startswith('charset=')):
encoding = encoding[1].split('=', 1)[1].strip()
else:
encoding = 'utf-8'
if isinstance(resp.body, bytes):
try:
content = resp.body.decode(encoding)
except UnicodeError:
# All right, the detected encoding has failed. Try with
# UTF-8 (even if there was no detected encoding and we've
# defaulted to UTF-8, it's not that expensive an operation)
try:
content = resp.body.decode('utf-8')
except UnicodeError:
# At this point the content cannot be decoded to a str
# using the detected encoding or falling back to utf-8,
# so we have to resort to replacing undecodable chars.
# This *will* result in broken content but there isn't
# anything else that can be done.
content = resp.body.decode(encoding, 'replace')
else:
content = resp.body
try:
yadis_loc = findHTMLMeta(StringIO(content))
except (MetaNotFound, UnicodeError):
# UnicodeError: Response body could not be encoded and xrds
# location could not be found before troubles occur.
pass
return yadis_loc

View File

@@ -0,0 +1,318 @@
# -*- test-case-name: yadis.test.test_etxrd -*-
"""
ElementTree interface to an XRD document.
"""
__all__ = [
'nsTag',
'mkXRDTag',
'isXRDS',
'parseXRDS',
'getCanonicalID',
'getYadisXRD',
'getPriorityStrict',
'getPriority',
'prioSort',
'iterServices',
'expandService',
'expandServices',
]
import sys
import random
import functools
from datetime import datetime
from time import strptime
from openid.oidutil import importElementTree, importSafeElementTree
ElementTree = importElementTree()
SafeElementTree = importSafeElementTree()
from openid.yadis import xri
class XRDSError(Exception):
"""An error with the XRDS document."""
# The exception that triggered this exception
reason = None
class XRDSFraud(XRDSError):
"""Raised when there's an assertion in the XRDS that it does not have
the authority to make.
"""
def parseXRDS(text):
"""Parse the given text as an XRDS document.
@return: ElementTree containing an XRDS document
@raises XRDSError: When there is a parse error or the document does
not contain an XRDS.
"""
try:
# lxml prefers to parse bytestrings, and occasionally chokes on a
# combination of text strings and declared XML encodings -- see
# https://github.com/necaris/python3-openid/issues/19
# To avoid this, we ensure that the 'text' we're parsing is actually
# a bytestring
bytestring = text.encode('utf8') if isinstance(text, str) else text
element = SafeElementTree.XML(bytestring)
except (SystemExit, MemoryError, AssertionError, ImportError):
raise
except Exception as why:
exc = XRDSError('Error parsing document as XML')
exc.reason = why
raise exc
else:
tree = ElementTree.ElementTree(element)
if not isXRDS(tree):
raise XRDSError('Not an XRDS document')
return tree
XRD_NS_2_0 = 'xri://$xrd*($v*2.0)'
XRDS_NS = 'xri://$xrds'
def nsTag(ns, t):
return '{%s}%s' % (ns, t)
def mkXRDTag(t):
"""basestring -> basestring
Create a tag name in the XRD 2.0 XML namespace suitable for using
with ElementTree
"""
return nsTag(XRD_NS_2_0, t)
def mkXRDSTag(t):
"""basestring -> basestring
Create a tag name in the XRDS XML namespace suitable for using
with ElementTree
"""
return nsTag(XRDS_NS, t)
# Tags that are used in Yadis documents
root_tag = mkXRDSTag('XRDS')
service_tag = mkXRDTag('Service')
xrd_tag = mkXRDTag('XRD')
type_tag = mkXRDTag('Type')
uri_tag = mkXRDTag('URI')
expires_tag = mkXRDTag('Expires')
# Other XRD tags
canonicalID_tag = mkXRDTag('CanonicalID')
def isXRDS(xrd_tree):
"""Is this document an XRDS document?"""
root = xrd_tree.getroot()
return root.tag == root_tag
def getYadisXRD(xrd_tree):
"""Return the XRD element that should contain the Yadis services"""
xrd = None
# for the side-effect of assigning the last one in the list to the
# xrd variable
for xrd in xrd_tree.findall(xrd_tag):
pass
# There were no elements found, or else xrd would be set to the
# last one
if xrd is None:
raise XRDSError('No XRD present in tree')
return xrd
def getXRDExpiration(xrd_element, default=None):
"""Return the expiration date of this XRD element, or None if no
expiration was specified.
@type xrd_element: ElementTree node
@param default: The value to use as the expiration if no
expiration was specified in the XRD.
@rtype: datetime.datetime
@raises ValueError: If the xrd:Expires element is present, but its
contents are not formatted according to the specification.
"""
expires_element = xrd_element.find(expires_tag)
if expires_element is None:
return default
else:
expires_string = expires_element.text
# Will raise ValueError if the string is not the expected format
expires_time = strptime(expires_string, "%Y-%m-%dT%H:%M:%SZ")
return datetime(*expires_time[0:6])
def getCanonicalID(iname, xrd_tree):
"""Return the CanonicalID from this XRDS document.
@param iname: the XRI being resolved.
@type iname: unicode
@param xrd_tree: The XRDS output from the resolver.
@type xrd_tree: ElementTree
@returns: The XRI CanonicalID or None.
@returntype: unicode or None
"""
xrd_list = xrd_tree.findall(xrd_tag)
xrd_list.reverse()
try:
canonicalID = xri.XRI(xrd_list[0].findall(canonicalID_tag)[0].text)
except IndexError:
return None
childID = canonicalID.lower()
for xrd in xrd_list[1:]:
parent_sought = childID.rsplit("!", 1)[0]
parent = xri.XRI(xrd.findtext(canonicalID_tag))
if parent_sought != parent.lower():
raise XRDSFraud("%r can not come from %s" % (childID, parent))
childID = parent_sought
root = xri.rootAuthority(iname)
if not xri.providerIsAuthoritative(root, childID):
raise XRDSFraud("%r can not come from root %r" % (childID, root))
return canonicalID
@functools.total_ordering
class _Max(object):
"""
Value that compares greater than any other value.
Should only be used as a singleton. Implemented for use as a
priority value for when a priority is not specified.
"""
def __lt__(self, other):
return isinstance(other, self.__class__)
def __eq__(self, other):
return isinstance(other, self.__class__)
Max = _Max()
def getPriorityStrict(element):
"""Get the priority of this element.
Raises ValueError if the value of the priority is invalid. If no
priority is specified, it returns a value that compares greater
than any other value.
"""
prio_str = element.get('priority')
if prio_str is not None:
prio_val = int(prio_str)
if prio_val >= 0:
return prio_val
else:
raise ValueError('Priority values must be non-negative integers')
# Any errors in parsing the priority fall through to here
return Max
def getPriority(element):
"""Get the priority of this element
Returns Max if no priority is specified or the priority value is invalid.
"""
try:
return getPriorityStrict(element)
except ValueError:
return Max
def prioSort(elements):
"""Sort a list of elements that have priority attributes"""
# Randomize the services before sorting so that equal priority
# elements are load-balanced.
random.shuffle(elements)
sorted_elems = sorted(elements, key=getPriority)
return sorted_elems
def iterServices(xrd_tree):
"""Return an iterable over the Service elements in the Yadis XRD
sorted by priority"""
xrd = getYadisXRD(xrd_tree)
return prioSort(xrd.findall(service_tag))
def sortedURIs(service_element):
"""Given a Service element, return a list of the contents of all
URI tags in priority order."""
return [
uri_element.text
for uri_element in prioSort(service_element.findall(uri_tag))
]
def getTypeURIs(service_element):
"""Given a Service element, return a list of the contents of all
Type tags"""
return [
type_element.text for type_element in service_element.findall(type_tag)
]
def expandService(service_element):
"""Take a service element and expand it into an iterator of:
([type_uri], uri, service_element)
"""
uris = sortedURIs(service_element)
if not uris:
uris = [None]
expanded = []
for uri in uris:
type_uris = getTypeURIs(service_element)
expanded.append((type_uris, uri, service_element))
return expanded
def expandServices(service_elements):
"""Take a sorted iterator of service elements and expand it into a
sorted iterator of:
([type_uri], uri, service_element)
There may be more than one item in the resulting list for each
service element if there is more than one URI or type for a
service, but each triple will be unique.
If there is no URI or Type for a Service element, it will not
appear in the result.
"""
expanded = []
for service_element in service_elements:
expanded.extend(expandService(service_element))
return expanded

View File

@@ -0,0 +1,213 @@
"""This module contains functions and classes used for extracting
endpoint information out of a Yadis XRD file using the ElementTree XML
parser.
"""
__all__ = [
'BasicServiceEndpoint',
'mkFilter',
'IFilter',
'TransformFilterMaker',
'CompoundFilter',
]
from openid.yadis.etxrd import expandService
try:
from collections.abc import Callable
except ImportError:
from collections import Callable
class BasicServiceEndpoint(object):
"""Generic endpoint object that contains parsed service
information, as well as a reference to the service element from
which it was generated. If there is more than one xrd:Type or
xrd:URI in the xrd:Service, this object represents just one of
those pairs.
This object can be used as a filter, because it implements
fromBasicServiceEndpoint.
The simplest kind of filter you can write implements
fromBasicServiceEndpoint, which takes one of these objects.
"""
def __init__(self, yadis_url, type_uris, uri, service_element):
self.type_uris = type_uris
self.yadis_url = yadis_url
self.uri = uri
self.service_element = service_element
def matchTypes(self, type_uris):
"""Query this endpoint to see if it has any of the given type
URIs. This is useful for implementing other endpoint classes
that e.g. need to check for the presence of multiple versions
of a single protocol.
@param type_uris: The URIs that you wish to check
@type type_uris: iterable of str
@return: all types that are in both in type_uris and
self.type_uris
"""
return [uri for uri in type_uris if uri in self.type_uris]
def fromBasicServiceEndpoint(endpoint):
"""Trivial transform from a basic endpoint to itself. This
method exists to allow BasicServiceEndpoint to be used as a
filter.
If you are subclassing this object, re-implement this function.
@param endpoint: An instance of BasicServiceEndpoint
@return: The object that was passed in, with no processing.
"""
return endpoint
fromBasicServiceEndpoint = staticmethod(fromBasicServiceEndpoint)
class IFilter(object):
"""Interface for Yadis filter objects. Other filter-like things
are convertable to this class."""
def getServiceEndpoints(self, yadis_url, service_element):
"""Returns an iterator of endpoint objects"""
raise NotImplementedError
class TransformFilterMaker(object):
"""Take a list of basic filters and makes a filter that transforms
the basic filter into a top-level filter. This is mostly useful
for the implementation of mkFilter, which should only be needed
for special cases or internal use by this library.
This object is useful for creating simple filters for services
that use one URI and are specified by one Type (we expect most
Types will fit this paradigm).
Creates a BasicServiceEndpoint object and apply the filter
functions to it until one of them returns a value.
"""
def __init__(self, filter_functions):
"""Initialize the filter maker's state
@param filter_functions: The endpoint transformer functions to
apply to the basic endpoint. These are called in turn
until one of them does not return None, and the result of
that transformer is returned.
"""
self.filter_functions = filter_functions
def getServiceEndpoints(self, yadis_url, service_element):
"""Returns an iterator of endpoint objects produced by the
filter functions."""
endpoints = []
# Do an expansion of the service element by xrd:Type and xrd:URI
for type_uris, uri, _ in expandService(service_element):
# Create a basic endpoint object to represent this
# yadis_url, Service, Type, URI combination
endpoint = BasicServiceEndpoint(yadis_url, type_uris, uri,
service_element)
e = self.applyFilters(endpoint)
if e is not None:
endpoints.append(e)
return endpoints
def applyFilters(self, endpoint):
"""Apply filter functions to an endpoint until one of them
returns non-None."""
for filter_function in self.filter_functions:
e = filter_function(endpoint)
if e is not None:
# Once one of the filters has returned an
# endpoint, do not apply any more.
return e
return None
class CompoundFilter(object):
"""Create a new filter that applies a set of filters to an endpoint
and collects their results.
"""
def __init__(self, subfilters):
self.subfilters = subfilters
def getServiceEndpoints(self, yadis_url, service_element):
"""Generate all endpoint objects for all of the subfilters of
this filter and return their concatenation."""
endpoints = []
for subfilter in self.subfilters:
endpoints.extend(
subfilter.getServiceEndpoints(yadis_url, service_element))
return endpoints
# Exception raised when something is not able to be turned into a filter
filter_type_error = TypeError(
'Expected a filter, an endpoint, a callable or a list of any of these.')
def mkFilter(parts):
"""Convert a filter-convertable thing into a filter
@param parts: a filter, an endpoint, a callable, or a list of any of these.
"""
# Convert the parts into a list, and pass to mkCompoundFilter
if parts is None:
parts = [BasicServiceEndpoint]
try:
parts = list(parts)
except TypeError:
return mkCompoundFilter([parts])
else:
return mkCompoundFilter(parts)
def mkCompoundFilter(parts):
"""Create a filter out of a list of filter-like things
Used by mkFilter
@param parts: list of filter, endpoint, callable or list of any of these
"""
# Separate into a list of callables and a list of filter objects
transformers = []
filters = []
for subfilter in parts:
try:
subfilter = list(subfilter)
except TypeError:
# If it's not an iterable
if hasattr(subfilter, 'getServiceEndpoints'):
# It's a full filter
filters.append(subfilter)
elif hasattr(subfilter, 'fromBasicServiceEndpoint'):
# It's an endpoint object, so put its endpoint
# conversion attribute into the list of endpoint
# transformers
transformers.append(subfilter.fromBasicServiceEndpoint)
elif isinstance(subfilter, Callable):
# It's a simple callable, so add it to the list of
# endpoint transformers
transformers.append(subfilter)
else:
raise filter_type_error
else:
filters.append(mkCompoundFilter(subfilter))
if transformers:
filters.append(TransformFilterMaker(transformers))
if len(filters) == 1:
return filters[0]
else:
return CompoundFilter(filters)

View File

@@ -0,0 +1,195 @@
class YadisServiceManager(object):
"""Holds the state of a list of selected Yadis services, managing
storing it in a session and iterating over the services in order."""
def __init__(self, starting_url, yadis_url, services, session_key):
# The URL that was used to initiate the Yadis protocol
self.starting_url = starting_url
# The URL after following redirects (the identifier)
self.yadis_url = yadis_url
# List of service elements
self.services = list(services)
self.session_key = session_key
# Reference to the current service object
self._current = None
def __len__(self):
"""How many untried services remain?"""
return len(self.services)
def __iter__(self):
return self
def __next__(self):
"""Return the next service
self.current() will continue to return that service until the
next call to this method."""
try:
self._current = self.services.pop(0)
except IndexError:
raise StopIteration
else:
return self._current
def current(self):
"""Return the current service.
Returns None if there are no services left.
"""
return self._current
def forURL(self, url):
return url in [self.starting_url, self.yadis_url]
def started(self):
"""Has the first service been returned?"""
return self._current is not None
def store(self, session):
"""Store this object in the session, by its session key."""
session[self.session_key] = self
class Discovery(object):
"""State management for discovery.
High-level usage pattern is to call .getNextService(discover) in
order to find the next available service for this user for this
session. Once a request completes, call .finish() to clean up the
session state.
@ivar session: a dict-like object that stores state unique to the
requesting user-agent. This object must be able to store
serializable objects.
@ivar url: the URL that is used to make the discovery request
@ivar session_key_suffix: The suffix that will be used to identify
this object in the session object.
"""
DEFAULT_SUFFIX = 'auth'
PREFIX = '_yadis_services_'
def __init__(self, session, url, session_key_suffix=None):
"""Initialize a discovery object"""
self.session = session
self.url = url
if session_key_suffix is None:
session_key_suffix = self.DEFAULT_SUFFIX
self.session_key_suffix = session_key_suffix
def getNextService(self, discover):
"""Return the next authentication service for the pair of
user_input and session. This function handles fallback.
@param discover: a callable that takes a URL and returns a
list of services
@type discover: str -> [service]
@return: the next available service
"""
manager = self.getManager()
if manager is not None and not manager:
self.destroyManager()
if not manager:
yadis_url, services = discover(self.url)
manager = self.createManager(services, yadis_url)
if manager:
service = next(manager)
manager.store(self.session)
else:
service = None
return service
def cleanup(self, force=False):
"""Clean up Yadis-related services in the session and return
the most-recently-attempted service from the manager, if one
exists.
@param force: True if the manager should be deleted regardless
of whether it's a manager for self.url.
@return: current service endpoint object or None if there is
no current service
"""
manager = self.getManager(force=force)
if manager is not None:
service = manager.current()
self.destroyManager(force=force)
else:
service = None
return service
### Lower-level methods
def getSessionKey(self):
"""Get the session key for this starting URL and suffix
@return: The session key
@rtype: str
"""
return self.PREFIX + self.session_key_suffix
def getManager(self, force=False):
"""Extract the YadisServiceManager for this object's URL and
suffix from the session.
@param force: True if the manager should be returned
regardless of whether it's a manager for self.url.
@return: The current YadisServiceManager, if it's for this
URL, or else None
"""
manager = self.session.get(self.getSessionKey())
if (manager is not None and (manager.forURL(self.url) or force)):
return manager
else:
return None
def createManager(self, services, yadis_url=None):
"""Create a new YadisService Manager for this starting URL and
suffix, and store it in the session.
@raises KeyError: When I already have a manager.
@return: A new YadisServiceManager or None
"""
key = self.getSessionKey()
if self.getManager():
raise KeyError('There is already a %r manager for %r' %
(key, self.url))
if not services:
return None
manager = YadisServiceManager(self.url, yadis_url, services, key)
manager.store(self.session)
return manager
def destroyManager(self, force=False):
"""Delete any YadisServiceManager with this starting URL and
suffix from the session.
If there is no service manager or the service manager is for a
different URL, it silently does nothing.
@param force: True if the manager should be deleted regardless
of whether it's a manager for self.url.
"""
if self.getManager(force=force) is not None:
key = self.getSessionKey()
del self.session[key]

View File

@@ -0,0 +1,207 @@
__all__ = ['findHTMLMeta', 'MetaNotFound']
from html.parser import HTMLParser
import html.entities
import re
import sys
from openid.yadis.constants import YADIS_HEADER_NAME
# Size of the chunks to search at a time (also the amount that gets
# read at a time)
CHUNK_SIZE = 1024 * 16 # 16 KB
class ParseDone(Exception):
"""Exception to hold the URI that was located when the parse is
finished. If the parse finishes without finding the URI, set it to
None."""
class MetaNotFound(Exception):
"""Exception to hold the content of the page if we did not find
the appropriate <meta> tag"""
re_flags = re.IGNORECASE | re.UNICODE | re.VERBOSE
ent_pat = r'''
&
(?: \#x (?P<hex> [a-f0-9]+ )
| \# (?P<dec> \d+ )
| (?P<word> \w+ )
)
;'''
ent_re = re.compile(ent_pat, re_flags)
def substituteMO(mo):
if mo.lastgroup == 'hex':
codepoint = int(mo.group('hex'), 16)
elif mo.lastgroup == 'dec':
codepoint = int(mo.group('dec'))
else:
assert mo.lastgroup == 'word'
codepoint = html.entities.name2codepoint.get(mo.group('word'))
if codepoint is None:
return mo.group()
else:
return chr(codepoint)
def substituteEntities(s):
return ent_re.sub(substituteMO, s)
class YadisHTMLParser(HTMLParser):
"""Parser that finds a meta http-equiv tag in the head of a html
document.
When feeding in data, if the tag is matched or it will never be
found, the parser will raise ParseDone with the uri as the first
attribute.
Parsing state diagram
=====================
Any unlisted input does not affect the state::
1, 2, 5 8
+--------------------------+ +-+
| | | |
4 | 3 1, 2, 5, 7 v | v
TOP -> HTML -> HEAD ----------> TERMINATED
| | ^ | ^ ^
| | 3 | | | |
| +------------+ +-> FOUND ------+ |
| 6 8 |
| 1, 2 |
+------------------------------------+
1. any of </body>, </html>, </head> -> TERMINATE
2. <body> -> TERMINATE
3. <head> -> HEAD
4. <html> -> HTML
5. <html> -> TERMINATE
6. <meta http-equiv='X-XRDS-Location'> -> FOUND
7. <head> -> TERMINATE
8. Any input -> TERMINATE
"""
TOP = 0
HTML = 1
HEAD = 2
FOUND = 3
TERMINATED = 4
def __init__(self):
if (sys.version_info.minor <= 2):
# Python 3.2 and below actually require the `strict` argument
# to `html.parser.HTMLParser` -- otherwise it's deprecated and
# we don't want to pass it
super(YadisHTMLParser, self).__init__(strict=False)
else:
super(YadisHTMLParser, self).__init__()
self.phase = self.TOP
def _terminate(self):
self.phase = self.TERMINATED
raise ParseDone(None)
def handle_endtag(self, tag):
# If we ever see an end of head, body, or html, bail out right away.
# [1]
if tag in ['head', 'body', 'html']:
self._terminate()
def handle_starttag(self, tag, attrs):
# if we ever see a start body tag, bail out right away, since
# we want to prevent the meta tag from appearing in the body
# [2]
if tag == 'body':
self._terminate()
if self.phase == self.TOP:
# At the top level, allow a html tag or a head tag to move
# to the head or html phase
if tag == 'head':
# [3]
self.phase = self.HEAD
elif tag == 'html':
# [4]
self.phase = self.HTML
elif self.phase == self.HTML:
# if we are in the html tag, allow a head tag to move to
# the HEAD phase. If we get another html tag, then bail
# out
if tag == 'head':
# [3]
self.phase = self.HEAD
elif tag == 'html':
# [5]
self._terminate()
elif self.phase == self.HEAD:
# If we are in the head phase, look for the appropriate
# meta tag. If we get a head or body tag, bail out.
if tag == 'meta':
attrs_d = dict(attrs)
http_equiv = attrs_d.get('http-equiv', '').lower()
if http_equiv == YADIS_HEADER_NAME.lower():
raw_attr = attrs_d.get('content')
yadis_loc = substituteEntities(raw_attr)
# [6]
self.phase = self.FOUND
raise ParseDone(yadis_loc)
elif tag in ('head', 'html'):
# [5], [7]
self._terminate()
def feed(self, chars):
# [8]
if self.phase in (self.TERMINATED, self.FOUND):
self._terminate()
return super(YadisHTMLParser, self).feed(chars)
def findHTMLMeta(stream):
"""Look for a meta http-equiv tag with the YADIS header name.
@param stream: Source of the html text
@type stream: Object that implements a read() method that works
like file.read
@return: The URI from which to fetch the XRDS document
@rtype: str
@raises MetaNotFound: raised with the content that was
searched as the first parameter.
"""
parser = YadisHTMLParser()
chunks = []
while 1:
chunk = stream.read(CHUNK_SIZE)
if not chunk:
# End of file
break
chunks.append(chunk)
try:
parser.feed(chunk)
except ParseDone as why:
uri = why.args[0]
if uri is None:
# Parse finished, but we may need the rest of the file
chunks.append(stream.read())
break
else:
return uri
content = ''.join(chunks)
raise MetaNotFound(content)

View File

@@ -0,0 +1,56 @@
# -*- test-case-name: openid.test.test_services -*-
from openid.yadis.filters import mkFilter
from openid.yadis.discover import discover, DiscoveryFailure
from openid.yadis.etxrd import parseXRDS, iterServices, XRDSError
def getServiceEndpoints(input_url, flt=None):
"""Perform the Yadis protocol on the input URL and return an
iterable of resulting endpoint objects.
@param flt: A filter object or something that is convertable to
a filter object (using mkFilter) that will be used to generate
endpoint objects. This defaults to generating BasicEndpoint
objects.
@param input_url: The URL on which to perform the Yadis protocol
@return: The normalized identity URL and an iterable of endpoint
objects generated by the filter function.
@rtype: (str, [endpoint])
@raises DiscoveryFailure: when Yadis fails to obtain an XRDS document.
"""
result = discover(input_url)
try:
endpoints = applyFilter(result.normalized_uri, result.response_text,
flt)
except XRDSError as err:
raise DiscoveryFailure(str(err), None)
return (result.normalized_uri, endpoints)
def applyFilter(normalized_uri, xrd_data, flt=None):
"""Generate an iterable of endpoint objects given this input data,
presumably from the result of performing the Yadis protocol.
@param normalized_uri: The input URL, after following redirects,
as in the Yadis protocol.
@param xrd_data: The XML text the XRDS file fetched from the
normalized URI.
@type xrd_data: str
"""
flt = mkFilter(flt)
et = parseXRDS(xrd_data)
endpoints = []
for service_element in iterServices(et):
endpoints.extend(
flt.getServiceEndpoints(normalized_uri, service_element))
return endpoints

View File

@@ -0,0 +1,122 @@
# -*- test-case-name: openid.test.test_xri -*-
"""Utility functions for handling XRIs.
@see: XRI Syntax v2.0 at the U{OASIS XRI Technical Committee<http://www.oasis-open.org/committees/tc_home.php?wg_abbrev=xri>}
"""
import re
from functools import reduce
from openid import codecutil # registers 'oid_percent_escape' encoding handler
XRI_AUTHORITIES = ['!', '=', '@', '+', '$', '(']
def identifierScheme(identifier):
"""Determine if this identifier is an XRI or URI.
@returns: C{"XRI"} or C{"URI"}
"""
if identifier.startswith('xri://') or (identifier and
identifier[0] in XRI_AUTHORITIES):
return "XRI"
else:
return "URI"
def toIRINormal(xri):
"""Transform an XRI to IRI-normal form."""
if not xri.startswith('xri://'):
xri = 'xri://' + xri
return escapeForIRI(xri)
_xref_re = re.compile(r'\((.*?)\)')
def _escape_xref(xref_match):
"""Escape things that need to be escaped if they're in a cross-reference.
"""
xref = xref_match.group()
xref = xref.replace('/', '%2F')
xref = xref.replace('?', '%3F')
xref = xref.replace('#', '%23')
return xref
def escapeForIRI(xri):
"""Escape things that need to be escaped when transforming to an IRI."""
xri = xri.replace('%', '%25')
xri = _xref_re.sub(_escape_xref, xri)
return xri
def toURINormal(xri):
"""Transform an XRI to URI normal form."""
return iriToURI(toIRINormal(xri))
def iriToURI(iri):
"""Transform an IRI to a URI by escaping unicode."""
# According to RFC 3987, section 3.1, "Mapping of IRIs to URIs"
if isinstance(iri, bytes):
iri = str(iri, encoding="utf-8")
return iri.encode('ascii', errors='oid_percent_escape').decode()
def providerIsAuthoritative(providerID, canonicalID):
"""Is this provider ID authoritative for this XRI?
@returntype: bool
"""
# XXX: can't use rsplit until we require python >= 2.4.
lastbang = canonicalID.rindex('!')
parent = canonicalID[:lastbang]
return parent == providerID
def rootAuthority(xri):
"""Return the root authority for an XRI.
Example::
rootAuthority("xri://@example") == "xri://@"
@type xri: unicode
@returntype: unicode
"""
if xri.startswith('xri://'):
xri = xri[6:]
authority = xri.split('/', 1)[0]
if authority[0] == '(':
# Cross-reference.
# XXX: This is incorrect if someone nests cross-references so there
# is another close-paren in there. Hopefully nobody does that
# before we have a real xriparse function. Hopefully nobody does
# that *ever*.
root = authority[:authority.index(')') + 1]
elif authority[0] in XRI_AUTHORITIES:
# Other XRI reference.
root = authority[0]
else:
# IRI reference. XXX: Can IRI authorities have segments?
segments = authority.split('!')
segments = reduce(list.__add__, [s.split('*') for s in segments])
root = segments[0]
return XRI(root)
def XRI(xri):
"""An XRI object allowing comparison of XRI.
Ideally, this would do full normalization and provide comparsion
operators as per XRI Syntax. Right now, it just does a bit of
canonicalization by ensuring the xri scheme is present.
@param xri: an xri string
@type xri: unicode
"""
if not xri.startswith('xri://'):
xri = 'xri://' + xri
return xri

View File

@@ -0,0 +1,123 @@
# -*- test-case-name: openid.test.test_xrires -*-
"""XRI resolution.
"""
from urllib.parse import urlencode
from openid import fetchers
from openid.yadis import etxrd
from openid.yadis.xri import toURINormal
from openid.yadis.services import iterServices
DEFAULT_PROXY = 'http://proxy.xri.net/'
class ProxyResolver(object):
"""Python interface to a remote XRI proxy resolver.
"""
def __init__(self, proxy_url=DEFAULT_PROXY):
self.proxy_url = proxy_url
def queryURL(self, xri, service_type=None):
"""Build a URL to query the proxy resolver.
@param xri: An XRI to resolve.
@type xri: unicode
@param service_type: The service type to resolve, if you desire
service endpoint selection. A service type is a URI.
@type service_type: str
@returns: a URL
@returntype: str
"""
# Trim off the xri:// prefix. The proxy resolver didn't accept it
# when this code was written, but that may (or may not) change for
# XRI Resolution 2.0 Working Draft 11.
qxri = toURINormal(xri)[6:]
hxri = self.proxy_url + qxri
args = {
# XXX: If the proxy resolver will ensure that it doesn't return
# bogus CanonicalIDs (as per Steve's message of 15 Aug 2006
# 11:13:42), then we could ask for application/xrd+xml instead,
# which would give us a bit less to process.
'_xrd_r': 'application/xrds+xml',
}
if service_type:
args['_xrd_t'] = service_type
else:
# Don't perform service endpoint selection.
args['_xrd_r'] += ';sep=false'
query = _appendArgs(hxri, args)
return query
def query(self, xri, service_types):
"""Resolve some services for an XRI.
Note: I don't implement any service endpoint selection beyond what
the resolver I'm querying does, so the Services I return may well
include Services that were not of the types you asked for.
May raise fetchers.HTTPFetchingError or L{etxrd.XRDSError} if
the fetching or parsing don't go so well.
@param xri: An XRI to resolve.
@type xri: unicode
@param service_types: A list of services types to query for. Service
types are URIs.
@type service_types: list of str
@returns: tuple of (CanonicalID, Service elements)
@returntype: (unicode, list of C{ElementTree.Element}s)
"""
# FIXME: No test coverage!
services = []
# Make a seperate request to the proxy resolver for each service
# type, as, if it is following Refs, it could return a different
# XRDS for each.
canonicalID = None
for service_type in service_types:
url = self.queryURL(xri, service_type)
response = fetchers.fetch(url)
if response.status not in (200, 206):
# XXX: sucks to fail silently.
# print "response not OK:", response
continue
et = etxrd.parseXRDS(response.body)
canonicalID = etxrd.getCanonicalID(xri, et)
some_services = list(iterServices(et))
services.extend(some_services)
# TODO:
# * If we do get hits for multiple service_types, we're almost
# certainly going to have duplicated service entries and
# broken priority ordering.
return canonicalID, services
def _appendArgs(url, args):
"""Append some arguments to an HTTP query.
"""
# to be merged with oidutil.appendArgs when we combine the projects.
if hasattr(args, 'items'):
args = list(args.items())
args.sort()
if len(args) == 0:
return url
# According to XRI Resolution section "QXRI query parameters":
#
# """If the original QXRI had a null query component (only a leading
# question mark), or a query component consisting of only question
# marks, one additional leading question mark MUST be added when
# adding any XRI resolution parameters."""
if '?' in url.rstrip('?'):
sep = '&'
else:
sep = '?'
return '%s%s%s' % (url, sep, urlencode(args))