key-cape/workplans/KEY-WP-0016-authorization-code-protocol-hardening.md
tegwick 2e78648fb2 chore(consistency): register KEY-WP-0016 and refresh work records [auto]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:44:55 +02:00

5.5 KiB

id type title domain repo status owner topic_slug created updated state_hub_workstream_id
KEY-WP-0016 workplan Authorization-code protocol hardening and token verification bindings infotech key-cape finished claude authorization-code-protocol-hardening 2026-09-06 2026-09-06 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

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

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

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

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.