Repository: GoogleCloudPlatform/google-auth-library-python
Pull request #8 (2016-10-04 21:45:50)

Add google.auth.credentials - the public interfaces for credentials
@nathanielmanistaatgoogle this is the section you indicated you wanted to give input on. PTAL.

jonparrott: I am unsure if I should leave the `scopes` property as is (completely private) or make it a public read-only property.
dhermes: I vote for public read-only. I'm holding off a review here until you tell me to look @jonparrott 
jonparrott: Sgtm, gonna let Nathaniel chime in first.

On Tue, Oct 4, 2016, 3:08 PM Danny Hermes <notifications@github.com> wrote:

> I vote for public read-only. I'm holding off a review here until you tell
> me to look @jonparrott <https://github.com/jonparrott>
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/GoogleCloudPlatform/google-auth-library-python/pull/8#issuecomment-251528239>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AAPUc-0omIhmwrF0HlyVl8t7Mu8-MKApks5qws5igaJpZM4KOMdL>
> .
>

jonparrott: @nathanielmanistaatgoogle ping, this is now blocking.
dhermes: @jonparrott rebase?
jonparrott: @dhermes doing it now.
jonparrott: Rebased.

Patch:
diff --git a/docs/reference/google.auth.credentials.rst b/docs/reference/google.auth.credentials.rst
new file mode 100644
index 0000000..646fa7b
--- /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 ef0d689..78f7786 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 d4bf8f1..9c49e06 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 0000000..b10e63e
--- /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 <refresh>` 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/tox.ini b/tox.ini
index ce942f1..6517828 100644
--- a/tox.ini
+++ b/tox.ini
@@ -26,6 +26,8 @@ basepython = python3.5
 deps =
   {[testenv]deps}
   sphinx
+setenv =
+  SPHINX_APIDOC_OPTIONS=members,inherited-members,show-inheritance
 commands =
   rm -r docs/reference
   sphinx-apidoc --output-dir docs/reference --separate --module-first google


Test patch:
diff --git a/tests/test__helpers.py b/tests/test__helpers.py
index d475fc4..86cacef 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 0000000..d78c554
--- /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