From 084585bd3ee18e946c7ce10eddff7e35df7bb22b Mon Sep 17 00:00:00 2001 From: JS Ng Date: Wed, 30 Sep 2026 15:05:00 +0800 Subject: [PATCH] refactor(auth): send full token resource in credentials PATCH campus #656 (merged to weekly, 2e46783c) validates PATCH /credentials token bodies via OAuthToken.from_resource(), which accepts the RFC 6749 `scope` string alias that to_resource() dual-emits, maps legacy expiry_seconds, and bags unknown provider keys into provider_fields. The scope-alias strip added for #40 is no longer necessary; User.update() sends the full to_resource() output again. Also bumps campus-suite to weekly head 2e46783c carrying the fix, and updates the contract tests to pin the new validation contract, including the post-deprecation scope-only shape. Refs nyjc-computing/campus#655 --- campus_python/auth/v1/credentials.py | 8 +---- poetry.lock | 2 +- tests/unit/test_oauth_token_contract.py | 44 +++++++++++++++++-------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/campus_python/auth/v1/credentials.py b/campus_python/auth/v1/credentials.py index 82b31d2..24b383c 100644 --- a/campus_python/auth/v1/credentials.py +++ b/campus_python/auth/v1/credentials.py @@ -88,15 +88,9 @@ 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_payload + "token": token.to_resource() } 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 ddbdb2c..1f7995b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -142,7 +142,7 @@ werkzeug = "^3.0.0" type = "git" url = "https://github.com/nyjc-computing/campus.git" reference = "weekly" -resolved_reference = "56ae7b9385a3a330dd74973d13dc5232563a9a1a" +resolved_reference = "2e46783c9b55be587625aa83156d118419fb730d" [[package]] name = "certifi" diff --git a/tests/unit/test_oauth_token_contract.py b/tests/unit/test_oauth_token_contract.py index ee85e5e..38ab073 100644 --- a/tests/unit/test_oauth_token_contract.py +++ b/tests/unit/test_oauth_token_contract.py @@ -11,9 +11,11 @@ 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. +- PATCH /credentials/... bodies are validated server-side via + OAuthToken.from_resource() (campus #656), which accepts the + dual-emitted `scope` string alias, legacy `expiry_seconds`, and bags + unknown provider keys into provider_fields; User.update() sends the + full to_resource() output. """ import os @@ -111,8 +113,9 @@ def test_nested_token_deserializes(self): class TestCredentialsUpdatePatchBody(unittest.TestCase): - """User.update() must send a body the server's OAuthToken(**payload) - validation accepts (campus/auth/routes/credentials.py).""" + """User.update() must send a body the server's + OAuthToken.from_resource() validation accepts + (campus/auth/routes/credentials.py, campus #656).""" def setUp(self): self.auth, self.client = make_auth() @@ -130,27 +133,40 @@ def test_update_patches_credentials_endpoint(self): 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) + self.assertEqual(kwargs["json"]["token"], self.token.to_resource()) def test_patch_body_passes_server_validation(self): - """The sent token payload must construct via OAuthToken(**payload). + """The sent token payload must pass OAuthToken.from_resource(). - 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). + Mirrors the server-side validation (campus #656), which accepts + the RFC 6749 `scope` string alias dual-emitted by to_resource() + alongside `scopes` (campus #650). """ 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("scope", sent_token) self.assertIn("scopes", sent_token) - validated = campus.model.OAuthToken(**sent_token) + validated = campus.model.OAuthToken.from_resource(sent_token) self.assertEqual(validated.id, "tok123") + self.assertEqual(validated.scopes, ["campus.profile", "campus.identities"]) self.assertEqual(validated.scope, "campus.profile campus.identities") + def test_scope_only_payload_passes_server_validation(self): + """A scope-only payload — the token resource shape expected once + the campus #648 deprecation window closes — also passes the + server's from_resource() validation.""" + scope_only = { + "access_token": "tok456", + "expires_in": 3600, + "scope": "campus.profile", + } + validated = campus.model.OAuthToken.from_resource(scope_only) + self.assertEqual(validated.id, "tok456") + self.assertEqual(validated.scopes, ["campus.profile"]) + self.assertEqual(validated.scope, "campus.profile") + if __name__ == "__main__": unittest.main()