--- id: KEY-WP-0016 type: workplan title: "Authorization-code protocol hardening and token verification bindings" domain: infotech repo: key-cape status: finished owner: claude topic_slug: authorization-code-protocol-hardening created: "2026-09-06" updated: "2026-09-06" state_hub_workstream_id: "22698171-f283-59a9-9443-183faaeb6287" --- Closes gap G01 of `history/2026-09-05-011726-scope-intent-assessment.md`, the assessment's first recommended item. The browser grant validates PKCE, client id and scopes but does not bind the redirect URI, authenticate confidential clients, enforce grant-type eligibility, or consume the authorization code atomically. UserInfo verification is weaker than the caller CLI's contract. Scope is the local protocol surface only. Upstream provider-token verification (the Authelia adapter's deliberate no-signature-check trust assumption) is a separate trust-contract decision and is not addressed here. ## Consume authorization codes atomically ```task id: KEY-WP-0016-T01 status: done priority: high state_hub_task_id: "a5171df0-4905-518d-a52f-7152b980af79" ``` `SessionStore.Get` reads a code and `Delete` removes it in separate lock acquisitions, with signing in between, so two simultaneous exchanges can both observe the same session before either deletes it. Replace the token path's lookup with a single-operation consume that removes the session under the same lock that reads it, so exactly one concurrent exchange can succeed. A failed exchange must not leave a replayable code. Cover with a concurrent-reuse test that asserts exactly one success across parallel requests. Added `SessionStore.Consume`, which reads and deletes under one lock, and moved the token path onto it. Verified the defect was real before claiming the fix: with `Get`/`Delete` restored, 9 of 16 concurrent exchanges succeeded and a PKCE-failed exchange left the code replayable. Both now fail closed — `TestConcurrentCodeExchangeSucceedsExactlyOnce`, `TestFailedExchangeConsumesTheCode`. ## Bind redirect URI, grant type and confidential client authentication ```task id: KEY-WP-0016-T02 status: done priority: high state_hub_task_id: "1d0fb108-273f-5c9a-a7c7-7640f19bad9d" ``` On the authorization-code path: require `redirect_uri` and compare it for exact equality with the value stored at authorization time; reject a client whose registration does not permit `authorization_code` (an empty `grantTypes` stays an implicit authorization-code client, matching config validation, but a `client_credentials`-only client must be refused); and authenticate confidential clients with a constant-time secret comparison, accepting the same `client_secret_basic` and form-encoded credentials as the service grant. Public clients continue to present no secret and must not be able to supply one to impersonate a confidential registration. Negative tests per condition. Implemented all three in `token.go`, with `basicClientCredentials` and `secretsEqual` shared with the service grant (digest-first comparison, so a wrong-length secret is indistinguishable from a wrong value). Tests: `TestCodeExchangeRejectsRedirectURIMismatchAndOmission` (omitted, different, traversal-shaped and trailing-slash variants), `TestCodeExchangeRejectsServiceOnlyClient`, `TestCodeExchangeAllowsImplicitAuthorizationCodeClient`, `TestConfidentialCodeClientRequiresItsSecret` and `TestPublicClientSecretIsIgnoredAndEmptyConfidentialSecretIsRejected`. The `keycape login` CLI already sent `redirect_uri`, so no caller change was needed; the authclient test stub was recording no redirect and is now faithful to real `/authorize`. ## Harden UserInfo token verification ```task id: KEY-WP-0016-T03 status: done priority: high state_hub_task_id: "336db63a-e4fd-522d-86b6-0bf54a31af1c" ``` UserInfo verifies an RSA signature and expiry but ignores its own `Issuer` field and does not check the JOSE header algorithm, the issuer claim, or the token purpose, so an ID token or a foreign-issuer token of the right shape is accepted where an access token is required. Enforce `alg=RS256` from the header, reject `none` and any other algorithm, require the configured issuer, and reject tokens that are not access tokens. Negative tests per condition. `validateJWT` became `validateAccessToken`, checking the JOSE header before trusting the signature, requiring the configured issuer (an empty configured issuer is a misconfiguration, not a skipped check), and requiring the `scope` claim the token endpoint sets only on access tokens. Purpose is decided verification-side deliberately: adding a `token_use` claim would change the issued-token contract that consumers pin exactly. Tests: `TestUserinfoRejectsUnverifiedTokenShapes` (foreign issuer, missing issuer, ID token presented as an access token) and `TestUserinfoRejectsNonRS256Algorithms`, which restates the header algorithm over a genuine RS256 signature so it isolates the header check. ## Reconcile scope and assessment records ```task id: KEY-WP-0016-T04 status: done priority: medium state_hub_task_id: "90efa6ef-9495-5716-bee2-49d01c9ce05d" ``` Update `SCOPE.md`'s protocol-hardening limit and G01's closure status in the assessment to state exactly which bindings are now enforced and which remain open, naming the upstream-trust item as still outstanding. Do not claim complete profile conformance: G01 closure covers these bindings, not the whole profile. Updated `SCOPE.md`'s protocol-hardening limit and recorded a partial-closure status under G01 in the assessment, naming the Authelia upstream-trust item as still open. G01 is explicitly not fully closed.