key-cape/workplans/KEY-WP-0019-upstream-provider-token-verification.md
tegwick 3ec8404c87
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 33s
Move the caller verifier onto internal/jose
Completes KEY-WP-0019. Client.Verify now delegates JWK-set parsing and RS256
signature checking to internal/jose, so the caller path and upstream provider
verification share one implementation rather than two copies that drift. Its
claim policy stays put: audience and nonce bindings belong to the caller.

The existing authclient tests pass unchanged, which is the evidence the
migration preserved behaviour. One deliberate strictness increase: a key set
containing any malformed RSA signing key is refused outright rather than used
alongside a good key. KeyCape's /jwks publishes a single key, so no current
deployment is affected.

Adds direct tests for internal/jose. It is now the single verifier behind both
paths, and testing it only through its callers would leave duplicate key ids,
crit headers, even exponents and undersized moduli covered by accident.

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-07 09:01:16 +02:00

6.3 KiB

id type title domain repo status owner topic_slug created updated state_hub_workstream_id
KEY-WP-0019 workplan Verify upstream Authelia ID tokens infotech key-cape finished claude upstream-provider-token-verification 2026-09-07 2026-09-07 87ea18ea-dc60-52ce-baf4-476b46aa06f9

Closes the remaining half of gap G01 in history/2026-09-05-011726-scope-intent-assessment.md. The Authelia adapter decodes upstream ID-token claims without verifying the signature, issuer, audience or expiry, justified in a comment by a server-to-server TLS boundary.

Operator decision (2026-09-07, Bernd): verify the token itself. The KeyCape→Authelia hop is to be HTTPS as defence in depth, but KeyCape will not monitor, check or gate on that — transport enforcement is friction without protection value here, and a check that must be configured correctly to help is itself a failure mode. Verification is chosen precisely because it does not depend on the transport being what we believe it is. No HTTPS validation, opt-in flag or transport telemetry is to be added.

Extract a shared RS256/JWKS verifier

id: KEY-WP-0019-T01
status: done
priority: high
state_hub_task_id: "68c1b099-96b4-5065-a4be-8592438a0ca9"

internal/authclient already contains a strict RS256 verifier — kid required, no crit, RSA-only keys of at least 2048 bits with an odd exponent. The upstream verification needs the same rules. Duplicating security-critical verification is how two copies drift and one silently misses a fix, so extract the reusable half into internal/jose: strict JWK-set parsing and signature verification returning claims. Claim policy stays with each caller, whose audience, nonce and issuer rules genuinely differ.

Added internal/jose: strict JWK-set parsing (RSA signing keys only, at least 2048 bits, odd exponent 3 or greater, no duplicate key ids) and RS256 signature verification returning claims. All failures collapse to one ErrVerification so a caller cannot leak which check failed back to whoever supplied the token. authclient is not yet migrated onto it — see T05.

Verify the Authelia ID token

id: KEY-WP-0019-T02
status: done
priority: high
state_hub_task_id: "8105030b-c91f-5a40-a221-4a38c8531e1c"

Before trusting any claim from the upstream ID token: verify the RS256 signature against Authelia's published keys, require the issuer Authelia advertises, require KeyCape's own client ID in the audience, and reject expired or not-yet-valid tokens. Fail closed — an unavailable or unparseable key set denies the login rather than falling back to unverified claims. Resolve Authelia's metadata and key set from the server-side token base URL so a split-horizon deployment, where the advertised public URL is not reachable from KeyCape, still verifies; allow explicit issuer and JWKS overrides for deployments where that inference is wrong. Refresh the key set once on an unknown key id so provider key rotation does not require a KeyCape restart. Replace the comment claiming the TLS boundary as justification.

idTokenVerifier in the adapter resolves the issuer and JWKS path from the provider's discovery document, rebasing the advertised jwks_uri path onto the server-side token base URL so split-horizon deployments resolve, with issuer/jwksUrl config overrides where that inference is wrong. Key sets are cached and refetched once on an unknown key id. parseIDTokenClaims remains for diagnostics but is documented as outside the authentication path.

Prove the rejections

id: KEY-WP-0019-T03
status: done
priority: high
state_hub_task_id: "163f64dd-aa87-5ce3-b3be-d134fa993eb6"

Negative tests per condition: forged signature, alg: none and other algorithms, unknown key id, wrong issuer, missing and wrong audience, expired token, future iat/nbf, malformed key set, and an unreachable JWKS endpoint. A positive case must confirm a genuine token still authenticates, and a rotation case that a new key id is picked up after one refresh.

Thirteen rejection cases in verify_test.go plus the algorithm and rotation cases. Confirmed they catch the original defect rather than merely passing: with the unverified parse restored, all fifteen fail. The existing adapter tests now build genuinely signed tokens against a fixture provider, so they exercise the real verification path instead of being routed around it.

Reconcile the records

id: KEY-WP-0019-T04
status: done
priority: medium
state_hub_task_id: "8a208092-7e85-5ad9-a8c4-0dddc66aaaee"

Update SCOPE.md and G01's status in the assessment to state that upstream provider tokens are now verified independently of transport, and record the operator decision and its reasoning so the absence of transport enforcement reads as a choice rather than an oversight. G01 closes with this; complete profile conformance is still not claimed.

SCOPE.md and G01's status record the verification and the operator decision, including why no transport enforcement exists, so its absence reads as a choice.

Consolidate the caller verifier

id: KEY-WP-0019-T05
status: done
priority: medium
state_hub_task_id: "0dc909fe-1772-5816-9e11-dbe7664a4e1f"

Move internal/authclient's inline verification onto internal/jose so one implementation serves both paths. Kept separate from T01/T02 deliberately: the caller verifier is a tested security path, and destabilising it in the same change that introduces upstream verification would confuse the evidence for both.

Client.Verify now fetches the raw key set and delegates parsing and signature checking to internal/jose, keeping its own claim policy — audience and nonce bindings are the caller's and cannot live in a shared verifier. Its existing tests pass unchanged, which is the point: the migration preserved behaviour.

One deliberate strictness increase: a key set containing any malformed RSA signing key is now refused outright, where the previous code would have used a good key alongside a bad one. KeyCape's own /jwks publishes a single key, so this affects no current deployment.

Also added direct tests for internal/jose (17 cases). It is now the single verifier behind both paths, so testing it only through its callers would leave its edges — duplicate key ids, crit, even exponents, undersized moduli — to be covered by accident.