Skip to content

Commit 1c24ddd

Browse files
kryzthovJon Wayne Parrott
authored andcommitted
Add google.oauth2.service_account.IDTokenCredentials. (#234)
1 parent 222b2f3 commit 1c24ddd

File tree

6 files changed

+427
-6
lines changed

6 files changed

+427
-6
lines changed

packages/google-auth/google/auth/app_engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def requires_scopes(self):
136136

137137
@_helpers.copy_docstring(credentials.Scoped)
138138
def with_scopes(self, scopes):
139-
return Credentials(
139+
return self.__class__(
140140
scopes=scopes, service_account_id=self._service_account_id)
141141

142142
@_helpers.copy_docstring(credentials.Signing)

packages/google-auth/google/auth/jwt.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ def with_claims(self, issuer=None, subject=None, audience=None,
438438
new_additional_claims = copy.deepcopy(self._additional_claims)
439439
new_additional_claims.update(additional_claims or {})
440440

441-
return Credentials(
441+
return self.__class__(
442442
self._signer,
443443
issuer=issuer if issuer is not None else self._issuer,
444444
subject=subject if subject is not None else self._subject,
@@ -643,7 +643,7 @@ def with_claims(self, issuer=None, subject=None, additional_claims=None):
643643
new_additional_claims = copy.deepcopy(self._additional_claims)
644644
new_additional_claims.update(additional_claims or {})
645645

646-
return OnDemandCredentials(
646+
return self.__class__(
647647
self._signer,
648648
issuer=issuer if issuer is not None else self._issuer,
649649
subject=subject if subject is not None else self._subject,

packages/google-auth/google/oauth2/_client.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
from google.auth import _helpers
3434
from google.auth import exceptions
35+
from google.auth import jwt
3536

3637
_URLENCODED_CONTENT_TYPE = 'application/x-www-form-urlencoded'
3738
_JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
@@ -155,6 +156,51 @@ def jwt_grant(request, token_uri, assertion):
155156
return access_token, expiry, response_data
156157

157158

159+
def id_token_jwt_grant(request, token_uri, assertion):
160+
"""Implements the JWT Profile for OAuth 2.0 Authorization Grants, but
161+
requests an OpenID Connect ID Token instead of an access token.
162+
163+
This is a variant on the standard JWT Profile that is currently unique
164+
to Google. This was added for the benefit of authenticating to services
165+
that require ID Tokens instead of access tokens or JWT bearer tokens.
166+
167+
Args:
168+
request (google.auth.transport.Request): A callable used to make
169+
HTTP requests.
170+
token_uri (str): The OAuth 2.0 authorization server's token endpoint
171+
URI.
172+
assertion (str): JWT token signed by a service account. The token's
173+
payload must include a ``target_audience`` claim.
174+
175+
Returns:
176+
Tuple[str, Optional[datetime], Mapping[str, str]]:
177+
The (encoded) Open ID Connect ID Token, expiration, and additional
178+
data returned by the endpoint.
179+
180+
Raises:
181+
google.auth.exceptions.RefreshError: If the token endpoint returned
182+
an error.
183+
"""
184+
body = {
185+
'assertion': assertion,
186+
'grant_type': _JWT_GRANT_TYPE,
187+
}
188+
189+
response_data = _token_endpoint_request(request, token_uri, body)
190+
191+
try:
192+
id_token = response_data['id_token']
193+
except KeyError as caught_exc:
194+
new_exc = exceptions.RefreshError(
195+
'No ID token in response.', response_data)
196+
six.raise_from(new_exc, caught_exc)
197+
198+
payload = jwt.decode(id_token, verify=False)
199+
expiry = datetime.datetime.utcfromtimestamp(payload['exp'])
200+
201+
return id_token, expiry, response_data
202+
203+
158204
def refresh_grant(request, token_uri, refresh_token, client_id, client_secret):
159205
"""Implements the OAuth 2.0 refresh token grant.
160206

packages/google-auth/google/oauth2/service_account.py

Lines changed: 207 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ def requires_scopes(self):
230230

231231
@_helpers.copy_docstring(credentials.Scoped)
232232
def with_scopes(self, scopes):
233-
return Credentials(
233+
return self.__class__(
234234
self._signer,
235235
service_account_email=self._service_account_email,
236236
scopes=scopes,
@@ -249,7 +249,7 @@ def with_subject(self, subject):
249249
google.auth.service_account.Credentials: A new credentials
250250
instance.
251251
"""
252-
return Credentials(
252+
return self.__class__(
253253
self._signer,
254254
service_account_email=self._service_account_email,
255255
scopes=self._scopes,
@@ -273,7 +273,7 @@ def with_claims(self, additional_claims):
273273
new_additional_claims = copy.deepcopy(self._additional_claims)
274274
new_additional_claims.update(additional_claims or {})
275275

276-
return Credentials(
276+
return self.__class__(
277277
self._signer,
278278
service_account_email=self._service_account_email,
279279
scopes=self._scopes,
@@ -336,3 +336,207 @@ def signer(self):
336336
@_helpers.copy_docstring(credentials.Signing)
337337
def signer_email(self):
338338
return self._service_account_email
339+
340+
341+
class IDTokenCredentials(credentials.Signing, credentials.Credentials):
342+
"""Open ID Connect ID Token-based service account credentials.
343+
344+
These credentials are largely similar to :class:`.Credentials`, but instead
345+
of using an OAuth 2.0 Access Token as the bearer token, they use an Open
346+
ID Connect ID Token as the bearer token. These credentials are useful when
347+
communicating to services that require ID Tokens and can not accept access
348+
tokens.
349+
350+
Usually, you'll create these credentials with one of the helper
351+
constructors. To create credentials using a Google service account
352+
private key JSON file::
353+
354+
credentials = (
355+
service_account.IDTokenCredentials.from_service_account_file(
356+
'service-account.json'))
357+
358+
Or if you already have the service account file loaded::
359+
360+
service_account_info = json.load(open('service_account.json'))
361+
credentials = (
362+
service_account.IDTokenCredentials.from_service_account_info(
363+
service_account_info))
364+
365+
Both helper methods pass on arguments to the constructor, so you can
366+
specify additional scopes and a subject if necessary::
367+
368+
credentials = (
369+
service_account.IDTokenCredentials.from_service_account_file(
370+
'service-account.json',
371+
scopes=['email'],
372+
subject='user@example.com'))
373+
`
374+
The credentials are considered immutable. If you want to modify the scopes
375+
or the subject used for delegation, use :meth:`with_scopes` or
376+
:meth:`with_subject`::
377+
378+
scoped_credentials = credentials.with_scopes(['email'])
379+
delegated_credentials = credentials.with_subject(subject)
380+
381+
"""
382+
def __init__(self, signer, service_account_email, token_uri,
383+
target_audience, additional_claims=None):
384+
"""
385+
Args:
386+
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
387+
service_account_email (str): The service account's email.
388+
token_uri (str): The OAuth 2.0 Token URI.
389+
target_audience (str): The intended audience for these credentials,
390+
used when requesting the ID Token. The ID Token's ``aud`` claim
391+
will be set to this string.
392+
additional_claims (Mapping[str, str]): Any additional claims for
393+
the JWT assertion used in the authorization grant.
394+
395+
.. note:: Typically one of the helper constructors
396+
:meth:`from_service_account_file` or
397+
:meth:`from_service_account_info` are used instead of calling the
398+
constructor directly.
399+
"""
400+
super(IDTokenCredentials, self).__init__()
401+
self._signer = signer
402+
self._service_account_email = service_account_email
403+
self._token_uri = token_uri
404+
self._target_audience = target_audience
405+
406+
if additional_claims is not None:
407+
self._additional_claims = additional_claims
408+
else:
409+
self._additional_claims = {}
410+
411+
@classmethod
412+
def _from_signer_and_info(cls, signer, info, **kwargs):
413+
"""Creates a credentials instance from a signer and service account
414+
info.
415+
416+
Args:
417+
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
418+
info (Mapping[str, str]): The service account info.
419+
kwargs: Additional arguments to pass to the constructor.
420+
421+
Returns:
422+
google.auth.jwt.IDTokenCredentials: The constructed credentials.
423+
424+
Raises:
425+
ValueError: If the info is not in the expected format.
426+
"""
427+
kwargs.setdefault('service_account_email', info['client_email'])
428+
kwargs.setdefault('token_uri', info['token_uri'])
429+
return cls(signer, **kwargs)
430+
431+
@classmethod
432+
def from_service_account_info(cls, info, **kwargs):
433+
"""Creates a credentials instance from parsed service account info.
434+
435+
Args:
436+
info (Mapping[str, str]): The service account info in Google
437+
format.
438+
kwargs: Additional arguments to pass to the constructor.
439+
440+
Returns:
441+
google.auth.service_account.IDTokenCredentials: The constructed
442+
credentials.
443+
444+
Raises:
445+
ValueError: If the info is not in the expected format.
446+
"""
447+
signer = _service_account_info.from_dict(
448+
info, require=['client_email', 'token_uri'])
449+
return cls._from_signer_and_info(signer, info, **kwargs)
450+
451+
@classmethod
452+
def from_service_account_file(cls, filename, **kwargs):
453+
"""Creates a credentials instance from a service account json file.
454+
455+
Args:
456+
filename (str): The path to the service account json file.
457+
kwargs: Additional arguments to pass to the constructor.
458+
459+
Returns:
460+
google.auth.service_account.IDTokenCredentials: The constructed
461+
credentials.
462+
"""
463+
info, signer = _service_account_info.from_filename(
464+
filename, require=['client_email', 'token_uri'])
465+
return cls._from_signer_and_info(signer, info, **kwargs)
466+
467+
def with_target_audience(self, target_audience):
468+
"""Create a copy of these credentials with the specified target
469+
audience.
470+
471+
Args:
472+
target_audience (str): The intended audience for these credentials,
473+
used when requesting the ID Token.
474+
475+
Returns:
476+
google.auth.service_account.IDTokenCredentials: A new credentials
477+
instance.
478+
"""
479+
return self.__class__(
480+
self._signer,
481+
service_account_email=self._service_account_email,
482+
token_uri=self._token_uri,
483+
target_audience=target_audience,
484+
additional_claims=self._additional_claims.copy())
485+
486+
def _make_authorization_grant_assertion(self):
487+
"""Create the OAuth 2.0 assertion.
488+
489+
This assertion is used during the OAuth 2.0 grant to acquire an
490+
ID token.
491+
492+
Returns:
493+
bytes: The authorization grant assertion.
494+
"""
495+
now = _helpers.utcnow()
496+
lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS)
497+
expiry = now + lifetime
498+
499+
payload = {
500+
'iat': _helpers.datetime_to_secs(now),
501+
'exp': _helpers.datetime_to_secs(expiry),
502+
# The issuer must be the service account email.
503+
'iss': self.service_account_email,
504+
# The audience must be the auth token endpoint's URI
505+
'aud': self._token_uri,
506+
# The target audience specifies which service the ID token is
507+
# intended for.
508+
'target_audience': self._target_audience
509+
}
510+
511+
payload.update(self._additional_claims)
512+
513+
token = jwt.encode(self._signer, payload)
514+
515+
return token
516+
517+
@_helpers.copy_docstring(credentials.Credentials)
518+
def refresh(self, request):
519+
assertion = self._make_authorization_grant_assertion()
520+
access_token, expiry, _ = _client.id_token_jwt_grant(
521+
request, self._token_uri, assertion)
522+
self.token = access_token
523+
self.expiry = expiry
524+
525+
@property
526+
def service_account_email(self):
527+
"""The service account email."""
528+
return self._service_account_email
529+
530+
@_helpers.copy_docstring(credentials.Signing)
531+
def sign_bytes(self, message):
532+
return self._signer.sign(message)
533+
534+
@property
535+
@_helpers.copy_docstring(credentials.Signing)
536+
def signer(self):
537+
return self._signer
538+
539+
@property
540+
@_helpers.copy_docstring(credentials.Signing)
541+
def signer_email(self):
542+
return self._service_account_email

packages/google-auth/tests/oauth2/test__client.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,30 @@
1414

1515
import datetime
1616
import json
17+
import os
1718

1819
import mock
1920
import pytest
2021
import six
2122
from six.moves import http_client
2223
from six.moves import urllib
2324

25+
from google.auth import _helpers
26+
from google.auth import crypt
2427
from google.auth import exceptions
28+
from google.auth import jwt
2529
from google.auth import transport
2630
from google.oauth2 import _client
2731

2832

33+
DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
34+
35+
with open(os.path.join(DATA_DIR, 'privatekey.pem'), 'rb') as fh:
36+
PRIVATE_KEY_BYTES = fh.read()
37+
38+
SIGNER = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, '1')
39+
40+
2941
def test__handle_error_response():
3042
response_data = json.dumps({
3143
'error': 'help',
@@ -129,6 +141,42 @@ def test_jwt_grant_no_access_token():
129141
_client.jwt_grant(request, 'http://example.com', 'assertion_value')
130142

131143

144+
def test_id_token_jwt_grant():
145+
now = _helpers.utcnow()
146+
id_token_expiry = _helpers.datetime_to_secs(now)
147+
id_token = jwt.encode(SIGNER, {'exp': id_token_expiry}).decode('utf-8')
148+
request = make_request({
149+
'id_token': id_token,
150+
'extra': 'data'})
151+
152+
token, expiry, extra_data = _client.id_token_jwt_grant(
153+
request, 'http://example.com', 'assertion_value')
154+
155+
# Check request call
156+
verify_request_params(request, {
157+
'grant_type': _client._JWT_GRANT_TYPE,
158+
'assertion': 'assertion_value'
159+
})
160+
161+
# Check result
162+
assert token == id_token
163+
# JWT does not store microseconds
164+
now = now.replace(microsecond=0)
165+
assert expiry == now
166+
assert extra_data['extra'] == 'data'
167+
168+
169+
def test_id_token_jwt_grant_no_access_token():
170+
request = make_request({
171+
# No access token.
172+
'expires_in': 500,
173+
'extra': 'data'})
174+
175+
with pytest.raises(exceptions.RefreshError):
176+
_client.id_token_jwt_grant(
177+
request, 'http://example.com', 'assertion_value')
178+
179+
132180
@mock.patch('google.auth._helpers.utcnow', return_value=datetime.datetime.min)
133181
def test_refresh_grant(unused_utcnow):
134182
request = make_request({

0 commit comments

Comments
 (0)