Skip to content

Commit 82cde04

Browse files
committed
Added JWTAccessCredentials.
Newer Google APIs can accept JWTs signed using ServiceAccountCredentials for authentication. (See https://jwt.io/). The new behavior for GoogleCredentials.get_application_default() will attempt to use a signed JWT if ServiceAccountCredentials are available and no scope is specified. Upon specifying a scope, OAuth2 authentication will be used.
1 parent e99a4ac commit 82cde04

File tree

4 files changed

+457
-25
lines changed

4 files changed

+457
-25
lines changed

oauth2client/client.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,26 @@ def _update_query_params(uri, params):
495495
return urllib.parse.urlunparse(new_parts)
496496

497497

498+
def _initialize_headers(headers):
499+
"""Creates a copy of the headers."""
500+
if headers is None:
501+
headers = {}
502+
else:
503+
headers = dict(headers)
504+
return headers
505+
506+
507+
def _apply_user_agent(headers, user_agent):
508+
"""Adds a user-agent to the headers."""
509+
if user_agent is not None:
510+
if 'user-agent' in headers:
511+
headers['user-agent'] = (user_agent + ' ' + headers['user-agent'])
512+
else:
513+
headers['user-agent'] = user_agent
514+
515+
return headers
516+
517+
498518
class OAuth2Credentials(Credentials):
499519
"""Credentials object for OAuth 2.0.
500520
@@ -598,18 +618,9 @@ def new_request(uri, method='GET', body=None, headers=None,
598618

599619
# Clone and modify the request headers to add the appropriate
600620
# Authorization header.
601-
if headers is None:
602-
headers = {}
603-
else:
604-
headers = dict(headers)
621+
headers = _initialize_headers(headers)
605622
self.apply(headers)
606-
607-
if self.user_agent is not None:
608-
if 'user-agent' in headers:
609-
headers['user-agent'] = (self.user_agent + ' ' +
610-
headers['user-agent'])
611-
else:
612-
headers['user-agent'] = self.user_agent
623+
_apply_user_agent(headers, self.user_agent)
613624

614625
body_stream_position = None
615626
if all(getattr(body, stream_prop, None) for stream_prop in
@@ -1237,13 +1248,18 @@ def from_json(cls, json_data):
12371248
# TODO(issue 388): eliminate the circularity that is the reason for
12381249
# this non-top-level import.
12391250
from oauth2client.service_account import ServiceAccountCredentials
1251+
from oauth2client.service_account import _JWTAccessCredentials
12401252
data = json.loads(_from_bytes(json_data))
12411253

12421254
# We handle service_account.ServiceAccountCredentials since it is a
12431255
# possible return type of GoogleCredentials.get_application_default()
12441256
if (data['_module'] == 'oauth2client.service_account' and
12451257
data['_class'] == 'ServiceAccountCredentials'):
12461258
return ServiceAccountCredentials.from_json(data)
1259+
elif (data['_module'] == 'oauth2client.service_account' and
1260+
data['_class'] == '_JWTAccessCredentials'):
1261+
return _JWTAccessCredentials.from_json(data)
1262+
12471263

12481264
token_expiry = _parse_expiry(data.get('token_expiry'))
12491265
google_credentials = cls(
@@ -1523,8 +1539,8 @@ def _get_application_default_credential_from_file(filename):
15231539
token_uri=GOOGLE_TOKEN_URI,
15241540
user_agent='Python client library')
15251541
else: # client_credentials['type'] == SERVICE_ACCOUNT
1526-
from oauth2client.service_account import ServiceAccountCredentials
1527-
return ServiceAccountCredentials.from_json_keyfile_dict(
1542+
from oauth2client.service_account import _JWTAccessCredentials
1543+
return _JWTAccessCredentials.from_json_keyfile_dict(
15281544
client_credentials)
15291545

15301546

oauth2client/service_account.py

Lines changed: 186 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import base64
1818
import copy
1919
import datetime
20+
import httplib2
2021
import json
2122
import time
2223

@@ -26,9 +27,16 @@
2627
from oauth2client._helpers import _from_bytes
2728
from oauth2client._helpers import _urlsafe_b64encode
2829
from oauth2client import util
30+
from oauth2client.client import _apply_user_agent
31+
from oauth2client.client import _initialize_headers
32+
from oauth2client.client import AccessTokenInfo
2933
from oauth2client.client import AssertionCredentials
34+
from oauth2client.client import clean_headers
3035
from oauth2client.client import EXPIRY_FORMAT
36+
from oauth2client.client import GoogleCredentials
3137
from oauth2client.client import SERVICE_ACCOUNT
38+
from oauth2client.client import TokenRevokeError
39+
from oauth2client.client import _UTCNOW
3240
from oauth2client import crypt
3341

3442

@@ -426,6 +434,32 @@ def create_scoped(self, scopes):
426434
result._private_key_pkcs12 = self._private_key_pkcs12
427435
result._private_key_password = self._private_key_password
428436
return result
437+
438+
def create_with_claims(self, claims):
439+
"""Create credentials that specify additional claims.
440+
441+
Args:
442+
claims: dict, key-value pairs for claims.
443+
444+
Returns:
445+
ServiceAccountCredentials, a copy of the current service account
446+
credentials with updated claims to use when obtaining access tokens.
447+
"""
448+
new_kwargs = dict(self._kwargs)
449+
new_kwargs.update(claims)
450+
result = self.__class__(self._service_account_email,
451+
self._signer,
452+
scopes=self._scopes,
453+
private_key_id=self._private_key_id,
454+
client_id=self.client_id,
455+
user_agent=self._user_agent,
456+
**new_kwargs)
457+
result.token_uri = self.token_uri
458+
result.revoke_uri = self.revoke_uri
459+
result._private_key_pkcs8_pem = self._private_key_pkcs8_pem
460+
result._private_key_pkcs12 = self._private_key_pkcs12
461+
result._private_key_password = self._private_key_password
462+
return result
429463

430464
def create_delegated(self, sub):
431465
"""Create credentials that act as domain-wide delegation of authority.
@@ -446,18 +480,158 @@ def create_delegated(self, sub):
446480
ServiceAccountCredentials, a copy of the current service account
447481
updated to act on behalf of ``sub``.
448482
"""
449-
new_kwargs = dict(self._kwargs)
450-
new_kwargs['sub'] = sub
451-
result = self.__class__(self._service_account_email,
452-
self._signer,
453-
scopes=self._scopes,
454-
private_key_id=self._private_key_id,
455-
client_id=self.client_id,
456-
user_agent=self._user_agent,
457-
**new_kwargs)
483+
return self.create_with_claims({'sub': sub})
484+
485+
486+
def _datetime_to_secs(utc_time):
487+
# TODO(issue 298): use time_delta.total_seconds()
488+
# time_delta.total_seconds() not supported in Python 2.6
489+
epoch = datetime.datetime(1970, 1, 1)
490+
time_delta = utc_time - epoch
491+
return time_delta.days * 86400 + time_delta.seconds
492+
493+
494+
class _JWTAccessCredentials(ServiceAccountCredentials):
495+
"""Self signed JWT credentials.
496+
497+
Makes an assertion to server using a self signed JWT from service account
498+
credentials. These credentials do NOT use OAuth 2.0 and instead
499+
authenticate directly.
500+
"""
501+
_MAX_TOKEN_LIFETIME_SECS = 3600
502+
"""Max lifetime of the token (one hour, in seconds)."""
503+
504+
def __init__(self,
505+
service_account_email,
506+
signer,
507+
scopes=None,
508+
private_key_id=None,
509+
client_id=None,
510+
user_agent=None,
511+
additional_claims=None):
512+
if additional_claims is None:
513+
additional_claims = {}
514+
super(_JWTAccessCredentials, self).__init__(
515+
service_account_email,
516+
signer,
517+
private_key_id=private_key_id,
518+
client_id=client_id,
519+
user_agent=user_agent,
520+
**additional_claims)
521+
522+
def authorize(self, http):
523+
"""Authorize an httplib2.Http instance with a JWT assertion.
524+
525+
Unless specified, the 'aud' of the assertion will be the base
526+
uri of the request.
527+
528+
Args:
529+
http: An instance of ``httplib2.Http`` or something that acts
530+
like it.
531+
Returns:
532+
A modified instance of http that was passed in.
533+
Example::
534+
h = httplib2.Http()
535+
h = credentials.authorize(h)
536+
"""
537+
request_orig = http.request
538+
request_auth = super(_JWTAccessCredentials, self).authorize(http).request
539+
540+
# The closure that will replace 'httplib2.Http.request'.
541+
def new_request(uri, method='GET', body=None, headers=None,
542+
redirections=httplib2.DEFAULT_MAX_REDIRECTS,
543+
connection_type=None):
544+
if 'aud' in self._kwargs:
545+
# Preemptively refresh token, this is not done for OAuth2
546+
if self.access_token is None or self.access_token_expired:
547+
self.refresh(None)
548+
return request_auth(uri, method, body,
549+
headers, redirections,
550+
connection_type)
551+
else:
552+
# If we don't have an 'aud' (audience) claim,
553+
# create a 1-time token with the uri root as the audience
554+
headers = _initialize_headers(headers)
555+
_apply_user_agent(headers, self.user_agent)
556+
uri_root = uri.split('?', 1)[0]
557+
token, unused_expiry = self._create_token({'aud': uri_root})
558+
559+
headers['Authorization'] = 'Bearer ' + token
560+
return request_orig(uri, method, body,
561+
clean_headers(headers),
562+
redirections, connection_type)
563+
564+
# Replace the request method with our own closure.
565+
http.request = new_request
566+
567+
return http
568+
569+
def get_access_token(self, http=None, additional_claims=None):
570+
"""Create a signed jwt.
571+
572+
Args:
573+
http: unused
574+
additional_claims: dict, additional claims to add to
575+
the payload of the JWT.
576+
Returns:
577+
An AccessTokenInfo with the signed jwt
578+
"""
579+
if additional_claims is None:
580+
if self.access_token is None or self.access_token_expired:
581+
self.refresh(None)
582+
return AccessTokenInfo(access_token=self.access_token,
583+
expires_in=self._expires_in())
584+
else:
585+
# Create a 1 time token
586+
token, unused_expiry = self._create_token(additional_claims)
587+
return AccessTokenInfo(access_token=token,
588+
expires_in=self._MAX_TOKEN_LIFETIME_SECS)
589+
590+
def revoke(self, http):
591+
"""Cannot revoke JWTAccessCredentials tokens."""
592+
pass
593+
594+
def create_scoped_required(self):
595+
# JWTAccessCredentials are unscoped by definition
596+
return True
597+
598+
def create_scoped(self, scopes):
599+
# Returns an OAuth2 credentials with the given scope
600+
result = ServiceAccountCredentials(self._service_account_email,
601+
self._signer,
602+
scopes=scopes,
603+
private_key_id=self._private_key_id,
604+
client_id=self.client_id,
605+
user_agent=self._user_agent,
606+
**self._kwargs)
458607
result.token_uri = self.token_uri
459608
result.revoke_uri = self.revoke_uri
460-
result._private_key_pkcs8_pem = self._private_key_pkcs8_pem
461-
result._private_key_pkcs12 = self._private_key_pkcs12
462-
result._private_key_password = self._private_key_password
609+
if self._private_key_pkcs8_pem is not None:
610+
result._private_key_pkcs8_pem = self._private_key_pkcs8_pem
611+
if self._private_key_pkcs12 is not None:
612+
result._private_key_pkcs12 = self._private_key_pkcs12
613+
if self._private_key_password is not None:
614+
result._private_key_password = self._private_key_password
463615
return result
616+
617+
def refresh(self, http):
618+
self._refresh(None)
619+
620+
def _refresh(self, http_request):
621+
self.access_token, self.token_expiry = self._create_token()
622+
623+
def _create_token(self, additional_claims=None):
624+
now = _UTCNOW()
625+
expiry = now + datetime.timedelta(seconds=self._MAX_TOKEN_LIFETIME_SECS)
626+
payload = {
627+
'iat': _datetime_to_secs(now),
628+
'exp': _datetime_to_secs(expiry),
629+
'iss': self._service_account_email,
630+
'sub': self._service_account_email
631+
}
632+
payload.update(self._kwargs)
633+
if additional_claims is not None:
634+
payload.update(additional_claims)
635+
jwt = crypt.make_signed_jwt(self._signer, payload,
636+
key_id=self._private_key_id)
637+
return jwt.decode('ascii'), expiry

tests/test_client.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,20 @@ def test_to_from_json_service_account(self):
840840
creds2_vals.pop('_signer')
841841
self.assertEqual(creds1_vals, creds2_vals)
842842

843+
def test_to_from_json_service_account_scoped(self):
844+
credentials_file = datafile(
845+
os.path.join('gcloud', _WELL_KNOWN_CREDENTIALS_FILE))
846+
creds1 = GoogleCredentials.from_stream(credentials_file)
847+
creds1 = creds1.create_scoped(['dummy_scope'])
848+
# Convert to and then back from json.
849+
creds2 = GoogleCredentials.from_json(creds1.to_json())
850+
851+
creds1_vals = creds1.__dict__
852+
creds1_vals.pop('_signer')
853+
creds2_vals = creds2.__dict__
854+
creds2_vals.pop('_signer')
855+
self.assertEqual(creds1_vals, creds2_vals)
856+
843857
def test_parse_expiry(self):
844858
dt = datetime.datetime(2016, 1, 1)
845859
parsed_expiry = client._parse_expiry(dt)

0 commit comments

Comments
 (0)