diff --git a/google/auth/app_engine.py b/google/auth/app_engine.py new file mode 100644 index 000000000..6f32b239b --- /dev/null +++ b/google/auth/app_engine.py @@ -0,0 +1,90 @@ +# 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. + +"""Google App Engine standard environment credentials. + +This module provides authentication for application running on App Engine in +the standard environment using the `App Identity API`_. + + +.. _App Identity API: + https://cloud.google.com/appengine/docs/python/appidentity/ +""" + +import datetime + +from google.auth import _helpers +from google.auth import credentials + +try: + from google.appengine.api import app_identity +except ImportError: + app_identity = None + + +class Credentials(credentials.Scoped, credentials.Signing, + credentials.Credentials): + """App Engine standard environment credentials. + + These credentials use the App Engine App Identity API to obtain access + tokens. + """ + + def __init__(self, scopes=None, service_account_id=None): + """ + Args: + scopes (Sequence[str]): Scopes to request from the App Identity + API. + service_account_id (str): The service account ID passed into + :func:`google.appengine.api.app_identity.get_access_token`. + If not specified, the default application service account + ID will be used. + + Raises: + EnvironmentError: If the App Engine APIs are unavailable. + """ + if app_identity is None: + raise EnvironmentError( + 'The App Engine APIs are not available.') + + super(Credentials, self).__init__() + self._scopes = scopes + self._service_account_id = service_account_id + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + # pylint: disable=unused-argument + token, ttl = app_identity.get_access_token( + self._scopes, self._service_account_id) + expiry = _helpers.utcnow() + datetime.timedelta(seconds=ttl) + + self.token, self.expiry = token, expiry + + @property + def requires_scopes(self): + """Checks if the credentials requires scopes. + + Returns: + bool: True if there are no scopes set otherwise False. + """ + return not self._scopes + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes): + return Credentials( + scopes=scopes, service_account_id=self._service_account_id) + + @_helpers.copy_docstring(credentials.Signing) + def sign_bytes(self, message): + return app_identity.sign_blob(message) diff --git a/tests/test_app_engine.py b/tests/test_app_engine.py new file mode 100644 index 000000000..a9844b080 --- /dev/null +++ b/tests/test_app_engine.py @@ -0,0 +1,88 @@ +# 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 + +import mock +import pytest + +from google.auth import app_engine + + +@pytest.fixture +def app_identity_mock(monkeypatch): + """Mocks the app_identity module for google.auth.app_engine.""" + app_identity_mock = mock.Mock() + monkeypatch.setattr( + app_engine, 'app_identity', app_identity_mock) + yield app_identity_mock + + +class TestCredentials(object): + def test_missing_apis(self): + with pytest.raises(EnvironmentError) as excinfo: + app_engine.Credentials() + + assert excinfo.match(r'App Engine APIs are not available') + + def test_default_state(self, app_identity_mock): + credentials = app_engine.Credentials() + + # Not token acquired yet + assert not credentials.valid + # Expiration hasn't been set yet + assert not credentials.expired + # Scopes are required + assert not credentials.scopes + assert credentials.requires_scopes + + def test_with_scopes(self, app_identity_mock): + credentials = app_engine.Credentials() + + assert not credentials.scopes + assert credentials.requires_scopes + + scoped_credentials = credentials.with_scopes(['email']) + + assert scoped_credentials.has_scopes(['email']) + assert not scoped_credentials.requires_scopes + + @mock.patch( + 'google.auth._helpers.utcnow', + return_value=datetime.datetime.min) + def test_refresh(self, now_mock, app_identity_mock): + token = 'token' + ttl = 100 + app_identity_mock.get_access_token.return_value = token, ttl + credentials = app_engine.Credentials(scopes=['email']) + + credentials.refresh(None) + + app_identity_mock.get_access_token.assert_called_with( + credentials.scopes, credentials._service_account_id) + assert credentials.token == token + assert credentials.expiry == ( + datetime.datetime.min + datetime.timedelta(seconds=ttl)) + assert credentials.valid + assert not credentials.expired + + def test_sign_bytes(self, app_identity_mock): + app_identity_mock.sign_blob.return_value = mock.sentinel.signature + credentials = app_engine.Credentials() + to_sign = b'123' + + signature = credentials.sign_bytes(to_sign) + + assert signature == mock.sentinel.signature + app_identity_mock.sign_blob.assert_called_with(to_sign)