Skip to content
This repository was archived by the owner on Mar 6, 2026. It is now read-only.

Commit a12b96d

Browse files
fix: adding more properties to external_account_authorized_user (#1169)
* fix: adding more properties to external_account_authorized_user * Adding token_info_url property * Changes requested by Leo * Changes requested by Leo * Changes requested by Leo and Timur * Empty-Commit Co-authored-by: Leo <39062083+lsirac@users.noreply.github.com>
1 parent 1378eae commit a12b96d

File tree

2 files changed

+142
-43
lines changed

2 files changed

+142
-43
lines changed

google/auth/external_account_authorized_user.py

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def __init__(
7373
token_url=None,
7474
token_info_url=None,
7575
revoke_url=None,
76+
scopes=None,
7677
quota_project_id=None,
7778
):
7879
"""Instantiates a external account authorized user credentials object.
@@ -90,8 +91,8 @@ def __init__(
9091
None if the token can not be refreshed.
9192
client_secret (str): The OAuth 2.0 client secret. Must be specified for refresh, can be
9293
left as None if the token can not be refreshed.
93-
token_url (str): The optional STS token exchange endpoint. Must be specified fro refresh,
94-
can be leftas None if the token can not be refreshed.
94+
token_url (str): The optional STS token exchange endpoint for refresh. Must be specified for
95+
refresh, can be left as None if the token can not be refreshed.
9596
token_info_url (str): The optional STS endpoint URL for token introspection.
9697
revoke_url (str): The optional STS endpoint URL for revoking tokens.
9798
quota_project_id (str): The optional project ID used for quota and billing.
@@ -102,9 +103,6 @@ def __init__(
102103
google.auth.external_account_authorized_user.Credentials: The
103104
constructed credentials.
104105
"""
105-
if not any((refresh_token, token)):
106-
raise ValueError("Either `refresh_token` or `token` should be set.")
107-
108106
super(Credentials, self).__init__()
109107

110108
self.token = token
@@ -117,6 +115,14 @@ def __init__(
117115
self._client_secret = client_secret
118116
self._revoke_url = revoke_url
119117
self._quota_project_id = quota_project_id
118+
self._scopes = scopes
119+
120+
if not self.valid and not self.can_refresh:
121+
raise ValueError(
122+
"Token should be created with fields to make it valid (`token` and "
123+
"`expiry`), or fields to allow it to refresh (`refresh_token`, "
124+
"`token_url`, `client_id`, `client_secret`)."
125+
)
120126

121127
self._client_auth = None
122128
if self._client_id:
@@ -154,20 +160,68 @@ def constructor_args(self):
154160
"token": self.token,
155161
"expiry": self.expiry,
156162
"revoke_url": self._revoke_url,
163+
"scopes": self._scopes,
157164
"quota_project_id": self._quota_project_id,
158165
}
159166

167+
@property
168+
def scopes(self):
169+
"""Optional[str]: The OAuth 2.0 permission scopes."""
170+
return self._scopes
171+
160172
@property
161173
def requires_scopes(self):
162174
""" False: OAuth 2.0 credentials have their scopes set when
163175
the initial token is requested and can not be changed."""
164176
return False
165177

178+
@property
179+
def client_id(self):
180+
"""Optional[str]: The OAuth 2.0 client ID."""
181+
return self._client_id
182+
183+
@property
184+
def client_secret(self):
185+
"""Optional[str]: The OAuth 2.0 client secret."""
186+
return self._client_secret
187+
188+
@property
189+
def audience(self):
190+
"""Optional[str]: The STS audience which contains the resource name for the
191+
workforce pool and the provider identifier in that pool."""
192+
return self._audience
193+
194+
@property
195+
def refresh_token(self):
196+
"""Optional[str]: The OAuth 2.0 refresh token."""
197+
return self._refresh_token
198+
199+
@property
200+
def token_url(self):
201+
"""Optional[str]: The STS token exchange endpoint for refresh."""
202+
return self._token_url
203+
204+
@property
205+
def token_info_url(self):
206+
"""Optional[str]: The STS endpoint for token info."""
207+
return self._token_info_url
208+
209+
@property
210+
def revoke_url(self):
211+
"""Optional[str]: The STS endpoint for token revocation."""
212+
return self._revoke_url
213+
166214
@property
167215
def is_user(self):
168216
""" True: This credential always represents a user."""
169217
return True
170218

219+
@property
220+
def can_refresh(self):
221+
return all(
222+
(self._refresh_token, self._token_url, self._client_id, self._client_secret)
223+
)
224+
171225
def get_project_id(self):
172226
"""Retrieves the project ID corresponding to the workload identity or workforce pool.
173227
For workforce pool credentials, it returns the project ID corresponding to
@@ -203,9 +257,7 @@ def refresh(self, request):
203257
google.auth.exceptions.RefreshError: If the credentials could
204258
not be refreshed.
205259
"""
206-
if not all(
207-
(self._refresh_token, self._token_url, self._client_id, self._client_secret)
208-
):
260+
if not self.can_refresh:
209261
raise exceptions.RefreshError(
210262
"The credentials do not contain the necessary fields need to "
211263
"refresh the access token. You must specify refresh_token, "
@@ -270,6 +322,7 @@ def from_info(cls, info, **kwargs):
270322
expiry=expiry,
271323
revoke_url=info.get("revoke_url"),
272324
quota_project_id=info.get("quota_project_id"),
325+
scopes=info.get("scopes"),
273326
**kwargs
274327
)
275328

tests/test_external_account_authorized_user.py

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
CLIENT_SECRET = "password"
4343
# Base64 encoding of "username:password".
4444
BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ="
45+
SCOPES = ["email", "profile"]
46+
NOW = datetime.datetime(1990, 8, 27, 6, 54, 30)
4547

4648

4749
class TestCredentials(object):
@@ -87,27 +89,74 @@ def test_default_state(self):
8789
assert not creds.token
8890
assert not creds.valid
8991
assert not creds.requires_scopes
92+
assert not creds.scopes
93+
assert not creds.revoke_url
94+
assert creds.token_info_url
95+
assert creds.client_id
96+
assert creds.client_secret
9097
assert creds.is_user
98+
assert creds.refresh_token == REFRESH_TOKEN
99+
assert creds.audience == AUDIENCE
100+
assert creds.token_url == TOKEN_URL
91101

92102
def test_basic_create(self):
93103
creds = external_account_authorized_user.Credentials(
94-
token=ACCESS_TOKEN, expiry=datetime.datetime.max
104+
token=ACCESS_TOKEN,
105+
expiry=datetime.datetime.max,
106+
scopes=SCOPES,
107+
revoke_url=REVOKE_URL,
95108
)
96109

97110
assert creds.expiry == datetime.datetime.max
98111
assert not creds.expired
99112
assert creds.token == ACCESS_TOKEN
100113
assert creds.valid
101114
assert not creds.requires_scopes
115+
assert creds.scopes == SCOPES
102116
assert creds.is_user
117+
assert creds.revoke_url == REVOKE_URL
103118

104-
def test_stunted_create(self):
119+
def test_stunted_create_no_refresh_token(self):
105120
with pytest.raises(ValueError) as excinfo:
106121
self.make_credentials(token=None, refresh_token=None)
107122

108-
assert excinfo.match(r"Either `refresh_token` or `token` should be set")
123+
assert excinfo.match(
124+
r"Token should be created with fields to make it valid \(`token` and "
125+
r"`expiry`\), or fields to allow it to refresh \(`refresh_token`, "
126+
r"`token_url`, `client_id`, `client_secret`\)\."
127+
)
128+
129+
def test_stunted_create_no_token_url(self):
130+
with pytest.raises(ValueError) as excinfo:
131+
self.make_credentials(token=None, token_url=None)
132+
133+
assert excinfo.match(
134+
r"Token should be created with fields to make it valid \(`token` and "
135+
r"`expiry`\), or fields to allow it to refresh \(`refresh_token`, "
136+
r"`token_url`, `client_id`, `client_secret`\)\."
137+
)
138+
139+
def test_stunted_create_no_client_id(self):
140+
with pytest.raises(ValueError) as excinfo:
141+
self.make_credentials(token=None, client_id=None)
142+
143+
assert excinfo.match(
144+
r"Token should be created with fields to make it valid \(`token` and "
145+
r"`expiry`\), or fields to allow it to refresh \(`refresh_token`, "
146+
r"`token_url`, `client_id`, `client_secret`\)\."
147+
)
109148

110-
@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min)
149+
def test_stunted_create_no_client_secret(self):
150+
with pytest.raises(ValueError) as excinfo:
151+
self.make_credentials(token=None, client_secret=None)
152+
153+
assert excinfo.match(
154+
r"Token should be created with fields to make it valid \(`token` and "
155+
r"`expiry`\), or fields to allow it to refresh \(`refresh_token`, "
156+
r"`token_url`, `client_id`, `client_secret`\)\."
157+
)
158+
159+
@mock.patch("google.auth._helpers.utcnow", return_value=NOW)
111160
def test_refresh_auth_success(self, utcnow):
112161
request = self.make_mock_request(
113162
status=http_client.OK,
@@ -137,7 +186,7 @@ def test_refresh_auth_success(self, utcnow):
137186
),
138187
)
139188

140-
@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min)
189+
@mock.patch("google.auth._helpers.utcnow", return_value=NOW)
141190
def test_refresh_auth_success_new_refresh_token(self, utcnow):
142191
request = self.make_mock_request(
143192
status=http_client.OK,
@@ -228,7 +277,7 @@ def test_refresh_without_refresh_token(self):
228277

229278
def test_refresh_without_token_url(self):
230279
request = self.make_mock_request()
231-
creds = self.make_credentials(token_url=None)
280+
creds = self.make_credentials(token_url=None, token=ACCESS_TOKEN)
232281

233282
with pytest.raises(exceptions.RefreshError) as excinfo:
234283
creds.refresh(request)
@@ -239,16 +288,14 @@ def test_refresh_without_token_url(self):
239288

240289
assert not creds.expiry
241290
assert not creds.expired
242-
assert not creds.token
243-
assert not creds.valid
244291
assert not creds.requires_scopes
245292
assert creds.is_user
246293

247294
request.assert_not_called()
248295

249296
def test_refresh_without_client_id(self):
250297
request = self.make_mock_request()
251-
creds = self.make_credentials(client_id=None)
298+
creds = self.make_credentials(client_id=None, token=ACCESS_TOKEN)
252299

253300
with pytest.raises(exceptions.RefreshError) as excinfo:
254301
creds.refresh(request)
@@ -259,16 +306,14 @@ def test_refresh_without_client_id(self):
259306

260307
assert not creds.expiry
261308
assert not creds.expired
262-
assert not creds.token
263-
assert not creds.valid
264309
assert not creds.requires_scopes
265310
assert creds.is_user
266311

267312
request.assert_not_called()
268313

269314
def test_refresh_without_client_secret(self):
270315
request = self.make_mock_request()
271-
creds = self.make_credentials(client_secret=None)
316+
creds = self.make_credentials(client_secret=None, token=ACCESS_TOKEN)
272317

273318
with pytest.raises(exceptions.RefreshError) as excinfo:
274319
creds.refresh(request)
@@ -279,8 +324,6 @@ def test_refresh_without_client_secret(self):
279324

280325
assert not creds.expiry
281326
assert not creds.expired
282-
assert not creds.token
283-
assert not creds.valid
284327
assert not creds.requires_scopes
285328
assert creds.is_user
286329

@@ -304,7 +347,7 @@ def test_info(self):
304347
def test_info_full(self):
305348
creds = self.make_credentials(
306349
token=ACCESS_TOKEN,
307-
expiry=datetime.datetime.min,
350+
expiry=NOW,
308351
revoke_url=REVOKE_URL,
309352
quota_project_id=QUOTA_PROJECT_ID,
310353
)
@@ -317,7 +360,7 @@ def test_info_full(self):
317360
assert info["client_id"] == CLIENT_ID
318361
assert info["client_secret"] == CLIENT_SECRET
319362
assert info["token"] == ACCESS_TOKEN
320-
assert info["expiry"] == datetime.datetime.min.isoformat() + "Z"
363+
assert info["expiry"] == NOW.isoformat() + "Z"
321364
assert info["revoke_url"] == REVOKE_URL
322365
assert info["quota_project_id"] == QUOTA_PROJECT_ID
323366

@@ -340,7 +383,7 @@ def test_to_json(self):
340383
def test_to_json_full(self):
341384
creds = self.make_credentials(
342385
token=ACCESS_TOKEN,
343-
expiry=datetime.datetime.min,
386+
expiry=NOW,
344387
revoke_url=REVOKE_URL,
345388
quota_project_id=QUOTA_PROJECT_ID,
346389
)
@@ -354,14 +397,14 @@ def test_to_json_full(self):
354397
assert info["client_id"] == CLIENT_ID
355398
assert info["client_secret"] == CLIENT_SECRET
356399
assert info["token"] == ACCESS_TOKEN
357-
assert info["expiry"] == datetime.datetime.min.isoformat() + "Z"
400+
assert info["expiry"] == NOW.isoformat() + "Z"
358401
assert info["revoke_url"] == REVOKE_URL
359402
assert info["quota_project_id"] == QUOTA_PROJECT_ID
360403

361404
def test_to_json_full_with_strip(self):
362405
creds = self.make_credentials(
363406
token=ACCESS_TOKEN,
364-
expiry=datetime.datetime.min,
407+
expiry=NOW,
365408
revoke_url=REVOKE_URL,
366409
quota_project_id=QUOTA_PROJECT_ID,
367410
)
@@ -386,7 +429,7 @@ def test_get_project_id(self):
386429
def test_with_quota_project(self):
387430
creds = self.make_credentials(
388431
token=ACCESS_TOKEN,
389-
expiry=datetime.datetime.min,
432+
expiry=NOW,
390433
revoke_url=REVOKE_URL,
391434
quota_project_id=QUOTA_PROJECT_ID,
392435
)
@@ -405,7 +448,7 @@ def test_with_quota_project(self):
405448
def test_with_token_uri(self):
406449
creds = self.make_credentials(
407450
token=ACCESS_TOKEN,
408-
expiry=datetime.datetime.min,
451+
expiry=NOW,
409452
revoke_url=REVOKE_URL,
410453
quota_project_id=QUOTA_PROJECT_ID,
411454
)
@@ -428,36 +471,39 @@ def test_from_file_required_options_only(self, tmpdir):
428471
creds = external_account_authorized_user.Credentials.from_file(str(config_file))
429472

430473
assert isinstance(creds, external_account_authorized_user.Credentials)
431-
assert creds._audience == AUDIENCE
432-
assert creds._refresh_token == REFRESH_TOKEN
433-
assert creds._token_url == TOKEN_URL
434-
assert creds._token_info_url == TOKEN_INFO_URL
435-
assert creds._client_id == CLIENT_ID
436-
assert creds._client_secret == CLIENT_SECRET
474+
assert creds.audience == AUDIENCE
475+
assert creds.refresh_token == REFRESH_TOKEN
476+
assert creds.token_url == TOKEN_URL
477+
assert creds.token_info_url == TOKEN_INFO_URL
478+
assert creds.client_id == CLIENT_ID
479+
assert creds.client_secret == CLIENT_SECRET
437480
assert creds.token is None
438481
assert creds.expiry is None
482+
assert creds.scopes is None
439483
assert creds._revoke_url is None
440484
assert creds._quota_project_id is None
441485

442486
def test_from_file_full_options(self, tmpdir):
443487
from_creds = self.make_credentials(
444488
token=ACCESS_TOKEN,
445-
expiry=datetime.datetime.min,
489+
expiry=NOW,
446490
revoke_url=REVOKE_URL,
447491
quota_project_id=QUOTA_PROJECT_ID,
492+
scopes=SCOPES,
448493
)
449494
config_file = tmpdir.join("config.json")
450495
config_file.write(from_creds.to_json())
451496
creds = external_account_authorized_user.Credentials.from_file(str(config_file))
452497

453498
assert isinstance(creds, external_account_authorized_user.Credentials)
454-
assert creds._audience == AUDIENCE
455-
assert creds._refresh_token == REFRESH_TOKEN
456-
assert creds._token_url == TOKEN_URL
457-
assert creds._token_info_url == TOKEN_INFO_URL
458-
assert creds._client_id == CLIENT_ID
459-
assert creds._client_secret == CLIENT_SECRET
499+
assert creds.audience == AUDIENCE
500+
assert creds.refresh_token == REFRESH_TOKEN
501+
assert creds.token_url == TOKEN_URL
502+
assert creds.token_info_url == TOKEN_INFO_URL
503+
assert creds.client_id == CLIENT_ID
504+
assert creds.client_secret == CLIENT_SECRET
460505
assert creds.token == ACCESS_TOKEN
461-
assert creds.expiry == datetime.datetime.min
506+
assert creds.expiry == NOW
507+
assert creds.scopes == SCOPES
462508
assert creds._revoke_url == REVOKE_URL
463509
assert creds._quota_project_id == QUOTA_PROJECT_ID

0 commit comments

Comments
 (0)