Introduce JWTProfile for declarative JWT validation#4191
Open
stevenvegt wants to merge 8 commits intomasterfrom
Open
Introduce JWTProfile for declarative JWT validation#4191stevenvegt wants to merge 8 commits intomasterfrom
stevenvegt wants to merge 8 commits intomasterfrom
Conversation
IntrospectAccessToken was not calling jwt.Validate, so tokens without exp/iat claims were silently accepted, and there was no max lifetime enforcement. This adds: - WithRequiredClaim for exp and iat - WithMaxDelta to reject tokens with lifetime exceeding 60s - WithAcceptableSkew for clock drift tolerance - Default base validators (IsExpirationValid, IsIssuedAtValid, IsNbfValid) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 new issues
|
|
Coverage Impact ⬇️ Merging this pull request will decrease total coverage on Modified Files with Diff Coverage (7)
🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
Each kind of JWT in this project (access tokens, bearer tokens, JAR, OpenID4VCI proof, VC, VP) had its own ad-hoc validation pattern, often duplicating logic like iss-to-kid binding or re-parsing the JWS to access protected headers. This introduces a JWTProfile type that captures the validation requirements declaratively, and threads it through ParseJWT. A JWTProfile defines: - Typ: required value of the JWT typ header - RequiredClaims: claims that must be present and non-empty - MaxValidity: max allowed exp - iat delta - Validators: additional checks (e.g. IssuerKidValidator) ParseJWT now applies the profile alongside signature verification: typ is checked early (before crypto), required-claims and max-validity options are forwarded to jwt.ParseString's internal validation, then non-empty checks and custom validators run on the parsed token. Profiles defined: - v1AccessTokenProfile (auth/services/oauth) - v1BearerTokenProfile (auth/services/oauth) - jarProfile (auth/api/iam) - openID4VCIProofProfile (vcr/issuer) - vcJWTProfile, vpJWTProfile (vcr/verifier) Additional VP hardening: the VP builder now always sets iat and exp (default 15min when caller doesn't specify), so verifiers can assume their presence. The VP profile requires nbf as the issuance-time anchor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
reinkrul
requested changes
Apr 16, 2026
Introspection used a hardcoded 60s cap (secureAccessTokenLifeSpan) while issuance used the configurable s.accessTokenLifeSpan. In non-strict mode this meant an operator with accesstokenlifespan>60 would issue tokens that introspect would reject as exceeding 60s. Adds JWTProfile.WithMaxValidity(d) to derive a profile copy with the caller's configured lifespan, and threads s.accessTokenLifeSpan through IntrospectAccessToken. Also adds paired tests proving the cap raises the ceiling and still enforces a max. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bundle of small review fixes from PR #4191: 1. JAR (auth/api/iam/jar.go) now has MaxValidity=5m. iat/exp are set in Sign(), not Create(), because the jarRequest is persisted in authzRequestObjectStore between an initial HTTP request (Create) and the counterparty's later fetch (Sign). Setting timestamps at Create would eat into the validity window. 2. ParseJWT (crypto/jwx.go) drops the per-claim jwt.WithRequiredClaim loop: WithMaxDelta already auto-requires its time claims, and the post-parse loop already checks presence and emptiness. Error message now mirrors jwx's format (%q not satisfied: ...) so callers see one consistent style. 3. Copyright year 2024 -> 2026 in crypto/jwt_profile.go. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ParseJWT previously accepted a variadic jwt.ParseOption which the auth package used for skew and the VCR verifier used for WithClock. This left other callers (jar.go, signature_verifier for VCs) at skew=0, risking spurious rejections on small clock drift. Unifies the plumbing: - JWTProfile gains a ClockSkew field and a WithClockSkew(d) method, mirroring WithMaxValidity. Zero value falls back to the new DefaultJWTClockSkew (5s). - ParseJWT's signature changes to (..., profile *JWTProfile, at *time.Time). The variadic is removed; historical verification (WithClock) now goes through the at parameter. Only VCR signature verification uses a non-nil at today. - Auth callers (authz_server parseAndValidateJwtBearerToken / IntrospectAccessToken) switch to profile.WithClockSkew(s.clockSkew), keeping the operator-configured auth.clockskew in effect. - vcr/issuer/openid.go drops its hardcoded WithAcceptableSkew(5s); covered by the default. - auth/api/iam/jar.go and vcr/verifier/signature_verifier.go (VC/VP) now get the 5s default instead of 0. The JWT_validity_too_long test bumps exp from +10s to +20s: with the new 5s default skew, jwx's WithMaxDelta treats skew as slack (delta <= max + skew), so the 5s max plus 5s skew accepts up to 10s. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both JWT types always carry an audience: the v1 bearer token builder (claimsFromRequest in relying_party.go) sets aud to the authorization server endpoint, and the OpenID4VCI proof spec §7.2.1 mandates aud == credential issuer identifier. Adding aud to RequiredClaims surfaces a missing claim as a clean profile-level error instead of reaching the downstream endpoint/issuer comparison with an absent value. Profiles where aud is conditional (JAR) or absent (v1 access token, VC/VP) are unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
reinkrul
approved these changes
Apr 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
JWT validation was scattered across the codebase with ad-hoc checks after
ParseJWTcalls. Each JWT type (access token, bearer token, JAR, OpenID4VCI proof, VC, VP) had its own validation pattern — some re-parsed the JWS to read headers, some duplicated iss-to-kid binding, and critically the v1 access token introspection did not calljwt.Validateat all, so tokens withoutexp/iatwere silently accepted with no max-lifetime enforcement. Clock skew tolerance was also inconsistent: the operator-configuredauth.clockskewonly reached two of the five ParseJWT call sites; the rest defaulted to 0.This PR introduces a declarative
JWTProfiletype and threads it throughParseJWT. All caller sites now share one validation shape, one skew policy, and one place to reason about what each JWT type requires.Changes
Core:
crypto/jwt_profile.go+crypto/jwx.goJWTProfiledeclares:Typ— requiredtypheader valueRequiredClaims— must be present and non-emptyMaxValidity— max allowedexp - iatdelta (auto-requires exp/iat via jwx'sWithMaxDelta)ClockSkew— acceptable clock skew; zero falls back toDefaultJWTClockSkew(5s)Validators— additional checks (e.g.IssuerKidValidator)Fluent overrides
WithMaxValidity(d)andWithClockSkew(d)let callers override per-call without mutating the shared profile globals.ParseJWT's signature becomes(tokenString, keyFunc, profile, at *time.Time):Typis checked early, before signature verification.MaxValidityandClockSkewflow into jwx's validation pass."<claim>" not satisfied: ...) for consistency.jwt.ParseOptionis gone; historical verification uses the explicitat *time.Time(only VC/VP signature verification passes non-nil).Profiles
v1AccessTokenProfile(auth/services/oauth) —typ: "at+jwt", requires iss/sub/service/exp/iat, iss-to-kid binding, max lifetime tracks configuredaccessTokenLifeSpanv1BearerTokenProfile(auth/services/oauth) — requires iss/sub/exp/iat/aud, max 5s (aud set byclaimsFromRequest, required because the builder always sets it)jarProfile(auth/api/iam) — requires iss/iat/exp, max 5m;iat/expare set inSign()(notCreate()) because the jarRequest is persisted between two HTTP requests — setting them at Create would eat into the validity windowopenID4VCIProofProfile(vcr/issuer) —typ: "openid4vci-proof+jwt", requires iat/aud (aud comparison againsti.issuerIdentifierURLstill runs post-parse)vcJWTProfile(vcr/verifier) — iss-to-kid binding for JWT VCsvpJWTProfile(vcr/verifier) — requiresnbfas issuance anchorClock skew unification
auth.clockskewconfig now reaches every ParseJWT call site viaprofile.WithClockSkew(s.clockSkew)(previously only two call sites honored it).DefaultJWTClockSkew(5s) applies whenever a profile doesn't set its own — no more silent zero-skew defaults for JAR/VC/VP paths.WithAcceptableSkew(5s)is gone (covered by the default).VP hardening
The VP builder (
vcr/holder/presenter.go) now always setsiatandexp(default 15m when caller doesn't passExpires). Previously VPs created via/internal/vcr/v2/holder/vpcould lack an expiration.Removed
jwt.Validatecall and manual iss/sub/service checks inIntrospectAccessTokenJWT validity too longcheck invalidateAccessTokenRequestjws.ParseStringre-parse invcr/issuer/openid.gothat existed only to checktypstrings.Split(keyID, "#")[0] != issuercheck in the VC signature path (covered byIssuerKidValidator)jwt.WithRequiredClaimloop inParseJWT(presence check is already done post-parse; jwx'sWithMaxDeltaauto-requires its time claims)Test plan
go test ./...passesTestService_IntrospectAccessTokencovers: missing/wrong typ, VP JWT replayed as access token, missing service/iss/exp/iat, iss/kid DID mismatch, excessive lifetime (both the strict 60s and configurableaccessTokenLifeSpanpaths), expired tokenTestParseJWTcases for default clock skew and profile-level overrideCreate/Sign/Parsetests verify iat/exp are set at sign time, caller's map is not mutated, and the 5m max is enforced on receive"<claim>" not satisfied: ...)🤖 Generated with Claude Code