Skip to content

Commit 4d50b47

Browse files
authored
feat: Introduce a way to provide scopes granted by user (#1189)
* feat: Introduce a way to provide scopes granted by user * nits * add rt * update rt
1 parent 252107b commit 4d50b47

File tree

2 files changed

+96
-12
lines changed

2 files changed

+96
-12
lines changed

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from datetime import datetime
3535
import io
3636
import json
37+
import logging
3738

3839
import six
3940

@@ -43,6 +44,8 @@
4344
from google.auth import exceptions
4445
from google.oauth2 import reauth
4546

47+
_LOGGER = logging.getLogger(__name__)
48+
4649

4750
# The Google OAuth 2.0 token endpoint. Used for authorized user credentials.
4851
_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
@@ -79,6 +82,7 @@ def __init__(
7982
rapt_token=None,
8083
refresh_handler=None,
8184
enable_reauth_refresh=False,
85+
granted_scopes=None,
8286
):
8387
"""
8488
Args:
@@ -117,6 +121,9 @@ def __init__(
117121
retrieving downscoped tokens from a token broker.
118122
enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow
119123
should be used. This flag is for gcloud to use only.
124+
granted_scopes (Optional[Sequence[str]]): The scopes that were consented/granted by the user.
125+
This could be different from the requested scopes and it could be empty if granted
126+
and requested scopes were same.
120127
"""
121128
super(Credentials, self).__init__()
122129
self.token = token
@@ -125,6 +132,7 @@ def __init__(
125132
self._id_token = id_token
126133
self._scopes = scopes
127134
self._default_scopes = default_scopes
135+
self._granted_scopes = granted_scopes
128136
self._token_uri = token_uri
129137
self._client_id = client_id
130138
self._client_secret = client_secret
@@ -155,6 +163,7 @@ def __setstate__(self, d):
155163
self._id_token = d.get("_id_token")
156164
self._scopes = d.get("_scopes")
157165
self._default_scopes = d.get("_default_scopes")
166+
self._granted_scopes = d.get("_granted_scopes")
158167
self._token_uri = d.get("_token_uri")
159168
self._client_id = d.get("_client_id")
160169
self._client_secret = d.get("_client_secret")
@@ -174,6 +183,11 @@ def scopes(self):
174183
"""Optional[str]: The OAuth 2.0 permission scopes."""
175184
return self._scopes
176185

186+
@property
187+
def granted_scopes(self):
188+
"""Optional[Sequence[str]]: The OAuth 2.0 permission scopes that were granted by the user."""
189+
return self._granted_scopes
190+
177191
@property
178192
def token_uri(self):
179193
"""Optional[str]: The OAuth 2.0 authorization server's token endpoint
@@ -249,6 +263,7 @@ def with_quota_project(self, quota_project_id):
249263
client_secret=self.client_secret,
250264
scopes=self.scopes,
251265
default_scopes=self.default_scopes,
266+
granted_scopes=self.granted_scopes,
252267
quota_project_id=quota_project_id,
253268
rapt_token=self.rapt_token,
254269
enable_reauth_refresh=self._enable_reauth_refresh,
@@ -266,6 +281,7 @@ def with_token_uri(self, token_uri):
266281
client_secret=self.client_secret,
267282
scopes=self.scopes,
268283
default_scopes=self.default_scopes,
284+
granted_scopes=self.granted_scopes,
269285
quota_project_id=self.quota_project_id,
270286
rapt_token=self.rapt_token,
271287
enable_reauth_refresh=self._enable_reauth_refresh,
@@ -335,10 +351,15 @@ def refresh(self, request):
335351

336352
if scopes and "scope" in grant_response:
337353
requested_scopes = frozenset(scopes)
338-
granted_scopes = frozenset(grant_response["scope"].split())
354+
self._granted_scopes = grant_response["scope"].split()
355+
granted_scopes = frozenset(self._granted_scopes)
339356
scopes_requested_but_not_granted = requested_scopes - granted_scopes
340357
if scopes_requested_but_not_granted:
341-
raise exceptions.RefreshError(
358+
# User might be presented with unbundled scopes at the time of
359+
# consent. So it is a valid scenario to not have all the requested
360+
# scopes as part of granted scopes but log a warning in case the
361+
# developer wants to debug the scenario.
362+
_LOGGER.warning(
342363
"Not all requested scopes were granted by the "
343364
"authorization server, missing scopes {}.".format(
344365
", ".join(scopes_requested_but_not_granted)

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

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ def test_credentials_with_scopes_requested_refresh_success(
449449
assert creds.id_token == mock.sentinel.id_token
450450
assert creds.has_scopes(scopes)
451451
assert creds.rapt_token == new_rapt_token
452+
assert creds.granted_scopes == scopes
452453

453454
# Check that the credentials are valid (have a token and are not
454455
# expired.)
@@ -466,7 +467,7 @@ def test_credentials_with_only_default_scopes_requested(
466467
token = "token"
467468
new_rapt_token = "new_rapt_token"
468469
expiry = _helpers.utcnow() + datetime.timedelta(seconds=500)
469-
grant_response = {"id_token": mock.sentinel.id_token}
470+
grant_response = {"id_token": mock.sentinel.id_token, "scope": "email profile"}
470471
refresh_grant.return_value = (
471472
# Access token
472473
token,
@@ -513,6 +514,7 @@ def test_credentials_with_only_default_scopes_requested(
513514
assert creds.id_token == mock.sentinel.id_token
514515
assert creds.has_scopes(default_scopes)
515516
assert creds.rapt_token == new_rapt_token
517+
assert creds.granted_scopes == default_scopes
516518

517519
# Check that the credentials are valid (have a token and are not
518520
# expired.)
@@ -530,10 +532,7 @@ def test_credentials_with_scopes_returned_refresh_success(
530532
token = "token"
531533
new_rapt_token = "new_rapt_token"
532534
expiry = _helpers.utcnow() + datetime.timedelta(seconds=500)
533-
grant_response = {
534-
"id_token": mock.sentinel.id_token,
535-
"scopes": " ".join(scopes),
536-
}
535+
grant_response = {"id_token": mock.sentinel.id_token, "scope": " ".join(scopes)}
537536
refresh_grant.return_value = (
538537
# Access token
539538
token,
@@ -580,6 +579,7 @@ def test_credentials_with_scopes_returned_refresh_success(
580579
assert creds.id_token == mock.sentinel.id_token
581580
assert creds.has_scopes(scopes)
582581
assert creds.rapt_token == new_rapt_token
582+
assert creds.granted_scopes == scopes
583583

584584
# Check that the credentials are valid (have a token and are not
585585
# expired.)
@@ -590,7 +590,72 @@ def test_credentials_with_scopes_returned_refresh_success(
590590
"google.auth._helpers.utcnow",
591591
return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD,
592592
)
593-
def test_credentials_with_scopes_refresh_failure_raises_refresh_error(
593+
def test_credentials_with_only_default_scopes_requested_different_granted_scopes(
594+
self, unused_utcnow, refresh_grant
595+
):
596+
default_scopes = ["email", "profile"]
597+
token = "token"
598+
new_rapt_token = "new_rapt_token"
599+
expiry = _helpers.utcnow() + datetime.timedelta(seconds=500)
600+
grant_response = {"id_token": mock.sentinel.id_token, "scope": "email"}
601+
refresh_grant.return_value = (
602+
# Access token
603+
token,
604+
# New refresh token
605+
None,
606+
# Expiry,
607+
expiry,
608+
# Extra data
609+
grant_response,
610+
# rapt token
611+
new_rapt_token,
612+
)
613+
614+
request = mock.create_autospec(transport.Request)
615+
creds = credentials.Credentials(
616+
token=None,
617+
refresh_token=self.REFRESH_TOKEN,
618+
token_uri=self.TOKEN_URI,
619+
client_id=self.CLIENT_ID,
620+
client_secret=self.CLIENT_SECRET,
621+
default_scopes=default_scopes,
622+
rapt_token=self.RAPT_TOKEN,
623+
enable_reauth_refresh=True,
624+
)
625+
626+
# Refresh credentials
627+
creds.refresh(request)
628+
629+
# Check jwt grant call.
630+
refresh_grant.assert_called_with(
631+
request,
632+
self.TOKEN_URI,
633+
self.REFRESH_TOKEN,
634+
self.CLIENT_ID,
635+
self.CLIENT_SECRET,
636+
default_scopes,
637+
self.RAPT_TOKEN,
638+
True,
639+
)
640+
641+
# Check that the credentials have the token and expiry
642+
assert creds.token == token
643+
assert creds.expiry == expiry
644+
assert creds.id_token == mock.sentinel.id_token
645+
assert creds.has_scopes(default_scopes)
646+
assert creds.rapt_token == new_rapt_token
647+
assert creds.granted_scopes == ["email"]
648+
649+
# Check that the credentials are valid (have a token and are not
650+
# expired.)
651+
assert creds.valid
652+
653+
@mock.patch("google.oauth2.reauth.refresh_grant", autospec=True)
654+
@mock.patch(
655+
"google.auth._helpers.utcnow",
656+
return_value=datetime.datetime.min + _helpers.REFRESH_THRESHOLD,
657+
)
658+
def test_credentials_with_scopes_refresh_different_granted_scopes(
594659
self, unused_utcnow, refresh_grant
595660
):
596661
scopes = ["email", "profile"]
@@ -628,10 +693,7 @@ def test_credentials_with_scopes_refresh_failure_raises_refresh_error(
628693
)
629694

630695
# Refresh credentials
631-
with pytest.raises(
632-
exceptions.RefreshError, match="Not all requested scopes were granted"
633-
):
634-
creds.refresh(request)
696+
creds.refresh(request)
635697

636698
# Check jwt grant call.
637699
refresh_grant.assert_called_with(
@@ -651,6 +713,7 @@ def test_credentials_with_scopes_refresh_failure_raises_refresh_error(
651713
assert creds.id_token == mock.sentinel.id_token
652714
assert creds.has_scopes(scopes)
653715
assert creds.rapt_token == new_rapt_token
716+
assert creds.granted_scopes == scopes_returned
654717

655718
# Check that the credentials are valid (have a token and are not
656719
# expired.)

0 commit comments

Comments
 (0)