Skip to content
This repository was archived by the owner on Jan 18, 2025. It is now read-only.

Commit 8518131

Browse files
Merge pull request #397 from dhermes/consolidate-service-accounts-v2
Made _ServiceAccountCredentials public.
2 parents 7c6938c + 91b3c61 commit 8518131

File tree

8 files changed

+291
-113
lines changed

8 files changed

+291
-113
lines changed

oauth2client/_openssl_crypt.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def from_string(key_pem, is_x509_cert):
6868
Raises:
6969
OpenSSL.crypto.Error: if the key_pem can't be parsed.
7070
"""
71+
key_pem = _to_bytes(key_pem)
7172
if is_x509_cert:
7273
pubkey = crypto.load_certificate(crypto.FILETYPE_PEM, key_pem)
7374
else:
@@ -112,7 +113,8 @@ def from_string(key, password=b'notasecret'):
112113
Raises:
113114
OpenSSL.crypto.Error if the key can't be parsed.
114115
"""
115-
parsed_pem_key = _parse_pem_key(_to_bytes(key))
116+
key = _to_bytes(key)
117+
parsed_pem_key = _parse_pem_key(key)
116118
if parsed_pem_key:
117119
pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, parsed_pem_key)
118120
else:

oauth2client/_pycrypto_crypt.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def from_string(key, password='notasecret'):
115115
Raises:
116116
NotImplementedError if the key isn't in PEM format.
117117
"""
118-
parsed_pem_key = _parse_pem_key(key)
118+
parsed_pem_key = _parse_pem_key(_to_bytes(key))
119119
if parsed_pem_key:
120120
pkey = RSA.importKey(parsed_pem_key)
121121
else:

oauth2client/client.py

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,17 +1230,17 @@ def create_scoped(self, scopes):
12301230
return self
12311231

12321232
@classmethod
1233-
def from_json(cls, s):
1233+
def from_json(cls, json_data):
12341234
# TODO(issue 388): eliminate the circularity that is the reason for
1235-
# this non-top-level import.
1236-
from oauth2client.service_account import _ServiceAccountCredentials
1237-
data = json.loads(_from_bytes(s))
1235+
# this non-top-level import.
1236+
from oauth2client.service_account import ServiceAccountCredentials
1237+
data = json.loads(_from_bytes(json_data))
12381238

1239-
# We handle service_account._ServiceAccountCredentials since it is a
1239+
# We handle service_account.ServiceAccountCredentials since it is a
12401240
# possible return type of GoogleCredentials.get_application_default()
12411241
if (data['_module'] == 'oauth2client.service_account' and
1242-
data['_class'] == '_ServiceAccountCredentials'):
1243-
return _ServiceAccountCredentials.from_json(s)
1242+
data['_class'] == 'ServiceAccountCredentials'):
1243+
return ServiceAccountCredentials.from_json(data)
12441244

12451245
token_expiry = _parse_expiry(data.get('token_expiry'))
12461246
google_credentials = cls(
@@ -1490,9 +1490,6 @@ def _get_well_known_file():
14901490

14911491
def _get_application_default_credential_from_file(filename):
14921492
"""Build the Application Default Credentials from file."""
1493-
1494-
from oauth2client import service_account
1495-
14961493
# read the credentials from the file
14971494
with open(filename) as file_obj:
14981495
client_credentials = json.load(file_obj)
@@ -1523,12 +1520,9 @@ def _get_application_default_credential_from_file(filename):
15231520
token_uri=GOOGLE_TOKEN_URI,
15241521
user_agent='Python client library')
15251522
else: # client_credentials['type'] == SERVICE_ACCOUNT
1526-
return service_account._ServiceAccountCredentials(
1527-
service_account_id=client_credentials['client_id'],
1528-
service_account_email=client_credentials['client_email'],
1529-
private_key_id=client_credentials['private_key_id'],
1530-
private_key_pkcs8_text=client_credentials['private_key'],
1531-
scopes=[])
1523+
from oauth2client.service_account import ServiceAccountCredentials
1524+
return ServiceAccountCredentials.from_json_keyfile_dict(
1525+
client_credentials)
15321526

15331527

15341528
def _raise_exception_for_missing_fields(missing_fields):

oauth2client/service_account.py

Lines changed: 174 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,48 +23,162 @@
2323
from oauth2client import GOOGLE_TOKEN_URI
2424
from oauth2client._helpers import _json_encode
2525
from oauth2client._helpers import _from_bytes
26-
from oauth2client._helpers import _to_bytes
2726
from oauth2client._helpers import _urlsafe_b64encode
2827
from oauth2client import util
2928
from oauth2client.client import AssertionCredentials
3029
from oauth2client.client import EXPIRY_FORMAT
30+
from oauth2client.client import SERVICE_ACCOUNT
3131
from oauth2client import crypt
3232

3333

34-
class _ServiceAccountCredentials(AssertionCredentials):
35-
"""Class representing a service account (signed JWT) credential."""
34+
class ServiceAccountCredentials(AssertionCredentials):
35+
"""Service Account credential for OAuth 2.0 signed JWT grants.
3636
37-
MAX_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds
37+
Supports
38+
39+
* JSON keyfile (typically contains a PKCS8 key stored as
40+
PEM text)
41+
42+
Makes an assertion to server using a signed JWT assertion in exchange
43+
for an access token.
44+
45+
This credential does not require a flow to instantiate because it
46+
represents a two legged flow, and therefore has all of the required
47+
information to generate and refresh its own access tokens.
48+
49+
Args:
50+
service_account_email: string, The email associated with the
51+
service account.
52+
signer: ``crypt.Signer``, A signer which can be used to sign content.
53+
scopes: List or string, (Optional) Scopes to use when acquiring
54+
an access token.
55+
private_key_id: string, (Optional) Private key identifier. Typically
56+
only used with a JSON keyfile. Can be sent in the
57+
header of a JWT token assertion.
58+
client_id: string, (Optional) Client ID for the project that owns the
59+
service account.
60+
user_agent: string, (Optional) User agent to use when sending
61+
request.
62+
kwargs: dict, Extra key-value pairs (both strings) to send in the
63+
payload body when making an assertion.
64+
"""
65+
66+
MAX_TOKEN_LIFETIME_SECS = 3600
67+
"""Max lifetime of the token (one hour, in seconds)."""
3868

3969
NON_SERIALIZED_MEMBERS = (
4070
frozenset(['_signer']) |
4171
AssertionCredentials.NON_SERIALIZED_MEMBERS)
72+
"""Members that aren't serialized when object is converted to JSON."""
73+
74+
# Can be over-ridden by factory constructors. Used for
75+
# serialization/deserialization purposes.
76+
_private_key_pkcs8_pem = None
4277

43-
def __init__(self, service_account_id, service_account_email,
44-
private_key_id, private_key_pkcs8_text, scopes,
45-
user_agent=None, token_uri=GOOGLE_TOKEN_URI,
46-
revoke_uri=GOOGLE_REVOKE_URI, **kwargs):
78+
def __init__(self,
79+
service_account_email,
80+
signer,
81+
scopes='',
82+
private_key_id=None,
83+
client_id=None,
84+
user_agent=None,
85+
**kwargs):
4786

48-
super(_ServiceAccountCredentials, self).__init__(
49-
None, user_agent=user_agent, token_uri=token_uri,
50-
revoke_uri=revoke_uri)
87+
super(ServiceAccountCredentials, self).__init__(
88+
None, user_agent=user_agent)
5189

52-
self._service_account_id = service_account_id
5390
self._service_account_email = service_account_email
54-
self._private_key_id = private_key_id
55-
self._private_key_pkcs8_text = private_key_pkcs8_text
56-
self._signer = crypt.Signer.from_string(self._private_key_pkcs8_text)
91+
self._signer = signer
5792
self._scopes = util.scopes_to_string(scopes)
93+
self._private_key_id = private_key_id
94+
self.client_id = client_id
5895
self._user_agent = user_agent
59-
self._token_uri = token_uri
60-
self._revoke_uri = revoke_uri
6196
self._kwargs = kwargs
6297

98+
@classmethod
99+
def _from_parsed_json_keyfile(cls, keyfile_dict, scopes):
100+
"""Helper for factory constructors from JSON keyfile.
101+
102+
Args:
103+
keyfile_dict: dict-like object, The parsed dictionary-like object
104+
containing the contents of the JSON keyfile.
105+
scopes: List or string, Scopes to use when acquiring an
106+
access token.
107+
108+
Returns:
109+
ServiceAccountCredentials, a credentials object created from
110+
the keyfile contents.
111+
112+
Raises:
113+
ValueError, if the credential type is not :data:`SERVICE_ACCOUNT`.
114+
KeyError, if one of the expected keys is not present in
115+
the keyfile.
116+
"""
117+
creds_type = keyfile_dict.get('type')
118+
if creds_type != SERVICE_ACCOUNT:
119+
raise ValueError('Unexpected credentials type', creds_type,
120+
'Expected', SERVICE_ACCOUNT)
121+
122+
service_account_email = keyfile_dict['client_email']
123+
private_key_pkcs8_pem = keyfile_dict['private_key']
124+
private_key_id = keyfile_dict['private_key_id']
125+
client_id = keyfile_dict['client_id']
126+
127+
signer = crypt.Signer.from_string(private_key_pkcs8_pem)
128+
credentials = cls(service_account_email, signer, scopes=scopes,
129+
private_key_id=private_key_id,
130+
client_id=client_id)
131+
credentials._private_key_pkcs8_pem = private_key_pkcs8_pem
132+
return credentials
133+
134+
@classmethod
135+
def from_json_keyfile_name(cls, filename, scopes=''):
136+
"""Factory constructor from JSON keyfile by name.
137+
138+
Args:
139+
filename: string, The location of the keyfile.
140+
scopes: List or string, (Optional) Scopes to use when acquiring an
141+
access token.
142+
143+
Returns:
144+
ServiceAccountCredentials, a credentials object created from
145+
the keyfile.
146+
147+
Raises:
148+
ValueError, if the credential type is not :data:`SERVICE_ACCOUNT`.
149+
KeyError, if one of the expected keys is not present in
150+
the keyfile.
151+
"""
152+
with open(filename, 'r') as file_obj:
153+
client_credentials = json.load(file_obj)
154+
return cls._from_parsed_json_keyfile(client_credentials, scopes)
155+
156+
@classmethod
157+
def from_json_keyfile_dict(cls, keyfile_dict, scopes=''):
158+
"""Factory constructor from parsed JSON keyfile.
159+
160+
Args:
161+
keyfile_dict: dict-like object, The parsed dictionary-like object
162+
containing the contents of the JSON keyfile.
163+
scopes: List or string, (Optional) Scopes to use when acquiring an
164+
access token.
165+
166+
Returns:
167+
ServiceAccountCredentials, a credentials object created from
168+
the keyfile.
169+
170+
Raises:
171+
ValueError, if the credential type is not :data:`SERVICE_ACCOUNT`.
172+
KeyError, if one of the expected keys is not present in
173+
the keyfile.
174+
"""
175+
return cls._from_parsed_json_keyfile(keyfile_dict, scopes)
176+
63177
def _generate_assertion(self):
64178
"""Generate the assertion that will be used in the request."""
65179
now = int(time.time())
66180
payload = {
67-
'aud': self._token_uri,
181+
'aud': self.token_uri,
68182
'scope': self._scopes,
69183
'iat': now,
70184
'exp': now + self.MAX_TOKEN_LIFETIME_SECS,
@@ -85,26 +199,45 @@ def service_account_email(self):
85199
def serialization_data(self):
86200
return {
87201
'type': 'service_account',
88-
'client_id': self._service_account_id,
89202
'client_email': self._service_account_email,
90203
'private_key_id': self._private_key_id,
91-
'private_key': self._private_key_pkcs8_text
204+
'private_key': self._private_key_pkcs8_pem,
205+
'client_id': self.client_id,
92206
}
93207

94208
@classmethod
95-
def from_json(cls, s):
96-
data = json.loads(_from_bytes(s))
209+
def from_json(cls, json_data):
210+
"""Deserialize a JSON-serialized instance.
211+
212+
Inverse to :meth:`to_json`.
213+
214+
Args:
215+
json_data: dict or string, Serialized JSON (as a string or an
216+
already parsed dictionary) representing a credential.
217+
218+
Returns:
219+
ServiceAccountCredentials from the serialized data.
220+
"""
221+
if not isinstance(json_data, dict):
222+
json_data = json.loads(_from_bytes(json_data))
97223

224+
private_key_pkcs8_pem = json_data['_private_key_pkcs8_pem']
225+
signer = crypt.Signer.from_string(private_key_pkcs8_pem)
98226
credentials = cls(
99-
service_account_id=data['_service_account_id'],
100-
service_account_email=data['_service_account_email'],
101-
private_key_id=data['_private_key_id'],
102-
private_key_pkcs8_text=data['_private_key_pkcs8_text'],
103-
scopes=[],
104-
user_agent=data['_user_agent'])
105-
credentials.invalid = data['invalid']
106-
credentials.access_token = data['access_token']
107-
token_expiry = data.get('token_expiry', None)
227+
json_data['_service_account_email'],
228+
signer,
229+
scopes=json_data['_scopes'],
230+
private_key_id=json_data['_private_key_id'],
231+
client_id=json_data['client_id'],
232+
user_agent=json_data['_user_agent'],
233+
**json_data['_kwargs']
234+
)
235+
credentials._private_key_pkcs8_pem = private_key_pkcs8_pem
236+
credentials.invalid = json_data['invalid']
237+
credentials.access_token = json_data['access_token']
238+
credentials.token_uri = json_data['token_uri']
239+
credentials.revoke_uri = json_data['revoke_uri']
240+
token_expiry = json_data.get('token_expiry', None)
108241
if token_expiry is not None:
109242
credentials.token_expiry = datetime.datetime.strptime(
110243
token_expiry, EXPIRY_FORMAT)
@@ -114,12 +247,13 @@ def create_scoped_required(self):
114247
return not self._scopes
115248

116249
def create_scoped(self, scopes):
117-
return _ServiceAccountCredentials(self._service_account_id,
118-
self._service_account_email,
119-
self._private_key_id,
120-
self._private_key_pkcs8_text,
121-
scopes,
122-
user_agent=self._user_agent,
123-
token_uri=self._token_uri,
124-
revoke_uri=self._revoke_uri,
125-
**self._kwargs)
250+
result = self.__class__(self._service_account_email,
251+
self._signer,
252+
scopes=scopes,
253+
private_key_id=self._private_key_id,
254+
client_id=self.client_id,
255+
user_agent=self._user_agent,
256+
**self._kwargs)
257+
result.token_uri = self.token_uri
258+
result.revoke_uri = self.revoke_uri
259+
return result

scripts/run_system_tests.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import httplib2
55
from oauth2client import client
6-
from oauth2client import service_account
6+
from oauth2client.service_account import ServiceAccountCredentials
77

88

99
JSON_KEY_PATH = os.getenv('OAUTH2CLIENT_TEST_JSON_KEY_PATH')
@@ -51,18 +51,10 @@ def _check_user_info(credentials, expected_email):
5151

5252

5353
def run_json():
54-
with open(JSON_KEY_PATH, 'r') as file_object:
55-
client_credentials = json.load(file_object)
56-
57-
credentials = service_account._ServiceAccountCredentials(
58-
service_account_id=client_credentials['client_id'],
59-
service_account_email=client_credentials['client_email'],
60-
private_key_id=client_credentials['private_key_id'],
61-
private_key_pkcs8_text=client_credentials['private_key'],
62-
scopes=SCOPE,
63-
)
64-
65-
_check_user_info(credentials, client_credentials['client_email'])
54+
credentials = ServiceAccountCredentials.from_json_keyfile_name(
55+
JSON_KEY_PATH, scopes=SCOPE)
56+
service_account_email = credentials._service_account_email
57+
_check_user_info(credentials, service_account_email)
6658

6759

6860
def run_p12():

0 commit comments

Comments
 (0)