diff --git a/campus_python/auth/v1/credentials.py b/campus_python/auth/v1/credentials.py index 24b383c..82b31d2 100644 --- a/campus_python/auth/v1/credentials.py +++ b/campus_python/auth/v1/credentials.py @@ -88,9 +88,15 @@ def new( def update(self, token: campus.model.OAuthToken) -> None: client_id = env.CLIENT_ID + token_payload = token.to_resource() + # The endpoint validates the body via OAuthToken(**payload), + # which does not accept the RFC 6749 `scope` string alias + # that to_resource() emits alongside `scopes` (campus #648 + # compat window); `scopes` carries the same information. + token_payload.pop("scope", None) json_data: JsonDict = { "client_id": client_id, - "token": token.to_resource() + "token": token_payload } resp = self.client.patch(self.make_path(), json=json_data) # Raise error if status code is not 2XX or 3XX diff --git a/poetry.lock b/poetry.lock index 946825a..ddbdb2c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand. [[package]] name = "bcrypt" @@ -112,7 +112,7 @@ files = [ [package.dependencies] jmespath = ">=0.7.1,<2.0.0" python-dateutil = ">=2.1,<3.0.0" -urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3" +urllib3 = ">=1.25.4,!=2.2.0,<3" [package.extras] crt = ["awscrt (==0.32.2)"] @@ -142,7 +142,7 @@ werkzeug = "^3.0.0" type = "git" url = "https://github.com/nyjc-computing/campus.git" reference = "weekly" -resolved_reference = "c7cab0eb352b8bc7d7afdc3d2f90f56dec442a47" +resolved_reference = "56ae7b9385a3a330dd74973d13dc5232563a9a1a" [[package]] name = "certifi" diff --git a/tests/unit/test_oauth_token_contract.py b/tests/unit/test_oauth_token_contract.py new file mode 100644 index 0000000..ee85e5e --- /dev/null +++ b/tests/unit/test_oauth_token_contract.py @@ -0,0 +1,156 @@ +"""Contract tests for OAuth token flows against the RFC 6749-compliant +OAuthToken interface (campus issue #648, PRs #650/#654; client issue #40). + +campus #650 aligned OAuthToken with RFC 6749 token semantics. These tests +pin the wire shapes exchanged with the dev API (campus-suite, branch +weekly) so the client stays compatible until campus lands its deprecation +PR: + +- POST /auth/v1/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 with both `scope` + (string) and `scopes` (list) during the compat window; +- PATCH /credentials/... bodies must stay within the keys the server's + OAuthToken(**payload) validation accepts: the `scope` string alias is + rejected (VALIDATION_FAILED), so User.update() strips it. +""" + +import os +import unittest +from unittest.mock import Mock, patch + +import campus.model + +from campus_python.auth.v1 import AuthRoot + +# Exact token-endpoint response shape emitted by campus weekly +# (device-code grant in campus/auth/routes/oauth.py; client_credentials +# and refresh_token grants are equivalent RFC payloads). +RFC_TOKEN_PAYLOAD = { + "access_token": "tok123", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rt123", + "scope": "campus.profile campus.identities", +} + +# Exact credentials-resource shape emitted by campus weekly +# (UserCredentials.to_resource() with its nested OAuthToken token). +CREDENTIALS_RESOURCE = { + "id": "cred1", + "created_at": "2026-09-30T06:31:24.582763+00:00", + "provider": "campus", + "client_id": "cid123", + "user_id": "user1", + "token": { + "id": "tok123", + "created_at": "2026-09-30T06:31:24.582763+00:00", + "expires_at": "2026-09-30T07:31:24.582763+00:00", + "expires_in": 3600, + "token_type": "Bearer", + "refresh_token": "rt123", + "refresh_token_expires_at": None, + "scopes": ["campus.profile", "campus.identities"], + "scope": "campus.profile campus.identities", + }, +} + + +def make_auth() -> tuple[AuthRoot, Mock]: + """Create an AuthRoot backed by a mock JSON client.""" + client = Mock() + return AuthRoot(json_client=client), client + + +class TestTokenEndpointDeserialization(unittest.TestCase): + """auth.token() / _exchange_code_for_token() consume RFC payloads.""" + + def test_rfc_token_payload_deserializes(self): + token = campus.model.OAuthToken.from_resource(RFC_TOKEN_PAYLOAD) + self.assertEqual(token.id, "tok123") + self.assertEqual(token.access_token, token.id) + self.assertEqual(token.token_type, "Bearer") + self.assertEqual(token.expires_in, 3600) + self.assertEqual(token.scopes, ["campus.profile", "campus.identities"]) + self.assertEqual(token.scope, "campus.profile campus.identities") + self.assertEqual(token.refresh_token, "rt123") + + def test_token_type_is_case_normalised(self): + payload = dict(RFC_TOKEN_PAYLOAD, token_type="bearer") + token = campus.model.OAuthToken.from_resource(payload) + self.assertEqual(token.token_type, "Bearer") + + def test_legacy_token_resource_deserializes(self): + """Records without the new fields still construct (compat window).""" + token = campus.model.OAuthToken.from_resource({ + "id": "tok-legacy", + "created_at": "2026-09-30T00:00:00Z", + "expires_at": "2026-09-30T01:00:00Z", + "scopes": ["campus.profile"], + }) + self.assertEqual(token.id, "tok-legacy") + self.assertEqual(token.token_type, "Bearer") + self.assertEqual(token.expires_in, 3600) + self.assertEqual(token.scopes, ["campus.profile"]) + + +class TestCredentialsResourceDeserialization(unittest.TestCase): + """UserCredentials.from_resource() must consume the nested token.""" + + def test_nested_token_deserializes(self): + creds = campus.model.UserCredentials.from_resource(CREDENTIALS_RESOURCE) + self.assertEqual(creds.provider, "campus") + self.assertEqual(creds.user_id, "user1") + self.assertIsInstance(creds.token, campus.model.OAuthToken) + self.assertEqual(creds.token.id, "tok123") + self.assertEqual(creds.token.token_type, "Bearer") + self.assertEqual(creds.token.expires_in, 3600) + self.assertEqual(creds.token.scopes, ["campus.profile", "campus.identities"]) + self.assertEqual(creds.token.scope, "campus.profile campus.identities") + + +class TestCredentialsUpdatePatchBody(unittest.TestCase): + """User.update() must send a body the server's OAuthToken(**payload) + validation accepts (campus/auth/routes/credentials.py).""" + + def setUp(self): + self.auth, self.client = make_auth() + self.token = campus.model.OAuthToken( + id="tok123", + expires_in=3600, + scopes=["campus.profile", "campus.identities"], + refresh_token="rt123", + ) + + def test_update_patches_credentials_endpoint(self): + with patch.dict(os.environ, {"CLIENT_ID": "cid123"}): + self.auth.credentials["campus"]["user1"].update(self.token) + self.client.patch.assert_called_once() + args, kwargs = self.client.patch.call_args + self.assertEqual(args[0], "/auth/v1/credentials/campus/user1") + self.assertEqual(kwargs["json"]["client_id"], "cid123") + expected_token = self.token.to_resource() + expected_token.pop("scope", None) + self.assertEqual(kwargs["json"]["token"], expected_token) + + def test_patch_body_passes_server_validation(self): + """The sent token payload must construct via OAuthToken(**payload). + + Mirrors the server-side validation, which rejects the RFC 6749 + `scope` string alias emitted by to_resource() with 422 + VALIDATION_FAILED (campus #650 dual emission). + """ + with patch.dict(os.environ, {"CLIENT_ID": "cid123"}): + self.auth.credentials["campus"]["user1"].update(self.token) + body = self.client.patch.call_args.kwargs["json"] + sent_token = body["token"] + self.assertNotIn("scope", sent_token) + self.assertIn("scopes", sent_token) + validated = campus.model.OAuthToken(**sent_token) + self.assertEqual(validated.id, "tok123") + self.assertEqual(validated.scope, "campus.profile campus.identities") + + +if __name__ == "__main__": + unittest.main()