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

123 lines
5.5 KiB
Markdown

---
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.