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 JWT credentials #21

Merged
merged 7 commits into from
Oct 17, 2016
Merged
Show file tree
Hide file tree
Changes from 5 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
242 changes: 242 additions & 0 deletions google/auth/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,13 @@

import base64
import collections
import datetime
import io
import json

from six.moves import urllib

from google.auth import credentials
from google.auth import crypt
from google.auth import _helpers

Expand Down Expand Up @@ -234,3 +239,240 @@ 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.

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.

This comment was marked as spam.

This comment was marked as spam.


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')
"""

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. 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
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.

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']
except KeyError:
raise ValueError(
'Service account info was not in the expected format.')

signer = crypt.Signer.from_string(private_key, key_id)

kwargs.setdefault('subject', email)
return cls(signer, issuer=email, **kwargs)

This comment was marked as spam.

This comment was marked as spam.


@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 io.open(filename, 'r', encoding='utf-8') 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=self._additional_claims.copy().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 doesn't correctly recognize overridden 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

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

# (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.
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)
10 changes: 10 additions & 0 deletions tests/data/service_account.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

"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": "[email protected]",
"client_id": "1234",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://accounts.google.com/o/oauth2/token"
}
Loading