diff --git a/docs/reference/google.oauth2.service_account.rst b/docs/reference/google.oauth2.service_account.rst new file mode 100644 index 000000000..cc4e43899 --- /dev/null +++ b/docs/reference/google.oauth2.service_account.rst @@ -0,0 +1,7 @@ +google.oauth2.service_account module +==================================== + +.. automodule:: google.oauth2.service_account + :members: + :inherited-members: + :show-inheritance: diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py new file mode 100644 index 000000000..4c4c1c03a --- /dev/null +++ b/google/oauth2/service_account.py @@ -0,0 +1,278 @@ +# 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. + +"""Service Accounts: JSON Web Token (JWT) Profile for OAuth 2.0 + +This module implements the JWT Profile for OAuth 2.0 Authorization Grants +as defined by `RFC 7523`_ with particular support for how this RFC is +implemented in Google's infrastructure. Google refers to these credentials +as *Service Accounts*. + +Service accounts are used for server-to-server communication, such as +interactions between a web application server and a Google service. The +service account belongs to your application instead of to an individual end +user. In contrast to other OAuth 2.0 profiles, no users are involved and your +application "acts" as the service account. + +Typically an application uses a service account when the application uses +Google APIs to work with its own data rather than a user's data. For example, +an application that uses Google Cloud Datastore for data persistence would use +a service account to authenticate its calls to the Google Cloud Datastore API. +However, an application that needs to access a user's Drive documents would +use the normal OAuth 2.0 profile. + +Additionally, Google Apps domain administrators can grant service accounts +`domain-wide delegation`_ authority to access user data on behalf of users in +the domain. + +This profile uses a JWT to acquire an OAuth 2.0 access token. The JWT is used +in place of the usual authorization token returned during the standard +OAuth 2.0 Authorization Code grant. The JWT is only used for this purpose, as +the acquired access token is used as the bearer token when making requests +using these credentials. + +This profile differs from normal OAuth 2.0 profile because no user consent +step is required. The use of the private key allows this profile to assert +identity directly. + +This profile also differs from the :mod:`google.auth.jwt` authentication +because the JWT credentials use the JWT directly as the bearer token. This +profile instead only uses the JWT to obtain an OAuth 2.0 access token. The +obtained OAuth 2.0 access token is used as the bearer token. + +Domain-wide delegation +---------------------- + +Domain-wide delegation allows a service account to access user data on +behalf of any user in a Google Apps domain without consent from the user. +For example, an application that uses the Google Calendar API to add events to +the calendars of all users in a Google Apps domain would use a service account +to access the Google Calendar API on behalf of users. + +The Google Apps administrator must explicitly authorize the service account to +do this. This authorization step is referred to as "delegating domain-wide +authority" to a service account. + +You can use domain-wise delegation by creating a set of credentials with a +specific subject using :meth:`~Credentials.with_subject`. + +.. _RFC 7523: https://tools.ietf.org/html/rfc7523 +""" + +import datetime +import io +import json + +from google.auth import _helpers +from google.auth import credentials +from google.auth import crypt +from google.auth import jwt +from google.oauth2 import _client + +_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in sections + + +class Credentials(credentials.Signing, + credentials.Scoped, + credentials.Credentials): + """Service account credentials + + Usually, you'll create these credentials with one of the helper + constructors. To create credentials using a Google service account + private key JSON file:: + + credentials = service_account.Credentials.from_service_account_file( + 'service-account.json') + + Or if you already have the service account file loaded:: + + service_account_info = json.load(open('service_account.json')) + credentials = service_account.Credentials.from_service_account_info( + service_account_info) + + Both helper methods pass on arguments to the constructor, so you can + specify additional scopes and a subject if necessary:: + + credentials = service_account.Credentials.from_service_account_file( + 'service-account.json', + scopes=['email'], + subject='user@example.com') + + The credentials are considered immutable. If you want to modify the scopes + or the subject used for delegation, use :meth:`with_scopes` or + :meth:`with_subject`:: + + scoped_credentials = credentials.with_scopes(['email']) + delegated_credentials = credentials.with_subject(subject) + """ + + def __init__(self, signer, service_account_email, token_uri, scopes=None, + subject=None, additional_claims=None): + """ + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + service_account_email (str): The service account's email. + scopes (Sequence[str]): Scopes to request during the authorization + grant. + token_uri (str): The OAuth 2.0 Token URI. + subject (str): For domain-wide delegation, the email address of the + user to for which to request delegated access. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT assertion used in the authorization grant. + + .. note:: Typically one of the helper constructors + :meth:`from_service_account_file` or + :meth:`from_service_account_info` are used instead of calling the + constructor directly. + """ + super(Credentials, self).__init__() + + self._scopes = scopes + self._signer = signer + self._service_account_email = service_account_email + self._subject = subject + self._token_uri = token_uri + + if additional_claims is not None: + self._additional_claims = additional_claims + else: + self._additional_claims = {} + + @classmethod + def from_service_account_info(cls, info, **kwargs): + """Creates a Credentials instance from parsed service account info. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.service_account.Credentials: The constructed + credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + try: + email = info['client_email'] + key_id = info['private_key_id'] + private_key = info['private_key'] + token_uri = info['token_uri'] + except KeyError: + raise ValueError( + 'Service account info was not in the expected format.') + + signer = crypt.Signer.from_string(private_key, key_id) + + return cls( + signer, service_account_email=email, token_uri=token_uri, **kwargs) + + @classmethod + def from_service_account_file(cls, filename, **kwargs): + """Creates a Credentials instance from a service account json file. + + Args: + filename (str): The path to the service account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.service_account.Credentials: The constructed + credentials. + """ + with io.open(filename, 'r', encoding='utf-8') as json_file: + info = json.load(json_file) + return cls.from_service_account_info(info, **kwargs) + + @property + def requires_scopes(self): + """Checks if the credentials requires scopes. + + Returns: + bool: True if there are no scopes set otherwise False. + """ + return True if not self._scopes else False + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes): + return Credentials( + self._signer, + service_account_email=self._service_account_email, + scopes=scopes, + token_uri=self._token_uri, + subject=self._subject, + additional_claims=self._additional_claims.copy()) + + def with_subject(self, subject): + """Create a copy of these credentials with the specified subject. + + Args: + subject (str): The subject claim. + + Returns: + google.auth.service_account.Credentials: A new credentials + instance. + """ + return Credentials( + self._signer, + service_account_email=self._service_account_email, + scopes=self._scopes, + token_uri=self._token_uri, + subject=subject, + additional_claims=self._additional_claims.copy()) + + def _make_authorization_grant_assertion(self): + """Create the OAuth 2.0 assertion. + + This assertion is used during the OAuth 2.0 grant to acquire an + access token. + + Returns: + bytes: The authorization grant assertion. + """ + now = _helpers.utcnow() + lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS) + expiry = now + lifetime + + payload = { + 'iat': _helpers.datetime_to_secs(now), + 'exp': _helpers.datetime_to_secs(expiry), + # The issuer must be the service account email. + 'iss': self._service_account_email, + # The audience must be the auth token endpoint's URI + 'aud': self._token_uri, + 'scope': _helpers.scopes_to_string(self._scopes or ()) + } + + payload.update(self._additional_claims) + + # The subject can be a user email for domain-wide delegation. + if self._subject: + payload.setdefault('sub', self._subject) + + token = jwt.encode(self._signer, payload) + + return token + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + assertion = self._make_authorization_grant_assertion() + access_token, expiry, _ = _client.jwt_grant( + request, self._token_uri, assertion) + self.token = access_token + self.expiry = expiry + + @_helpers.copy_docstring(credentials.Signing) + def sign_bytes(self, message): + return self._signer.sign(message) diff --git a/tests/data/service_account.json b/tests/data/service_account.json new file mode 100644 index 000000000..9e76f4d35 --- /dev/null +++ b/tests/data/service_account.json @@ -0,0 +1,10 @@ +{ + "type": "service_account", + "project_id": "example-project", + "private_key_id": "1", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj\n7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/\nxmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs\nSliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18\npe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk\nSBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk\nnQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq\nHD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y\nnHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9\nIisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2\nYCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU\nZ422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ\nvzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP\nB8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl\naLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2\neCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI\naqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk\nklORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ\nCFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu\nUqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg\nsoBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28\nbvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH\n504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL\nYXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx\nBeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg==\n-----END RSA PRIVATE KEY-----\n", + "client_email": "service-account@example.com", + "client_id": "1234", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token" +} diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py new file mode 100644 index 000000000..473d440da --- /dev/null +++ b/tests/oauth2/test_service_account.py @@ -0,0 +1,209 @@ +# 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 json +import os + +import mock +import pytest + +from google.auth import _helpers +from google.auth import crypt +from google.auth import jwt +from google.oauth2 import service_account + + +DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data') + +with open(os.path.join(DATA_DIR, 'privatekey.pem'), 'rb') as fh: + PRIVATE_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, 'public_cert.pem'), 'rb') as fh: + PUBLIC_CERT_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, 'other_cert.pem'), 'rb') as fh: + OTHER_CERT_BYTES = fh.read() + +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, 'service_account.json') + +with open(SERVICE_ACCOUNT_JSON_FILE, 'r') as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + + +@pytest.fixture(scope='module') +def signer(): + return crypt.Signer.from_string(PRIVATE_KEY_BYTES, '1') + + +class TestCredentials(object): + SERVICE_ACCOUNT_EMAIL = 'service-account@example.com' + TOKEN_URI = 'https://example.com/oauth2/token' + credentials = None + + @pytest.fixture(autouse=True) + def credentials_fixture(self, signer): + self.credentials = service_account.Credentials( + signer, self.SERVICE_ACCOUNT_EMAIL, self.TOKEN_URI) + + def test_from_service_account_info(self): + with open(SERVICE_ACCOUNT_JSON_FILE, 'r') as fh: + info = json.load(fh) + + credentials = service_account.Credentials.from_service_account_info( + info) + + assert credentials._signer.key_id == info['private_key_id'] + assert credentials._service_account_email == info['client_email'] + assert credentials._token_uri == info['token_uri'] + + def test_from_service_account_info_args(self): + info = SERVICE_ACCOUNT_INFO.copy() + scopes = ['email', 'profile'] + subject = 'subject' + additional_claims = {'meta': 'data'} + + credentials = service_account.Credentials.from_service_account_info( + info, scopes=scopes, subject=subject, + additional_claims=additional_claims) + + assert credentials._signer.key_id == info['private_key_id'] + assert credentials._service_account_email == info['client_email'] + assert credentials._token_uri == info['token_uri'] + assert credentials._scopes == scopes + assert credentials._subject == subject + assert credentials._additional_claims == additional_claims + + def test_from_service_account_bad_key(self): + info = SERVICE_ACCOUNT_INFO.copy() + info['private_key'] = 'garbage' + + with pytest.raises(ValueError) as excinfo: + service_account.Credentials.from_service_account_info(info) + + assert excinfo.match(r'No key could be detected') + + def test_from_service_account_bad_format(self): + with pytest.raises(ValueError): + service_account.Credentials.from_service_account_info({}) + + def test_from_service_account_file(self): + info = SERVICE_ACCOUNT_INFO.copy() + + credentials = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE) + + assert credentials._signer.key_id == info['private_key_id'] + assert credentials._service_account_email == info['client_email'] + assert credentials._token_uri == info['token_uri'] + + def test_from_service_account_file_args(self): + info = SERVICE_ACCOUNT_INFO.copy() + scopes = ['email', 'profile'] + subject = 'subject' + additional_claims = {'meta': 'data'} + + credentials = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE, subject=subject, + scopes=scopes, additional_claims=additional_claims) + + assert credentials._signer.key_id == info['private_key_id'] + assert credentials._service_account_email == info['client_email'] + assert credentials._token_uri == info['token_uri'] + assert credentials._scopes == scopes + assert credentials._subject == subject + assert credentials._additional_claims == additional_claims + + def test_default_state(self): + assert not self.credentials.valid + # Expiration hasn't been set yet + assert not self.credentials.expired + # Scopes haven't been specified yet + assert self.credentials.requires_scopes + + def test_sign_bytes(self): + to_sign = b'123' + signature = self.credentials.sign_bytes(to_sign) + assert crypt.verify_signature(to_sign, signature, PUBLIC_CERT_BYTES) + + def test_create_scoped(self): + scopes = ['email', 'profile'] + credentials = self.credentials.with_scopes(scopes) + assert credentials._scopes == scopes + + def test__make_authorization_grant_assertion(self): + token = self.credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, PUBLIC_CERT_BYTES) + assert payload['iss'] == self.SERVICE_ACCOUNT_EMAIL + assert payload['aud'] == self.TOKEN_URI + + def test__make_authorization_grant_assertion_scoped(self): + scopes = ['email', 'profile'] + credentials = self.credentials.with_scopes(scopes) + token = credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, PUBLIC_CERT_BYTES) + assert payload['scope'] == 'email profile' + + def test__make_authorization_grant_assertion_subject(self): + subject = 'user@example.com' + credentials = self.credentials.with_subject(subject) + token = credentials._make_authorization_grant_assertion() + payload = jwt.decode(token, PUBLIC_CERT_BYTES) + assert payload['sub'] == subject + + @mock.patch('google.oauth2._client.jwt_grant') + def test_refresh_success(self, jwt_grant_mock): + token = 'token' + jwt_grant_mock.return_value = ( + token, _helpers.utcnow() + datetime.timedelta(seconds=500), None) + request_mock = mock.Mock() + + # Refresh credentials + self.credentials.refresh(request_mock) + + # Check jwt grant call. + assert jwt_grant_mock.called + request, token_uri, assertion = jwt_grant_mock.call_args[0] + assert request == request_mock + assert token_uri == self.credentials._token_uri + assert jwt.decode(assertion, PUBLIC_CERT_BYTES) + # No further assertion done on the token, as there are separate tests + # for checking the authorization grant assertion. + + # Check that the credentials have the token. + assert self.credentials.token == token + + # Check that the credentials are valid (have a token and are not + # expired) + assert self.credentials.valid + + @mock.patch('google.oauth2._client.jwt_grant') + def test_before_request_refreshes(self, jwt_grant_mock): + token = 'token' + jwt_grant_mock.return_value = ( + token, _helpers.utcnow() + datetime.timedelta(seconds=500), None) + request_mock = mock.Mock() + + # Credentials should start as invalid + assert not self.credentials.valid + + # before_request should cause a refresh + self.credentials.before_request( + request_mock, 'GET', 'http://example.com?a=1#3', {}) + + # The refresh endpoint should've been called. + assert jwt_grant_mock.called + + # Credentials should now be valid. + assert self.credentials.valid