Fail-closed at consumer-delegated security seams
ADR 0003 documents that algorithm pinning, typ:at+jwt, and kid live in the library's own JwtService, and that a consumer who supplies a custom JwtInterface "remains responsible for pinning algorithms in their own verifier." On second look this is a documentation mitigation for a security boundary the contract does not enforce — and the OIDC plan surfaced two siblings of the same shape: opaque authorization codes silently losing nonce when the consumer repository forgets to persist it, and UserInfo verifying signature/expiry but not iss. We decided that where the library delegates a security-relevant operation to a consumer seam, the library must fail-closed at the delegation point rather than mitigate with documentation. Concretely: a named AccessTokenVerifier in src/oidc/ owns the algorithm pin, typ:at+jwt check, iss equality check, and the recorded v1 aud policy regardless of which JwtInterface the consumer supplies; AuthCodeGrant.respondToAccessTokenRequest throws invalid_grant when an opaque-code payload arrives without a nonce that /authorize carried.
Considered Options
- Trust a consumer-supplied
JwtInterfaceto enforce algorithm pinning andtypchecks in its ownverify()implementation (ADR 0003's stance, narrowed here to access-token verification only) — rejected: documentation cannot enforce a security property the contract does not require. A customJwtInterfacethat omits the algorithm pin re-opens HS256/RS256 algorithm confusion regardless of how prominently the library warns against it. - Require the library's own
JwtServicewhen OIDC is enabled via aninstanceofcheck in the construction guard — rejected as the headline fix: blocks the legitimate extension point of a customJwtInterface(HSM-backed signing, KMS integration, audited crypto wrappers). Acceptable as belt-and-braces, but not as the primary mechanism. - Mitigate the opaque-code
nonce-loss footgun with documentation alone (the OIDC plan's original stance, noted in five places) — rejected: anonce-less ID token is a silent replay-vulnerability degradation, not a debuggability issue. The library must fail-closed when delegated state is missing. - Major bump for the
verify()algorithm-pin behaviour change — rejected: no legitimate consumer breaks; the pin only rejects attacks and misconfigurations. A minor bump with a CHANGELOG entry and an upgrade-guide note is the right semver call. "Safe in practice" is not "zero behaviour change" — the upgrade guide must say so.
Consequences
- A custom
JwtInterfaceremains a supported extension point. TheAccessTokenVerifierwraps the consumer-supplied verifier and supplies the checks the interface cannot enforce, so HSM/KMS integrations work without re-opening the verification trust boundary. - UserInfo's access-token verification has a single home rather than living inline; future access-token verifiers (a resource-server adapter, an introspection path under OIDC) reuse the seam by import, not by re-implementation.
- The opaque-code
nonce-loss guard adds a small runtime check at the auth-code grant boundary, but the failure mode shifts from "silent security degradation" to "loud, debuggableinvalid_grant." - ADR 0003's note that "algorithm pinning lives in the library's own
JwtService. A consumer who supplies a customJwtInterfaceimplementation remains responsible for pinning algorithms in their own verifier" remains correct for signing but no longer applies to access-token verification — the seam owns verification regardless of whichJwtInterfaceis injected. - Reviewers should challenge any future PR that delegates a security property to a consumer seam without a fail-closed guard at the call site. This is the principle, not just the v1 application of it.