Skip to content

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 JwtInterface to enforce algorithm pinning and typ checks in its own verify() 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 custom JwtInterface that omits the algorithm pin re-opens HS256/RS256 algorithm confusion regardless of how prominently the library warns against it.
  • Require the library's own JwtService when OIDC is enabled via an instanceof check in the construction guard — rejected as the headline fix: blocks the legitimate extension point of a custom JwtInterface (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: a nonce-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 JwtInterface remains a supported extension point. The AccessTokenVerifier wraps 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, debuggable invalid_grant."
  • ADR 0003's note that "algorithm pinning lives in the library's own JwtService. A consumer who supplies a custom JwtInterface implementation 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 which JwtInterface is 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.