key-cape/workplans/KEY-WP-0019-upstream-provider-token-verification.md
tegwick 8707d375a2
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s
Record what the consolidated verifier's tests actually establish
KEY-WP-0019-T05 rested on "existing tests pass unchanged", which shows the move
onto internal/jose preserved behaviour but not that the behaviour is checked.
Disabling the RSA comparison in jose.Verify fails both callers' suites, so the
shared verifier is load-bearing on each path.

Correct the comment on the caller-side cases added with the move. It claimed the
existing tamper case fails on the signature segment's shape before any key is
used; it does not — appending eight characters leaves a decodable segment, so
that case does reach and does check the signature. The two new cases are still
worth their place for what a byte-level tamper cannot reach: a structurally valid
token signed by an unpublished key under a published kid tests that key selection
is bound to the key set, and an undersized modulus in the published set tests
that ParseJWKS strictness denies rather than falling through to the claims.

Record both in the workplan and in G01's status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 867844@bnt-lap001
Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 09:05:16 +02:00

155 lines
6.9 KiB
Markdown

---
id: KEY-WP-0019
type: workplan
title: "Verify upstream Authelia ID tokens"
domain: infotech
repo: key-cape
status: finished
owner: claude
topic_slug: upstream-provider-token-verification
created: "2026-09-07"
updated: "2026-09-07"
state_hub_workstream_id: "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
```task
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
```task
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
```task
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
```task
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
```task
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.
Follow-on evidence for the same task: "existing tests pass unchanged" shows the
move preserved behaviour, not that the behaviour is checked, so the check was
made explicit. Disabling the RSA comparison in `jose.Verify` fails both callers'
suites, which establishes the shared verifier is load-bearing on each path.
Added two caller-side cases the existing tamper case cannot reach, since it
alters signature bytes for a key that is legitimately published: a structurally
valid token signed by an unpublished key under a published kid, which fails only
if key selection is bound to the key set, and an undersized modulus in the
published set, which must deny rather than fall through to the claims.