From 0429ed33ff772ab7ca400001cde0bf07350a18c0 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Fri, 2 Oct 2026 14:28:12 +0800 Subject: [PATCH] fix(auth): token() targets /auth/v1/oauth/token, not the auth-code endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthRoot.token() built its request URL as url_prefix + "/token", which resolves to /auth/v1/token — the provider's authorization-code session endpoint (campus/auth/provider.py). That route requires code and redirect_uri and rejects every grant_type but authorization_code, so the client_credentials and refresh_token cases of token() could never succeed: with_app_session() died with 422 VALIDATION_FAILED (missing code/redirect_uri), verified against a current campus auth service. The RFC 6749 token endpoint serving device_code, refresh_token, and client_credentials grants lives at /auth/v1/oauth/token (campus/auth/routes/oauth.py) — the same endpoint oauth.poll_for_token already uses. Point token() there and pin both grant paths with contract tests. Fixes #60. Unblocks the client side of campus#334 / campus-classroom#24 (server side: campus PR #731). --- campus_python/auth/v1/__init__.py | 12 ++++++-- tests/unit/test_oauth_token_contract.py | 37 ++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/campus_python/auth/v1/__init__.py b/campus_python/auth/v1/__init__.py index 79ac60e..daae0eb 100644 --- a/campus_python/auth/v1/__init__.py +++ b/campus_python/auth/v1/__init__.py @@ -323,7 +323,15 @@ def token( *, refresh_token: str | None = None, ) -> campus.model.OAuthToken: - """Get OAuth token from the token endpoint.""" + """Get OAuth token from the token endpoint. + + Targets the RFC 6749 token endpoint (/auth/v1/oauth/token, + campus/auth/routes/oauth.py), which serves the device_code, + refresh_token, and client_credentials grants — NOT the + authorization-code session endpoint at /auth/v1/token + (campus/auth/provider.py), whose contract requires code and + redirect_uri and rejects every other grant type (#60). + """ json_body: dict[str, str] = { "grant_type": grant_type, } @@ -339,7 +347,7 @@ def token( ) json_body["refresh_token"] = refresh_token - base_url = self.base_url + self.url_prefix + "/token" + base_url = self.base_url + self.url_prefix + "/oauth/token" resp = self.client.post(base_url, json=json_body) resp.raise_for_status() return campus.model.OAuthToken.from_resource(resp.json()) diff --git a/tests/unit/test_oauth_token_contract.py b/tests/unit/test_oauth_token_contract.py index bd71696..8f97145 100644 --- a/tests/unit/test_oauth_token_contract.py +++ b/tests/unit/test_oauth_token_contract.py @@ -6,7 +6,7 @@ weekly) so the client stays compatible until campus lands its deprecation PR: -- POST /auth/v1/token responses carry standard RFC keys (access_token, +- POST /auth/v1/oauth/token responses carry standard RFC keys (access_token, token_type, expires_in, scope) which OAuthToken.from_resource maps to campus names; - GET /credentials/... resources nest the token carrying the RFC 6749 @@ -172,6 +172,41 @@ def test_scope_only_payload_passes_server_validation(self): self.assertEqual(validated.scope, "campus.profile") +class TestTokenEndpointPath(unittest.TestCase): + """auth.token() must target the RFC 6749 token endpoint + (/auth/v1/oauth/token, campus/auth/routes/oauth.py), not the + authorization-code session endpoint at /auth/v1/token + (campus/auth/provider.py) — whose contract requires code and + redirect_uri and rejects every other grant type (client issue #60). + """ + + def setUp(self): + self.auth, self.client = make_auth() + self.client.base_url = "" + self.client.post.return_value.json.return_value = dict( + RFC_TOKEN_PAYLOAD + ) + + def test_client_credentials_targets_oauth_token_endpoint(self): + envvars = {"CLIENT_ID": "cid123", "CLIENT_SECRET": "sec123"} + with patch.dict(os.environ, envvars): + self.auth.token(grant_type="client_credentials") + + args, kwargs = self.client.post.call_args + self.assertEqual(args[0], "/auth/v1/oauth/token") + self.assertEqual(kwargs["json"]["grant_type"], "client_credentials") + self.assertEqual(kwargs["json"]["client_id"], "cid123") + self.assertEqual(kwargs["json"]["client_secret"], "sec123") + + def test_refresh_token_targets_oauth_token_endpoint(self): + self.auth.token(grant_type="refresh_token", refresh_token="rt123") + + args, kwargs = self.client.post.call_args + self.assertEqual(args[0], "/auth/v1/oauth/token") + self.assertEqual(kwargs["json"]["grant_type"], "refresh_token") + self.assertEqual(kwargs["json"]["refresh_token"], "rt123") + + class TestExpirySecondsAliasRemoved(unittest.TestCase): """The legacy `expiry_seconds` alias is gone (campus #659, closing #648 checklist item 2). The client has no call sites; these pins