Authorizers
===========

Authorizers are objects that encapsulate knowledge about a particular
web service's authentication scheme. lazr.restfulclient includes
authorizers for common HTTP authentication schemes.

The BasicHttpAuthorizer
-----------------------

This authorizer handles HTTP Basic Auth. To test it, we'll create a
fake web service that serves some sample WADL.

    >>> import importlib.resources
    >>> wadl_string = importlib.resources.read_binary(
    ...     'wadllib.tests.data', 'launchpad-wadl.xml')

    >>> responses = { 'application/vnd.sun.wadl+xml' : wadl_string,
    ...               'application/json' : b'{}' }

    >>> def sample_application(environ, start_response):
    ...     media_type = environ['HTTP_ACCEPT']
    ...     content = responses[media_type]
    ...     start_response(
    ...         '200', [('Content-type', media_type)])
    ...     return [content]


The WADL file will be protected with HTTP Basic Auth. To access it,
you'll need to provide a username of "user" and a password of
"password".

    >>> def authenticate(username, password):
    ...     """Accepts "user/password", rejects everything else.
    ...
    ...     :return: The username, if the credentials are valid.
    ...              None, otherwise.
    ...     """
    ...     if username == "user" and password == "password":
    ...         return username
    ...     return None

We protect the application with a small inline WSGI middleware that
validates HTTP Basic Auth credentials.

    >>> import base64
    >>> class BasicAuthMiddleware:
    ...     """Minimal WSGI middleware for HTTP Basic authentication."""
    ...     def __init__(self, app, authenticate_with):
    ...         self.app = app
    ...         self.authenticate = authenticate_with
    ...     def __call__(self, environ, start_response):
    ...         auth = environ.get('HTTP_AUTHORIZATION', '')
    ...         if auth.startswith('Basic '):
    ...             decoded = base64.b64decode(
    ...                 auth[6:]).decode('utf-8')
    ...             username, _, password = decoded.partition(':')
    ...             if self.authenticate(username, password):
    ...                 return self.app(environ, start_response)
    ...         start_response('401 Unauthorized', [
    ...             ('WWW-Authenticate', 'Basic realm="Test"'),
    ...             ('Content-Type', 'text/plain'),
    ...         ])
    ...         return [b'Unauthorized']

    >>> def protected_application():
    ...     return BasicAuthMiddleware(
    ...         sample_application, authenticate_with=authenticate)

Finally, we'll set up a WSGI intercept so that we can test the web
service by making HTTP requests to http://api.launchpad.dev/. (This is
the hostname mentioned in the WADL file.)

    >>> import wsgi_intercept
    >>> from wsgi_intercept.httplib2_intercept import install
    >>> install()
    >>> wsgi_intercept.add_wsgi_intercept(
    ...     'api.launchpad.dev', 80, protected_application)

With no HttpAuthorizer, a ServiceRoot can't get access to the web service.

    >>> from lazr.restfulclient.resource import ServiceRoot
    >>> client = ServiceRoot(None, "http://api.launchpad.dev/")
    Traceback (most recent call last):
    ...
    Unauthorized: HTTP Error 401: Unauthorized
    ...

We can't get access if the authorizer doesn't have the right
credentials.

    >>> from lazr.restfulclient.authorize import BasicHttpAuthorizer

    >>> bad_authorizer = BasicHttpAuthorizer("baduser", "badpassword")
    >>> client = ServiceRoot(bad_authorizer, "http://api.launchpad.dev/")
    Traceback (most recent call last):
    ...
    Unauthorized: HTTP Error 401: Unauthorized
    ...

If we provide the right credentials, we can retrieve the WADL. We'll
still get an exception, because our fake web service is too fake for
ServiceRoot--its 'service root' resource doesn't match the WADL--but
we're able to make HTTP requests without getting 401 errors.

    >>> import httplib2
    >>> httplib2.debuglevel = 1

    >>> authorizer = BasicHttpAuthorizer("user", "password")
    >>> client = ServiceRoot(authorizer, "http://api.launchpad.dev/")
    send: ...
    ...


The BasicHttpAuthorizer adds proper Basic Auth headers to each
request, using the username and password it was constructed with.

    >>> headers = {}
    >>> authorizer.authorizeRequest('/', 'GET', '', headers)
    >>> headers.get('authorization')
    'Basic dXNlcjpwYXNzd29yZA=='

Teardown.

    >>> httplib2.debuglevel = 0
    >>> _ = wsgi_intercept.remove_wsgi_intercept("api.launchpad.dev", 80)


The OAuthAuthorizer
-------------------

This authorizer handles OAuth 1.0 authorization. To test it, we'll
protect the sample application with a small inline WSGI middleware that
verifies OAuth PLAINTEXT signatures using oauthlib.

We use ``Consumer`` and ``AccessToken`` from lazr.restfulclient itself,
so there is no dependency on the legacy ``oauth`` package.

    >>> from lazr.restfulclient.authorize.oauth import (
    ...     AccessToken, Consumer, OAuthAuthorizer)
    >>> valid_consumer = Consumer("consumer", '')
    >>> valid_token = AccessToken("token", "secret")
    >>> empty_token = AccessToken("", "")

Our ``authenticate()`` implementation accepts the one valid
consumer/token pair and also allows anonymous access via an empty token.

    >>> def authenticate(consumer, token, parameters):
    ...     """Accepts the valid consumer and token, rejects everything else.
    ...
    ...     :return: The consumer, if the credentials are valid.
    ...              None, otherwise.
    ...     """
    ...     if token.key == '' and token.secret == '':
    ...         # Anonymous access: any consumer is fine with an empty token.
    ...         return consumer
    ...     if consumer == valid_consumer and token == valid_token:
    ...         return consumer
    ...     return None

A simple data store maps keys to consumer/token objects, so the
middleware can look them up by the keys transmitted in the request.

    >>> class SimpleOAuthDataStore:
    ...     def __init__(self, consumers, tokens):
    ...         self._consumers = consumers
    ...         self._tokens = tokens
    ...     def lookup_consumer(self, key):
    ...         return self._consumers.get(key)
    ...     def lookup_token(self, key):
    ...         return self._tokens.get(key)

    >>> class AnonymousAccessDataStore(SimpleOAuthDataStore):
    ...     """A data store that accepts any consumer for anonymous access."""
    ...     def lookup_consumer(self, key):
    ...         consumer = super().lookup_consumer(key)
    ...         if consumer is None:
    ...             consumer = Consumer(key, '')
    ...         return consumer

    >>> data_store = AnonymousAccessDataStore(
    ...     {valid_consumer.key: valid_consumer},
    ...     {valid_token.key: valid_token,
    ...      empty_token.key: empty_token})

The ``OAuthMiddleware`` parses the OAuth Authorization header, looks up
the consumer and token from the data store, and verifies the PLAINTEXT
signature before forwarding the request.

    >>> from urllib.parse import quote, unquote

    >>> class OAuthMiddleware:
    ...     """Minimal WSGI middleware for OAuth 1.0 PLAINTEXT auth."""
    ...     def __init__(self, app, realm, authenticate_with, data_store):
    ...         self.app = app
    ...         self.realm = realm
    ...         self.authenticate = authenticate_with
    ...         self.data_store = data_store
    ...     def _parse_header(self, header):
    ...         """Parse an OAuth Authorization header into a dict."""
    ...         params = {}
    ...         for part in header[6:].split(','):
    ...             key, _, value = part.strip().partition('=')
    ...             params[key.strip()] = unquote(value.strip().strip('"'))
    ...         return params
    ...     def _verify_plaintext(self, params, consumer, token):
    ...         """Verify an OAuth PLAINTEXT signature."""
    ...         c_secret = consumer.secret or ''
    ...         t_secret = token.secret if token is not None else ''
    ...         expected = (
    ...             quote(c_secret, safe='') + '&' + quote(t_secret, safe=''))
    ...         return params.get('oauth_signature', '') == expected
    ...     def __call__(self, environ, start_response):
    ...         auth = environ.get('HTTP_AUTHORIZATION', '')
    ...         if auth.startswith('OAuth '):
    ...             p = self._parse_header(auth)
    ...             consumer = self.data_store.lookup_consumer(
    ...                 p.get('oauth_consumer_key', ''))
    ...             token = self.data_store.lookup_token(
    ...                 p.get('oauth_token', ''))
    ...             if consumer is not None and self._verify_plaintext(
    ...                     p, consumer, token):
    ...                 token_obj = (
    ...                     token if token is not None else AccessToken('', ''))
    ...                 result = self.authenticate(consumer, token_obj, p)
    ...                 if result is not None:
    ...                     return self.app(environ, start_response)
    ...         start_response('401 Unauthorized', [
    ...             ('WWW-Authenticate', 'OAuth realm="%s"' % self.realm),
    ...             ('Content-Type', 'text/plain'),
    ...         ])
    ...         return [b'Unauthorized']

Now we're ready to protect the sample_application with OAuthMiddleware.

    >>> def protected_application():
    ...     return OAuthMiddleware(
    ...         sample_application, realm="OAuth test",
    ...         authenticate_with=authenticate, data_store=data_store)
    >>> wsgi_intercept.add_wsgi_intercept(
    ...     'api.launchpad.dev', 80, protected_application)

Let's try out some clients. As you'd expect, you can't get through the
middleware with no HTTPAuthorizer at all.

    >>> client = ServiceRoot(None, "http://api.launchpad.dev/")
    Traceback (most recent call last):
    ...
    Unauthorized: HTTP Error 401: Unauthorized
    ...

Invalid credentials are also no help.

    >>> authorizer = OAuthAuthorizer(
    ...     valid_consumer.key, access_token=AccessToken("invalid", "token"))
    >>> client = ServiceRoot(authorizer, "http://api.launchpad.dev/")
    Traceback (most recent call last):
    ...
    Unauthorized: HTTP Error 401: Unauthorized
    ...

But valid credentials work fine (again, up to the point at which
lazr.restfulclient runs against the limits of this simple web
service). Note that the User-Agent header mentions the consumer key.

    >>> httplib2.debuglevel = 1
    >>> authorizer = OAuthAuthorizer(
    ...     valid_consumer.key, access_token=valid_token)
    >>> client = ServiceRoot(authorizer, "http://api.launchpad.dev/")
    send: ...
    ...

If the OAuthAuthorizer is created with an application name as well as
a consumer key, the application name is mentioned in the User-Agent
header as well.

    >>> authorizer = OAuthAuthorizer(
    ...     valid_consumer.key, access_token=valid_token,
    ...     application_name="the app")
    >>> client = ServiceRoot(authorizer, "http://api.launchpad.dev/")
    send: ...
    ...

    >>> httplib2.debuglevel = 0

It's even possible to get anonymous access by providing an empty
access token.

    >>> authorizer = OAuthAuthorizer(
    ...     valid_consumer.key, access_token=empty_token)
    >>> client = ServiceRoot(authorizer, "http://api.launchpad.dev/")

Because of the way the AnonymousAccessDataStore (defined
earlier in the test) works, you can even get anonymous access by
specifying an OAuth consumer that's not in the server-side list of
valid consumers.

    >>> authorizer = OAuthAuthorizer(
    ...     "random consumer", access_token=empty_token)
    >>> client = ServiceRoot(authorizer, "http://api.launchpad.dev/")

A ServiceRoot object has a 'credentials' attribute which contains the
Authorizer used to authorize outgoing requests.

    >>> from lazr.restfulclient.resource import ServiceRoot
    >>> root = ServiceRoot(authorizer, "http://api.launchpad.dev/")
    >>> root.credentials
    <lazr.restfulclient.authorize.oauth.OAuthAuthorizer object...>

If you try to provide credentials with an unrecognized OAuth consumer,
you'll get an error--even if the credentials are valid. The data store
used in this test only lets unrecognized OAuth consumers through when
they request anonymous access.

    >>> authorizer = OAuthAuthorizer(
    ...     'random consumer', access_token=valid_token)
    >>> client = ServiceRoot(authorizer, "http://api.launchpad.dev/")
    Traceback (most recent call last):
    ...
    Unauthorized: HTTP Error 401: Unauthorized
    ...

    >>> authorizer = OAuthAuthorizer(
    ...     'random consumer', access_token=AccessToken("invalid", "token"))
    >>> client = ServiceRoot(authorizer, "http://api.launchpad.dev/")
    Traceback (most recent call last):
    ...
    Unauthorized: HTTP Error 401: Unauthorized
    ...

Teardown.

    >>> _ = wsgi_intercept.remove_wsgi_intercept("api.launchpad.dev", 80)
