diff --git a/docs/reference/google.auth.credentials.rst b/docs/reference/google.auth.credentials.rst new file mode 100644 index 000000000..646fa7bb3 --- /dev/null +++ b/docs/reference/google.auth.credentials.rst @@ -0,0 +1,7 @@ +google.auth.credentials module +============================== + +.. automodule:: google.auth.credentials + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/reference/google.auth.rst b/docs/reference/google.auth.rst index ef0d68981..78f778680 100644 --- a/docs/reference/google.auth.rst +++ b/docs/reference/google.auth.rst @@ -19,6 +19,7 @@ Submodules .. toctree:: + google.auth.credentials google.auth.crypt google.auth.exceptions google.auth.jwt diff --git a/google/auth/_helpers.py b/google/auth/_helpers.py index d4bf8f1b4..9c49e06f4 100644 --- a/google/auth/_helpers.py +++ b/google/auth/_helpers.py @@ -14,7 +14,6 @@ """Helper functions for commonly used utilities.""" - import calendar import datetime @@ -135,3 +134,31 @@ def update_query(url, params, remove=None): # Unsplit the url. new_parts = parts._replace(query=new_query) return urllib.parse.urlunparse(new_parts) + + +def scopes_to_string(scopes): + """Converts scope value to a string suitable for sending to OAuth 2.0 + authorization servers. + + Args: + scopes (Sequence[str]): The sequence of scopes to convert. + + Returns: + str: The scopes formatted as a single string. + """ + return ' '.join(scopes) + + +def string_to_scopes(scopes): + """Converts stringifed scopes value to a list. + + Args: + scopes (Union[Sequence, str]): The string of space-separated scopes + to convert. + Returns: + Sequence(str): The separated scopes. + """ + if not scopes: + return [] + + return scopes.split(' ') diff --git a/google/auth/credentials.py b/google/auth/credentials.py new file mode 100644 index 000000000..b10e63e14 --- /dev/null +++ b/google/auth/credentials.py @@ -0,0 +1,205 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Interfaces for credentials.""" + +import abc + +import six + +from google.auth import _helpers + + +@six.add_metaclass(abc.ABCMeta) +class Credentials(object): + """Base class for all credentials. + + All credentials have a :attr:`token` that is used for authentication and + may also optionally set an :attr:`expiry` to indicate when the token will + no longer be valid. + + Most credentials will be :attr:`invalid` until :meth:`refresh` is called. + Credentials can do this automatically before the first HTTP request in + :meth:`before_request`. + + Although the token and expiration will change as the credentials are + :meth:`refreshed ` and used, credentials should be considered + immutable. Various credentials will accept configuration such as private + keys, scopes, and other options. These options are not changeable after + construction. Some classes will provide mechanisms to copy the credentials + with modifications such as :meth:`ScopedCredentials.with_scopes`. + """ + def __init__(self): + self.token = None + """str: The bearer token that can be used in HTTP headers to make + authenticated requests.""" + self.expiry = None + """Optional[datetime]: When the token expires and is no longer valid. + If this is None, the token is assumed to never expire.""" + + @property + def expired(self): + """Checks if the credentials are expired. + + Note that credentials can be invalid but not expired becaue Credentials + with :attr:`expiry` set to None is considered to never expire. + """ + now = _helpers.utcnow() + return self.expiry is not None and self.expiry <= now + + @property + def valid(self): + """Checks the validity of the credentials. + + This is True if the credentials have a :attr:`token` and the token + is not :attr:`expired`. + """ + return self.token is not None and not self.expired + + @abc.abstractmethod + def refresh(self, request): + """Refreshes the access token. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the credentials could + not be refreshed. + """ + # pylint: disable=missing-raises-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError('Refresh must be implemented') + + def apply(self, headers, token=None): + """Apply the token to the authentication header. + + Args: + headers (Mapping): The HTTP request headers. + token (Optional[str]): If specified, overrides the current access + token. + """ + headers['authorization'] = 'Bearer {}'.format( + _helpers.from_bytes(token or self.token)) + + def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + + Refreshes the credentials if necessary, then calls :meth:`apply` to + apply the token to the authentication header. + + Args: + request (google.auth.transport.Request): the object used to make + HTTP requests. + method (str): The request's HTTP method. + url (str): The request's URI. + headers (Mapping): The request's headers. + """ + # pylint: disable=unused-argument + # (Subclasses may use these arguments to ascertain information about + # the http request.) + if not self.valid: + self.refresh(request) + self.apply(headers) + + +@six.add_metaclass(abc.ABCMeta) +class Scoped(object): + """Interface for scoped credentials. + + OAuth 2.0-based credentials allow limiting access using scopes as described + in `RFC6749 Section 3.3`_. + If a credential class implements this interface then the credentials either + use scopes in their implementation. + + Some credentials require scopes in order to obtain a token. You can check + if scoping is necessary with :attr:`requires_scopes`:: + + if credentials.requires_scopes: + # Scoping is required. + credentials = credentials.create_scoped(['one', 'two']) + + Credentials that require scopes must either be constructed with scopes:: + + credentials = SomeScopedCredentials(scopes=['one', 'two']) + + Or must copy an existing instance using :meth:`with_scopes`:: + + scoped_credentials = credentials.with_scopes(scopes=['one', 'two']) + + Some credentials have scopes but do not allow or require scopes to be set, + these credentials can be used as-is. + + .. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3 + """ + def __init__(self): + super(Scoped, self).__init__() + self._scopes = None + + @property + def scopes(self): + """Sequence[str]: the credentials' current set of scopes.""" + return self._scopes + + @abc.abstractproperty + def requires_scopes(self): + """True if these credentials require scopes to obtain an access token. + """ + return False + + @abc.abstractmethod + def with_scopes(self, scopes): + """Create a copy of these credentials with the specified scopes. + + Args: + scopes (Sequence[str]): The list of scopes to request. + + Raises: + NotImplementedError: If the credentials' scopes can not be changed. + This can be avoided by checking :attr:`requires_scopes` before + calling this method. + """ + raise NotImplementedError('This class does not require scoping.') + + def has_scopes(self, scopes): + """Checks if the credentials have the given scopes. + + .. warning: This method is not guaranteed to be accurate if the + credentials are :attr:`~Credentials.invalid`. + + Returns: + bool: True if the credentials have the given scopes. + """ + return set(scopes).issubset(set(self._scopes or [])) + + +@six.add_metaclass(abc.ABCMeta) +class Signing(object): + """Interface for credentials that can cryptographically sign messages.""" + + @abc.abstractmethod + def sign_bytes(self, message): + """Signs the given message. + + Args: + message (bytes): The message to sign. + + Returns: + bytes: The message's cryptographic signature. + """ + # pylint: disable=missing-raises-doc,redundant-returns-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError('Sign bytes must be implemented.') diff --git a/tests/test__helpers.py b/tests/test__helpers.py index d475fc4bc..86cacefef 100644 --- a/tests/test__helpers.py +++ b/tests/test__helpers.py @@ -93,3 +93,30 @@ def test_update_query_remove_param(): uri = base_uri + '?x=a' updated = _helpers.update_query(uri, {'y': 'c'}, remove=['x']) _assert_query(updated, {'y': ['c']}) + + +def test_scopes_to_string(): + cases = [ + ('', ()), + ('', []), + ('', ('',)), + ('', ['', ]), + ('a', ('a',)), + ('b', ['b', ]), + ('a b', ['a', 'b']), + ('a b', ('a', 'b')), + ('a b', (s for s in ['a', 'b'])), + ] + for expected, case in cases: + assert _helpers.scopes_to_string(case) == expected + + +def test_string_to_scopes(): + cases = [ + ('', []), + ('a', ['a']), + ('a b c d e f', ['a', 'b', 'c', 'd', 'e', 'f']), + ] + + for case, expected in cases: + assert _helpers.string_to_scopes(case) == expected diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 000000000..d78c554d5 --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,94 @@ +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +from google.auth import credentials + + +class CredentialsImpl(credentials.Credentials): + def refresh(self, request): + self.token = request + + +def test_credentials_constructor(): + credentials = CredentialsImpl() + assert not credentials.token + assert not credentials.expiry + assert not credentials.expired + assert not credentials.valid + + +def test_expired_and_valid(): + credentials = CredentialsImpl() + credentials.token = 'token' + + assert credentials.valid + assert not credentials.expired + + credentials.expiry = ( + datetime.datetime.utcnow() - datetime.timedelta(seconds=60)) + + assert not credentials.valid + assert credentials.expired + + +def test_before_request(): + credentials = CredentialsImpl() + request = 'token' + headers = {} + + # First call should call refresh, setting the token. + credentials.before_request(request, 'http://example.com', 'GET', headers) + assert credentials.valid + assert credentials.token == 'token' + assert headers['authorization'] == 'Bearer token' + + request = 'token2' + headers = {} + + # Second call shouldn't call refresh. + credentials.before_request(request, 'http://example.com', 'GET', headers) + assert credentials.valid + assert credentials.token == 'token' + assert headers['authorization'] == 'Bearer token' + + +class ScopedCredentialsImpl(credentials.Scoped, CredentialsImpl): + @property + def requires_scopes(self): + return super(ScopedCredentialsImpl, self).requires_scopes + + def with_scopes(self, scopes): + raise NotImplementedError + + +def test_scoped_credentials_constructor(): + credentials = ScopedCredentialsImpl() + assert credentials._scopes is None + + +def test_scoped_credentials_scopes(): + credentials = ScopedCredentialsImpl() + credentials._scopes = ['one', 'two'] + assert credentials.scopes == ['one', 'two'] + assert credentials.has_scopes(['one']) + assert credentials.has_scopes(['two']) + assert credentials.has_scopes(['one', 'two']) + assert not credentials.has_scopes(['three']) + + +def test_scoped_credentials_requires_scopes(): + credentials = ScopedCredentialsImpl() + assert not credentials.requires_scopes