From bf19834a3e1270cdbb2a0cedd3f42f25691247d3 Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Fri, 14 Oct 2016 13:41:00 -0700 Subject: [PATCH 1/7] Add jwt.Credentials --- google/auth/jwt.py | 232 ++++++++++++++++++++++++++++++++ tests/data/service_account.json | 10 ++ tests/test_jwt.py | 149 ++++++++++++++++++++ 3 files changed, 391 insertions(+) create mode 100644 tests/data/service_account.json diff --git a/google/auth/jwt.py b/google/auth/jwt.py index f68fc4b4d..e173e1b13 100644 --- a/google/auth/jwt.py +++ b/google/auth/jwt.py @@ -42,8 +42,12 @@ import base64 import collections +import datetime import json +from six.moves import urllib + +from google.auth import credentials from google.auth import crypt from google.auth import _helpers @@ -234,3 +238,231 @@ def decode(token, certs=None, verify=True, audience=None): claim_audience, audience)) return payload + + +class Credentials(credentials.Signing, + credentials.Credentials): + """Credentials that use a JWT as the bearer token. + + The constructor arguments determine the claims for the JWT that is + sent with requests. Usually, you'll construct these credentials with + one of the helper constructors. + + To create JWT credentials using a Google service account private key + JSON file:: + + credentials = jwt.Credentials.from_service_account_file( + 'service-account.json') + + If you already have the service account file loaded and parsed:: + + service_account_info = json.load(open('service_account.json')) + credentials = jwt.Credentials.from_service_account_info( + service_account_info) + + Both helper methods pass on arguments to the constructor, so you can + specify the JWT claims:: + + credentials = jwt.Credentials.from_service_account_file( + 'service-account.json', + audience='https://speech.googleapis.com', + additional_claims={'meta': 'data'}) + + You can also construct the credentials directly if you have a + :class:`~google.auth.crypt.Signer` instance:: + + credentials = jwt.Credentials( + signer, issuer='your-issuer', subject='your-subject') + + The claims are considered immutable. If you want to modify the claims, + you can easily create another instance using :meth:`with_claims`:: + + new_credentials = credentials.with_claims( + audience='https://vision.googleapis.com') + + Note that JWT credentials will also set the audience claim on demand. If no + audience is specified when creating the credentials, then whenever a + request is made the credentials will automatically generate a one-time + JWT with the request URI as the audience. + """ + + def __init__(self, signer, issuer=None, subject=None, audience=None, + additional_claims=None, + token_lifetime=_DEFAULT_TOKEN_LIFETIME_SECS): + """ + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + issuer (str): The `iss` claim. + subject (str): The `sub` claim. + audience (str): the `aud` claim. If not specified, a new + JWT will be generated for every request and will use + the request URI as the audience. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. + token_lifetime (int): The amount of time in seconds for + which the token is valid. Defaults to 1 hour. + """ + super(Credentials, self).__init__() + self._signer = signer + self._issuer = issuer + self._subject = subject + self._audience = audience + self._additional_claims = additional_claims or {} + self._token_lifetime = token_lifetime + + @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.jwt.Credentials: The constructed credentials. + """ + email = info['client_email'] + key_id = info['private_key_id'] + private_key = info['private_key'] + + signer = crypt.Signer.from_string(private_key, key_id) + + kwargs.setdefault('subject', email) + return cls(signer, issuer=email, **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.jwt.Credentials: The constructed credentials. + """ + with open(filename, 'r') as json_file: + info = json.load(json_file) + return cls.from_service_account_info(info, **kwargs) + + def with_claims(self, issuer=None, subject=None, audience=None, + additional_claims=None): + """Returns a copy of these credentials with modified claims. + + Args: + issuer (str): The `iss` claim. If unspecified the current issuer + claim will be used. + subject (str): The `sub` claim. If unspecified the current subject + claim will be used. + audience (str): the `aud` claim. If not specified, a new + JWT will be generated for every request and will use + the request URI as the audience. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. This will be merged with the current + additional claims. + + Returns: + google.auth.jwt.Credentials: A new credentials instance. + """ + return Credentials( + self._signer, + issuer=issuer if issuer is not None else self._issuer, + subject=subject if subject is not None else self._subject, + audience=audience if audience is not None else self._audience, + additional_claims=dict(self._additional_claims).update( + additional_claims or {})) + + def _make_jwt(self, audience=None): + """Make a signed JWT. + + Args: + audience (str): Overrides the instance's current audience claim. + + Returns: + Tuple(bytes, datetime): The encoded JWT and the expiration. + """ + now = _helpers.utcnow() + lifetime = datetime.timedelta(seconds=self._token_lifetime) + expiry = now + lifetime + + payload = { + 'iss': self._issuer, + 'sub': self._subject or self._issuer, + 'iat': _helpers.datetime_to_secs(now), + 'exp': _helpers.datetime_to_secs(expiry), + 'aud': audience or self._audience + } + + payload.update(self._additional_claims) + + jwt = encode(self._signer, payload) + + return jwt, expiry + + def _make_one_time_jwt(self, uri): + """Makes a one-off JWT with the URI as the audience. + + Args: + uri (str): The request URI. + + Returns: + bytes: The encoded JWT. + """ + parts = urllib.parse.urlsplit(uri) + # Strip query string and fragment + audience = urllib.parse.urlunsplit( + (parts.scheme, parts.netloc, parts.path, None, None)) + token, _ = self._make_jwt(audience=audience) + return token + + def refresh(self, request): + """Refreshes the access token. + + Args: + request (Any): Unused. + """ + # pylint: disable=unused-argument + # (pylint doens't correctly recognize overriden methods.) + + self.token, self.expiry = self._make_jwt() + + def sign_bytes(self, message): + """Signs the given message. + + Args: + message (bytes): The message to sign. + + Returns: + bytes: The message signature. + """ + return self._signer.sign(message) + + def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + + If an audience is specified it will refresh the credentials if + necessary. If no audience is specified it will generate a one-time + token for the request URI. In either case, it will set the + authorization header in headers to the token. + + Args: + request (Any): Unused. + method (str): The request's HTTP method. + url (str): The request's URI. + headers (Mapping): The request's headers. + """ + # pylint: disable=unused-argument + # (pylint doens't correctly recognize overriden methods.) + + # If this set of credentials has a pre-set audience, just ensure that + # there is a valid token and apply the auth headers. + if self._audience: + if not self.valid: + self.refresh(request) + self.apply(headers) + # Otherwise, generate a one-time token using the URL + # (without the query string and fragment) as the audience. + else: + token = self._make_one_time_jwt(url) + self.apply(headers, token=token) 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/test_jwt.py b/tests/test_jwt.py index 69628e5c9..f28de69a1 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -14,8 +14,10 @@ import base64 import datetime +import json import os +import mock import pytest from google.auth import _helpers @@ -34,6 +36,11 @@ 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 def signer(): @@ -187,3 +194,145 @@ def test_roundtrip_explicit_key_id(token_factory): certs = {'2': OTHER_CERT_BYTES, '3': PUBLIC_CERT_BYTES} payload = jwt.decode(token, certs) assert payload['user'] == 'billy bob' + + +class TestCredentials: + service_account_email = 'service-account@example.com' + + @pytest.fixture(autouse=True) + def credentials(self, signer): + self.credentials = jwt.Credentials( + signer, self.service_account_email) + + def test_from_service_account_info(self): + with open(SERVICE_ACCOUNT_JSON_FILE, 'r') as fh: + info = json.load(fh) + + credentials = jwt.Credentials.from_service_account_info(info) + + assert credentials._signer.key_id == info['private_key_id'] + assert credentials._issuer == info['client_email'] + assert credentials._subject == info['client_email'] + + def test_from_service_account_info_args(self): + info = dict(SERVICE_ACCOUNT_INFO) + + credentials = jwt.Credentials.from_service_account_info( + info, subject='subject', audience='audience', + additional_claims={'meta': 'data'}) + + assert credentials._signer.key_id == info['private_key_id'] + assert credentials._issuer == info['client_email'] + assert credentials._subject == 'subject' + assert credentials._audience == 'audience' + assert credentials._additional_claims['meta'] == 'data' + + def test_from_service_account_bad_key(self): + info = dict(SERVICE_ACCOUNT_INFO) + info['private_key'] = 'garbage' + + with pytest.raises(ValueError) as excinfo: + jwt.Credentials.from_service_account_info(info) + + assert excinfo.match(r'No key could be detected') + + def test_from_service_account_bad_format(self): + info = {} + + with pytest.raises(KeyError): + jwt.Credentials.from_service_account_info(info) + + def test_from_service_account_file(self): + info = dict(SERVICE_ACCOUNT_INFO) + + credentials = jwt.Credentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE) + + assert credentials._signer.key_id == info['private_key_id'] + assert credentials._issuer == info['client_email'] + assert credentials._subject == info['client_email'] + + def test_from_service_account_file_args(self): + info = dict(SERVICE_ACCOUNT_INFO) + + credentials = jwt.Credentials.from_service_account_file( + SERVICE_ACCOUNT_JSON_FILE, subject='subject', audience='audience', + additional_claims={'meta': 'data'}) + + assert credentials._signer.key_id == info['private_key_id'] + assert credentials._issuer == info['client_email'] + assert credentials._subject == 'subject' + assert credentials._audience == 'audience' + assert credentials._additional_claims['meta'] == 'data' + + def test_default_state(self): + assert not self.credentials.valid + # Expiration hasn't been set yet + assert not self.credentials.expired + + def test_sign_bytes(self): + to_sign = b'123' + signature = self.credentials.sign_bytes(to_sign) + crypt.verify_signature(to_sign, signature, PUBLIC_CERT_BYTES) + + def _verify_token(self, token): + payload = jwt.decode(token, PUBLIC_CERT_BYTES) + assert payload['iss'] == self.service_account_email + return payload + + def test_refresh(self): + self.credentials.refresh(None) + assert self.credentials.valid + assert not self.credentials.expired + + def test_expired(self): + assert not self.credentials.expired + + self.credentials.refresh(None) + assert not self.credentials.expired + + with mock.patch('google.auth._helpers.utcnow') as now: + one_day_from_now = datetime.timedelta(days=1) + now.return_value = self.credentials.expiry + one_day_from_now + assert self.credentials.expired + + def test_before_request_one_time_token(self): + headers = {} + + self.credentials.refresh(None) + self.credentials.before_request( + mock.Mock(), 'GET', 'http://example.com?a=1#3', headers) + + header_value = headers['authorization'] + token = header_value.split().pop() + + # This should be a one-off token, so it shouldn't be the same as the + # credentials' stored token. + assert token != self.credentials.token + + payload = self._verify_token(token) + assert payload['aud'] == 'http://example.com' + + def test_before_request_set_audience(self): + headers = {} + + credentials = self.credentials.with_claims(audience='test') + credentials.refresh(None) + credentials.before_request( + None, 'GET', 'http://example.com?a=1#3', headers) + + header_value = headers['authorization'] + token = header_value.split().pop() + + # Since the audience is set, it should use the existing token. + assert token.encode('utf-8') == credentials.token + + payload = self._verify_token(token) + assert payload['aud'] == 'test' + + def test_before_request_refreshes(self): + credentials = self.credentials.with_claims(audience='test') + assert not credentials.valid + credentials.before_request( + None, 'GET', 'http://example.com?a=1#3', {}) + assert credentials.valid From 39d54d09bf60d34c2e594089d75c73b3a0d00897 Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Sat, 15 Oct 2016 12:24:37 -0700 Subject: [PATCH 2/7] Use io.open and catch keyerrors --- google/auth/jwt.py | 14 ++++++++++---- tests/test_jwt.py | 6 ++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/google/auth/jwt.py b/google/auth/jwt.py index e173e1b13..64843f906 100644 --- a/google/auth/jwt.py +++ b/google/auth/jwt.py @@ -43,6 +43,7 @@ import base64 import collections import datetime +import io import json from six.moves import urllib @@ -322,9 +323,14 @@ def from_service_account_info(cls, info, **kwargs): Returns: google.auth.jwt.Credentials: The constructed credentials. """ - email = info['client_email'] - key_id = info['private_key_id'] - private_key = info['private_key'] + + try: + email = info['client_email'] + key_id = info['private_key_id'] + private_key = info['private_key'] + except KeyError: + raise ValueError( + 'Service account info was not in the expected format.') signer = crypt.Signer.from_string(private_key, key_id) @@ -342,7 +348,7 @@ def from_service_account_file(cls, filename, **kwargs): Returns: google.auth.jwt.Credentials: The constructed credentials. """ - with open(filename, 'r') as json_file: + with io.open(filename, 'r', encoding='utf-8') as json_file: info = json.load(json_file) return cls.from_service_account_info(info, **kwargs) diff --git a/tests/test_jwt.py b/tests/test_jwt.py index f28de69a1..059c4386e 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -237,10 +237,8 @@ def test_from_service_account_bad_key(self): assert excinfo.match(r'No key could be detected') def test_from_service_account_bad_format(self): - info = {} - - with pytest.raises(KeyError): - jwt.Credentials.from_service_account_info(info) + with pytest.raises(ValueError): + jwt.Credentials.from_service_account_info({}) def test_from_service_account_file(self): info = dict(SERVICE_ACCOUNT_INFO) From 743c4b7a18e4e65a45a914666f43b1f599fc5b58 Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Sat, 15 Oct 2016 12:34:53 -0700 Subject: [PATCH 3/7] Fix docstring --- google/auth/jwt.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/google/auth/jwt.py b/google/auth/jwt.py index 64843f906..9c6a279d2 100644 --- a/google/auth/jwt.py +++ b/google/auth/jwt.py @@ -322,6 +322,9 @@ def from_service_account_info(cls, info, **kwargs): Returns: google.auth.jwt.Credentials: The constructed credentials. + + Raises: + ValueError: If the info is not in the expected format. """ try: From 954056bcfddd18dc0ae5bcc3e28a032b87bd4438 Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Mon, 17 Oct 2016 10:16:54 -0700 Subject: [PATCH 4/7] Address review comments --- google/auth/jwt.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/google/auth/jwt.py b/google/auth/jwt.py index 9c6a279d2..669869a0e 100644 --- a/google/auth/jwt.py +++ b/google/auth/jwt.py @@ -245,6 +245,13 @@ class Credentials(credentials.Signing, credentials.Credentials): """Credentials that use a JWT as the bearer token. + These credentials require an "audience" claim. This claim identifies the + intended recipient of the bearer token. You can set the audience when + you construct these credentials, however, these credentials can also set + the audience claim automatically if not specified. In this case, whenever + a request is made the credentials will automatically generate a one-time + JWT with the request URI as the audience. + The constructor arguments determine the claims for the JWT that is sent with requests. Usually, you'll construct these credentials with one of the helper constructors. @@ -280,11 +287,6 @@ class Credentials(credentials.Signing, new_credentials = credentials.with_claims( audience='https://vision.googleapis.com') - - Note that JWT credentials will also set the audience claim on demand. If no - audience is specified when creating the credentials, then whenever a - request is made the credentials will automatically generate a one-time - JWT with the request URI as the audience. """ def __init__(self, signer, issuer=None, subject=None, audience=None, @@ -295,9 +297,9 @@ def __init__(self, signer, issuer=None, subject=None, audience=None, signer (google.auth.crypt.Signer): The signer used to sign JWTs. issuer (str): The `iss` claim. subject (str): The `sub` claim. - audience (str): the `aud` claim. If not specified, a new - JWT will be generated for every request and will use - the request URI as the audience. + audience (str): the `aud` claim. The intended audience for the + credentials. If not specified, a new JWT will be generated for + every request and will use the request URI as the audience. additional_claims (Mapping[str, str]): Any additional claims for the JWT payload. token_lifetime (int): The amount of time in seconds for @@ -379,7 +381,7 @@ def with_claims(self, issuer=None, subject=None, audience=None, issuer=issuer if issuer is not None else self._issuer, subject=subject if subject is not None else self._subject, audience=audience if audience is not None else self._audience, - additional_claims=dict(self._additional_claims).update( + additional_claims=self._additional_claims.copy().update( additional_claims or {})) def _make_jwt(self, audience=None): @@ -400,7 +402,7 @@ def _make_jwt(self, audience=None): 'sub': self._subject or self._issuer, 'iat': _helpers.datetime_to_secs(now), 'exp': _helpers.datetime_to_secs(expiry), - 'aud': audience or self._audience + 'aud': audience or self._audience, } payload.update(self._additional_claims) @@ -432,8 +434,7 @@ def refresh(self, request): request (Any): Unused. """ # pylint: disable=unused-argument - # (pylint doens't correctly recognize overriden methods.) - + # (pylint doesn't correctly recognize overridden methods.) self.token, self.expiry = self._make_jwt() def sign_bytes(self, message): @@ -462,7 +463,7 @@ def before_request(self, request, method, url, headers): headers (Mapping): The request's headers. """ # pylint: disable=unused-argument - # (pylint doens't correctly recognize overriden methods.) + # (pylint doesn't correctly recognize overridden methods.) # If this set of credentials has a pre-set audience, just ensure that # there is a valid token and apply the auth headers. From da4500a28709636926cd8d57c1e2c73934fd6608 Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Mon, 17 Oct 2016 10:27:46 -0700 Subject: [PATCH 5/7] Address review comments --- tests/test_jwt.py | 60 +++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 059c4386e..204ffe423 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -197,12 +197,16 @@ def test_roundtrip_explicit_key_id(token_factory): class TestCredentials: - service_account_email = 'service-account@example.com' + SERVICE_ACCOUNT_EMAIL = 'service-account@example.com' + SUBJECT = 'subject' + AUDIENCE = 'audience' + ADDITIONAL_CLAIMS = {'meta': 'data'} + credentials = None @pytest.fixture(autouse=True) - def credentials(self, signer): + def credentials_fixture(self, signer): self.credentials = jwt.Credentials( - signer, self.service_account_email) + signer, self.SERVICE_ACCOUNT_EMAIL) def test_from_service_account_info(self): with open(SERVICE_ACCOUNT_JSON_FILE, 'r') as fh: @@ -215,20 +219,20 @@ def test_from_service_account_info(self): assert credentials._subject == info['client_email'] def test_from_service_account_info_args(self): - info = dict(SERVICE_ACCOUNT_INFO) + info = SERVICE_ACCOUNT_INFO.copy() credentials = jwt.Credentials.from_service_account_info( - info, subject='subject', audience='audience', - additional_claims={'meta': 'data'}) + info, subject=self.SUBJECT, audience=self.AUDIENCE, + additional_claims=self.ADDITIONAL_CLAIMS) assert credentials._signer.key_id == info['private_key_id'] assert credentials._issuer == info['client_email'] - assert credentials._subject == 'subject' - assert credentials._audience == 'audience' - assert credentials._additional_claims['meta'] == 'data' + assert credentials._subject == self.SUBJECT + assert credentials._audience == self.AUDIENCE + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS - def test_from_service_account_bad_key(self): - info = dict(SERVICE_ACCOUNT_INFO) + def test_from_service_account_bad_private_key(self): + info = SERVICE_ACCOUNT_INFO.copy() info['private_key'] = 'garbage' with pytest.raises(ValueError) as excinfo: @@ -241,7 +245,7 @@ def test_from_service_account_bad_format(self): jwt.Credentials.from_service_account_info({}) def test_from_service_account_file(self): - info = dict(SERVICE_ACCOUNT_INFO) + info = SERVICE_ACCOUNT_INFO.copy() credentials = jwt.Credentials.from_service_account_file( SERVICE_ACCOUNT_JSON_FILE) @@ -251,17 +255,17 @@ def test_from_service_account_file(self): assert credentials._subject == info['client_email'] def test_from_service_account_file_args(self): - info = dict(SERVICE_ACCOUNT_INFO) + info = SERVICE_ACCOUNT_INFO.copy() credentials = jwt.Credentials.from_service_account_file( - SERVICE_ACCOUNT_JSON_FILE, subject='subject', audience='audience', - additional_claims={'meta': 'data'}) + SERVICE_ACCOUNT_JSON_FILE, subject=self.SUBJECT, + audience=self.AUDIENCE, additional_claims=self.ADDITIONAL_CLAIMS) assert credentials._signer.key_id == info['private_key_id'] assert credentials._issuer == info['client_email'] - assert credentials._subject == 'subject' - assert credentials._audience == 'audience' - assert credentials._additional_claims['meta'] == 'data' + assert credentials._subject == self.SUBJECT + assert credentials._audience == self.AUDIENCE + assert credentials._additional_claims == self.ADDITIONAL_CLAIMS def test_default_state(self): assert not self.credentials.valid @@ -271,11 +275,11 @@ def test_default_state(self): def test_sign_bytes(self): to_sign = b'123' signature = self.credentials.sign_bytes(to_sign) - crypt.verify_signature(to_sign, signature, PUBLIC_CERT_BYTES) + assert crypt.verify_signature(to_sign, signature, PUBLIC_CERT_BYTES) def _verify_token(self, token): payload = jwt.decode(token, PUBLIC_CERT_BYTES) - assert payload['iss'] == self.service_account_email + assert payload['iss'] == self.SERVICE_ACCOUNT_EMAIL return payload def test_refresh(self): @@ -290,8 +294,8 @@ def test_expired(self): assert not self.credentials.expired with mock.patch('google.auth._helpers.utcnow') as now: - one_day_from_now = datetime.timedelta(days=1) - now.return_value = self.credentials.expiry + one_day_from_now + one_day = datetime.timedelta(days=1) + now.return_value = self.credentials.expiry + one_day assert self.credentials.expired def test_before_request_one_time_token(self): @@ -302,7 +306,7 @@ def test_before_request_one_time_token(self): mock.Mock(), 'GET', 'http://example.com?a=1#3', headers) header_value = headers['authorization'] - token = header_value.split().pop() + token = header_value.split('Bearer ').pop() # This should be a one-off token, so it shouldn't be the same as the # credentials' stored token. @@ -311,25 +315,25 @@ def test_before_request_one_time_token(self): payload = self._verify_token(token) assert payload['aud'] == 'http://example.com' - def test_before_request_set_audience(self): + def test_before_request_with_preset_audience(self): headers = {} - credentials = self.credentials.with_claims(audience='test') + credentials = self.credentials.with_claims(audience=self.AUDIENCE) credentials.refresh(None) credentials.before_request( None, 'GET', 'http://example.com?a=1#3', headers) header_value = headers['authorization'] - token = header_value.split().pop() + token = header_value.split('Bearer ').pop() # Since the audience is set, it should use the existing token. assert token.encode('utf-8') == credentials.token payload = self._verify_token(token) - assert payload['aud'] == 'test' + assert payload['aud'] == self.AUDIENCE def test_before_request_refreshes(self): - credentials = self.credentials.with_claims(audience='test') + credentials = self.credentials.with_claims(audience=self.AUDIENCE) assert not credentials.valid credentials.before_request( None, 'GET', 'http://example.com?a=1#3', {}) From f70eeae8e268c3d6e7da65f248103b33221996fd Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Mon, 17 Oct 2016 10:30:10 -0700 Subject: [PATCH 6/7] Slight revision to docstring --- google/auth/jwt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/auth/jwt.py b/google/auth/jwt.py index 669869a0e..394a7d528 100644 --- a/google/auth/jwt.py +++ b/google/auth/jwt.py @@ -254,7 +254,7 @@ class Credentials(credentials.Signing, The constructor arguments determine the claims for the JWT that is sent with requests. Usually, you'll construct these credentials with - one of the helper constructors. + one of the helper constructors as shown in the next section. To create JWT credentials using a Google service account private key JSON file:: From cc100227ece7093195c300dd8300877c79f7959a Mon Sep 17 00:00:00 2001 From: Jon Wayne Parrott Date: Mon, 17 Oct 2016 10:40:57 -0700 Subject: [PATCH 7/7] Use destructuring assignment to ensure header values are in the correct format --- tests/test_jwt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 204ffe423..b6c07df63 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -306,7 +306,7 @@ def test_before_request_one_time_token(self): mock.Mock(), 'GET', 'http://example.com?a=1#3', headers) header_value = headers['authorization'] - token = header_value.split('Bearer ').pop() + _, token = header_value.split(' ') # This should be a one-off token, so it shouldn't be the same as the # credentials' stored token. @@ -324,7 +324,7 @@ def test_before_request_with_preset_audience(self): None, 'GET', 'http://example.com?a=1#3', headers) header_value = headers['authorization'] - token = header_value.split('Bearer ').pop() + _, token = header_value.split(' ') # Since the audience is set, it should use the existing token. assert token.encode('utf-8') == credentials.token