From f1455c544b10f29998ea1adf7a23f9e35f8c644b Mon Sep 17 00:00:00 2001 From: JS Ng Date: Sun, 4 Oct 2026 09:18:41 +0800 Subject: [PATCH] feat(auth): add OAuth token revocation (RFC 7009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth.oauth.revoke(token, client_id, token_type_hint=None) POSTs /auth/v1/oauth/revoke, the endpoint campus-cli still raw-calls. The server returns 200 {} regardless of token state (RFC 7009 ยง2.2); errors (missing token, invalid client) surface as APIError subclasses via raise_for_status. Fixes #73 --- campus_python/auth/v1/oauth.py | 27 +++++++++++++++++ tests/unit/test_oauth_revoke.py | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/unit/test_oauth_revoke.py diff --git a/campus_python/auth/v1/oauth.py b/campus_python/auth/v1/oauth.py index 6d9b13e..44496cc 100644 --- a/campus_python/auth/v1/oauth.py +++ b/campus_python/auth/v1/oauth.py @@ -158,3 +158,30 @@ def authorize_device( ) resp.raise_for_status() return resp.json() + + def revoke( + self, + token: str, + client_id: str, + token_type_hint: "str | None" = None, + ) -> None: + """Revoke an access or refresh token (RFC 7009). + + Args: + token: The access or refresh token to revoke + client_id: The OAuth client the token was issued to + token_type_hint: Optional "access_token" or "refresh_token" + + Per RFC 7009 section 2.2 the server returns 200 regardless of + whether the token was found or already revoked, so callers + cannot use this endpoint to confirm a token's validity. + """ + json_body: dict = { + "token": token, + "client_id": client_id, + } + if token_type_hint is not None: + json_body["token_type_hint"] = token_type_hint + resp = self.client.post(self.make_path("revoke"), json=json_body) + resp.raise_for_status() + return None diff --git a/tests/unit/test_oauth_revoke.py b/tests/unit/test_oauth_revoke.py new file mode 100644 index 0000000..503b70e --- /dev/null +++ b/tests/unit/test_oauth_revoke.py @@ -0,0 +1,52 @@ +"""Contract tests for OAuth token revocation (RFC 7009, issue #73). + +The device flow moved in-library with the PR #70 fix sweep, but +campus-cli still raw-calls POST /auth/v1/oauth/revoke. The endpoint is +registered as the leaf path /oauth/revoke (campus/auth/routes/ +oauth.py; the auth app sets strict_slashes) and returns 200 {} per +RFC 7009 section 2.2 regardless of token state. +""" + +import unittest +from unittest.mock import Mock + +from campus_python.auth.v1 import AuthRoot + + +def make_auth() -> tuple[AuthRoot, Mock]: + """Create an AuthRoot backed by a mock JSON client.""" + client = Mock() + return AuthRoot(json_client=client), client + + +class TestOAuthRevoke(unittest.TestCase): + """auth.oauth.revoke() must POST the /oauth/revoke leaf route.""" + + def setUp(self): + self.auth, self.client = make_auth() + + def test_revoke_posts_token_and_client_id(self): + self.auth.oauth.revoke(token="tok-1", client_id="campus-cli") + self.client.post.assert_called_once_with( + "/auth/v1/oauth/revoke", + json={"token": "tok-1", "client_id": "campus-cli"}, + ) + + def test_revoke_sends_token_type_hint_when_given(self): + self.auth.oauth.revoke( + token="tok-1", + client_id="campus-cli", + token_type_hint="refresh_token", + ) + self.assertEqual( + self.client.post.call_args.kwargs["json"], + { + "token": "tok-1", + "client_id": "campus-cli", + "token_type_hint": "refresh_token", + }, + ) + + +if __name__ == "__main__": + unittest.main()