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()