Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add google.auth.credentials - the public interfaces for credentials #8

Merged
merged 8 commits into from
Oct 14, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/reference/google.auth.credentials.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
google.auth.credentials module
==============================

.. automodule:: google.auth.credentials
:members:
:undoc-members:
:show-inheritance:
1 change: 1 addition & 0 deletions docs/reference/google.auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Submodules

.. toctree::

google.auth.credentials
google.auth.crypt
google.auth.exceptions
google.auth.jwt
Expand Down
29 changes: 28 additions & 1 deletion google/auth/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

"""Helper functions for commonly used utilities."""


import calendar
import datetime

Expand Down Expand Up @@ -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(' ')
205 changes: 205 additions & 0 deletions google/auth/credentials.py
Original file line number Diff line number Diff line change
@@ -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

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

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

This comment was marked as spam.

This comment was marked as spam.


@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.')
27 changes: 27 additions & 0 deletions tests/test__helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
94 changes: 94 additions & 0 deletions tests/test_credentials.py
Original file line number Diff line number Diff line change
@@ -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