Closes the local protocol surface of gap G01 from the scope assessment (KEY-WP-0016). The browser grant validated PKCE, client id and scopes but left four bindings unenforced, and UserInfo verified less than the caller CLI does. Authorization-code path: bind the exchange to the redirect URI the code was issued for, refuse clients whose registration does not permit the grant, and authenticate confidential clients with a digest-based constant-time comparison over the same credential sources as the service grant. An empty grantTypes stays an implicit authorization-code client, matching config validation. Code consumption: SessionStore.Consume reads and deletes under one lock. The previous Get/Delete pair spanned JWT signing, and the added test reproduces the race against that version -- 9 of 16 concurrent exchanges succeeded, and a failed exchange left the code replayable. UserInfo: check the JOSE header algorithm before trusting the signature, require the configured issuer, and require an access token rather than accepting an ID token of the right shape. Purpose is decided on the scope claim so the issued token contract, which consumers pin exactly, does not change. SCOPE.md and the assessment record which bindings are now enforced and that the Authelia upstream-trust assumption remains open, so G01 is not fully closed and no profile-conformance claim is made. 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
5.2 KiB
| id | type | title | domain | repo | status | owner | topic_slug | created | updated |
|---|---|---|---|---|---|---|---|---|---|
| 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 |
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
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
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
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
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.