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