Skip to content

Commit d563898

Browse files
kdeniz-gitdaniel-sanchenbayati
authored
feat: Implement token revocation in STS client and add revoke() metho… (#1849)
…d to ExternalAccountAuthorizedUser credentials * Add support for OAuth 2.0 token revocation to the STS client, aligning with the specification in RFC7009. * A new revoke_token method is introduced, which makes a POST request to a revocation endpoint. The underlying request handler has also been updated to correctly process successful but empty HTTP responses, as specified by the standard for revocation. * Building on the STS client's new capabilities, this change exposes a public revoke() method on the ExternalAccountAuthorizedUser credentials class. * This method encapsulates the logic for revoking the refresh token by calling the underlying STS client's revoke_token function. It simplifies the process for client applications, like gcloud, to revoke these specific credentials without needing to interact directly with the STS client. * Unit tests are included to verify successful revocation and to ensure appropriate errors are raised if required fields (like revoke_url) are missing. --------- Co-authored-by: Daniel Sanche <d.sanche14@gmail.com> Co-authored-by: nbayati <99771966+nbayati@users.noreply.github.com>
1 parent cf6fc3c commit d563898

File tree

4 files changed

+176
-11
lines changed

4 files changed

+176
-11
lines changed

google/auth/external_account_authorized_user.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,30 @@ def _build_trust_boundary_lookup_url(self):
321321
universe_domain=self._universe_domain, pool_id=pool_id
322322
)
323323

324+
def revoke(self, request):
325+
"""Revokes the refresh token.
326+
327+
Args:
328+
request (google.auth.transport.Request): The object used to make
329+
HTTP requests.
330+
331+
Raises:
332+
google.auth.exceptions.OAuthError: If the token could not be
333+
revoked.
334+
"""
335+
if not self._revoke_url or not self._refresh_token_val:
336+
raise exceptions.OAuthError(
337+
"The credentials do not contain the necessary fields to "
338+
"revoke the refresh token. You must specify revoke_url and "
339+
"refresh_token."
340+
)
341+
342+
self._sts_client.revoke_token(
343+
request, self._refresh_token_val, "refresh_token", self._revoke_url
344+
)
345+
self.token = None
346+
self._refresh_token = None
347+
324348
@_helpers.copy_docstring(credentials.Credentials)
325349
def get_cred_info(self):
326350
if self._cred_file_path:

google/oauth2/sts.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def __init__(self, token_exchange_endpoint, client_authentication=None):
5757
super(Client, self).__init__(client_authentication)
5858
self._token_exchange_endpoint = token_exchange_endpoint
5959

60-
def _make_request(self, request, headers, request_body):
60+
def _make_request(self, request, headers, request_body, url=None):
6161
# Initialize request headers.
6262
request_headers = _URLENCODED_HEADERS.copy()
6363

@@ -69,9 +69,12 @@ def _make_request(self, request, headers, request_body):
6969
# Apply OAuth client authentication.
7070
self.apply_client_authentication_options(request_headers, request_body)
7171

72+
# Use default token exchange endpoint if no url is provided.
73+
url = url or self._token_exchange_endpoint
74+
7275
# Execute request.
7376
response = request(
74-
url=self._token_exchange_endpoint,
77+
url=url,
7578
method="POST",
7679
headers=request_headers,
7780
body=urllib.parse.urlencode(request_body).encode("utf-8"),
@@ -87,10 +90,12 @@ def _make_request(self, request, headers, request_body):
8790
if response.status != http_client.OK:
8891
utils.handle_error_response(response_body)
8992

90-
response_data = json.loads(response_body)
93+
# A successful token revocation returns an empty response body.
94+
if not response_body:
95+
return {}
9196

92-
# Return successful response.
93-
return response_data
97+
# Other successful responses should be valid JSON.
98+
return json.loads(response_body)
9499

95100
def exchange_token(
96101
self,
@@ -174,3 +179,23 @@ def refresh_token(self, request, refresh_token):
174179
None,
175180
{"grant_type": "refresh_token", "refresh_token": refresh_token},
176181
)
182+
183+
def revoke_token(self, request, token, token_type_hint, revoke_url):
184+
"""Revokes the provided token based on the RFC7009 spec.
185+
186+
Args:
187+
request (google.auth.transport.Request): A callable used to make
188+
HTTP requests.
189+
token (str): The OAuth 2.0 token to revoke.
190+
token_type_hint (str): Hint for the type of token being revoked.
191+
revoke_url (str): The STS endpoint URL for revoking tokens.
192+
193+
Raises:
194+
google.auth.exceptions.OAuthError: If the token revocation endpoint
195+
returned an error.
196+
"""
197+
request_body = {"token": token}
198+
if token_type_hint:
199+
request_body["token_type_hint"] = token_type_hint
200+
201+
return self._make_request(request, None, request_body, revoke_url)

tests/oauth2/test_sts.py

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ class TestStsClient(object):
4141
ACTOR_TOKEN = "HEADER.ACTOR_TOKEN_PAYLOAD.SIGNATURE"
4242
ACTOR_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
4343
TOKEN_EXCHANGE_ENDPOINT = "https://example.com/token.oauth2"
44+
REVOKE_URL = "https://example.com/revoke.oauth2"
45+
TOKEN_TO_REVOKE = "TOKEN_TO_REVOKE"
46+
TOKEN_TYPE_HINT = "refresh_token"
4447
ADDON_HEADERS = {"x-client-version": "0.1.2"}
4548
ADDON_OPTIONS = {"additional": {"non-standard": ["options"], "other": "some-value"}}
4649
SUCCESS_RESPONSE = {
@@ -72,21 +75,24 @@ def make_client(cls, client_auth=None):
7275
return sts.Client(cls.TOKEN_EXCHANGE_ENDPOINT, client_auth)
7376

7477
@classmethod
75-
def make_mock_request(cls, data, status=http_client.OK):
78+
def make_mock_request(cls, data, status=http_client.OK, use_json=True):
7679
response = mock.create_autospec(transport.Response, instance=True)
7780
response.status = status
78-
response.data = json.dumps(data).encode("utf-8")
81+
if use_json:
82+
response.data = json.dumps(data).encode("utf-8")
83+
else:
84+
response.data = data.encode("utf-8")
7985

8086
request = mock.create_autospec(transport.Request)
8187
request.return_value = response
8288

8389
return request
8490

8591
@classmethod
86-
def assert_request_kwargs(cls, request_kwargs, headers, request_data):
87-
"""Asserts the request was called with the expected parameters.
88-
"""
89-
assert request_kwargs["url"] == cls.TOKEN_EXCHANGE_ENDPOINT
92+
def assert_request_kwargs(cls, request_kwargs, headers, request_data, url=None):
93+
"""Asserts the request was called with the expected parameters."""
94+
url = url or cls.TOKEN_EXCHANGE_ENDPOINT
95+
assert request_kwargs["url"] == url
9096
assert request_kwargs["method"] == "POST"
9197
assert request_kwargs["headers"] == headers
9298
assert request_kwargs["body"] is not None
@@ -447,6 +453,63 @@ def test_refresh_token_failure(self):
447453
r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749"
448454
)
449455

456+
def test_revoke_token_success(self):
457+
"""Test revoke token with successful response."""
458+
client = self.make_client(self.CLIENT_AUTH_BASIC)
459+
request = self.make_mock_request(data="", status=http_client.OK, use_json=False)
460+
461+
response = client.revoke_token(
462+
request, self.TOKEN_TO_REVOKE, self.TOKEN_TYPE_HINT, self.REVOKE_URL
463+
)
464+
465+
headers = {
466+
"Authorization": "Basic {}".format(BASIC_AUTH_ENCODING),
467+
"Content-Type": "application/x-www-form-urlencoded",
468+
}
469+
request_data = {
470+
"token": self.TOKEN_TO_REVOKE,
471+
"token_type_hint": self.TOKEN_TYPE_HINT,
472+
}
473+
self.assert_request_kwargs(
474+
request.call_args[1], headers, request_data, url=self.REVOKE_URL
475+
)
476+
assert response == {}
477+
478+
def test_revoke_token_success_no_hint(self):
479+
"""Test revoke token with successful response."""
480+
client = self.make_client(self.CLIENT_AUTH_BASIC)
481+
request = self.make_mock_request(data="", status=http_client.OK, use_json=False)
482+
483+
response = client.revoke_token(
484+
request, self.TOKEN_TO_REVOKE, None, self.REVOKE_URL
485+
)
486+
487+
headers = {
488+
"Authorization": "Basic {}".format(BASIC_AUTH_ENCODING),
489+
"Content-Type": "application/x-www-form-urlencoded",
490+
}
491+
request_data = {"token": self.TOKEN_TO_REVOKE}
492+
self.assert_request_kwargs(
493+
request.call_args[1], headers, request_data, url=self.REVOKE_URL
494+
)
495+
assert response == {}
496+
497+
def test_revoke_token_failure(self):
498+
"""Test revoke token with failure response."""
499+
client = self.make_client(self.CLIENT_AUTH_BASIC)
500+
request = self.make_mock_request(
501+
status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE
502+
)
503+
504+
with pytest.raises(exceptions.OAuthError) as excinfo:
505+
client.revoke_token(
506+
request, self.TOKEN_TO_REVOKE, self.TOKEN_TYPE_HINT, self.REVOKE_URL
507+
)
508+
509+
assert excinfo.match(
510+
r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749"
511+
)
512+
450513
def test__make_request_success(self):
451514
"""Test base method with successful response."""
452515
client = self.make_client(self.CLIENT_AUTH_BASIC)
@@ -478,3 +541,12 @@ def test_make_request_failure(self):
478541
assert excinfo.match(
479542
r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749"
480543
)
544+
545+
def test__make_request_empty_response(self):
546+
"""Test _make_request with a successful but empty response body."""
547+
client = self.make_client()
548+
request = self.make_mock_request(data="", status=http_client.OK, use_json=False)
549+
550+
response = client._make_request(request, {}, {})
551+
552+
assert response == {}

tests/test_external_account_authorized_user.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,50 @@ def test_refresh_without_client_secret(self):
349349

350350
request.assert_not_called()
351351

352+
def test_revoke_auth_success(self):
353+
request = self.make_mock_request(status=http_client.OK, data={})
354+
creds = self.make_credentials(revoke_url=REVOKE_URL)
355+
356+
creds.revoke(request)
357+
358+
request.assert_called_once_with(
359+
url=REVOKE_URL,
360+
method="POST",
361+
headers={
362+
"Content-Type": "application/x-www-form-urlencoded",
363+
"Authorization": "Basic " + BASIC_AUTH_ENCODING,
364+
},
365+
body=("token=" + REFRESH_TOKEN + "&token_type_hint=refresh_token").encode(
366+
"utf-8"
367+
),
368+
)
369+
assert creds.token is None
370+
assert creds._refresh_token is None
371+
372+
def test_revoke_without_revoke_url(self):
373+
request = self.make_mock_request()
374+
creds = self.make_credentials(token=ACCESS_TOKEN)
375+
376+
with pytest.raises(exceptions.OAuthError) as excinfo:
377+
creds.revoke(request)
378+
379+
assert excinfo.match(
380+
r"The credentials do not contain the necessary fields to revoke the refresh token. You must specify revoke_url and refresh_token."
381+
)
382+
383+
def test_revoke_without_refresh_token(self):
384+
request = self.make_mock_request()
385+
creds = self.make_credentials(
386+
refresh_token=None, token=ACCESS_TOKEN, revoke_url=REVOKE_URL
387+
)
388+
389+
with pytest.raises(exceptions.OAuthError) as excinfo:
390+
creds.revoke(request)
391+
392+
assert excinfo.match(
393+
r"The credentials do not contain the necessary fields to revoke the refresh token. You must specify revoke_url and refresh_token."
394+
)
395+
352396
def test_info(self):
353397
creds = self.make_credentials()
354398
info = creds.info

0 commit comments

Comments
 (0)