Skip to content

chore: migrate from lestrrat-go/jwx v2 (EOL) to v3#4391

Open
reinkrul wants to merge 13 commits into
masterfrom
chore/jwx-v3-migration
Open

chore: migrate from lestrrat-go/jwx v2 (EOL) to v3#4391
reinkrul wants to merge 13 commits into
masterfrom
chore/jwx-v3-migration

Conversation

@reinkrul

@reinkrul reinkrul commented Jul 3, 2026

Copy link
Copy Markdown
Member

Migrates all direct usage of github.com/lestrrat-go/jwx from the EOL v2 series to v3. Closes part of #4383 (the master target).

Dependency

Requires go-did v0.22.0 (jwx v3) — pinned in go.mod. This was the last thing keeping jwx v2 in the tree (see nuts-foundation/go-did#155); the earlier merge blocker (branch pseudo-version) is now resolved.

Why

jwx v2 is deprecated (EOL as of v2.1.7) — no further bug/security fixes. jwx is a core JWT/JWS/JWK/crypto dependency, so staying on it is a security liability. We target v3, not v4: v4 hard-depends on encoding/json/v2 behind GOEXPERIMENT=jsonv2, which we won't bake into a production build until that graduates from the Go experiment (tracked as a follow-up in #4383).

Key changes

  • Imports rewritten v2 → v3; jwx v2 fully removed from go.mod/go.sum.
  • jwa algorithm constants → function calls (jwa.ES256jwa.ES256()); jwa values are now comparable structs.
  • Get()/accessor call sites adapted to v3 signatures (single-value accessors now return (T, bool); Get(name, &dst) error).
  • jwk.FromRawjwk.Import; (jwk.Key).Raw(&x)jwk.Export(key, &x).
  • didkey resolver: X25519 now built with crypto/ecdh (jwx dropped its x25519 package in v3).

Notable non-mechanical fixes (root-cause, behavior-preserving)

  • Raw signature verification: v3 no longer registers algorithms for the low-level jws.NewVerifier; migrated the detached-signature verification in crypto/jwx.go and vcr/signature/proof/jsonld.go to jwsbb.Verify(...).
  • AsMap / PrivateClaims removal: replaced the interim per-claim Get loops with JSON-based extraction in auth/client/iam, auth/api/iam/jar.go, and auth/services/oauth/authz_server.go. v3's per-claim Get errors on a null-valued claim, which would have rejected otherwise-valid external tokens; JSON extraction preserves v2's AsMap/PrivateClaims tolerance.
  • Azure Key Vault: parseKey now checks the key type/curve before jwk.Export, so an unsupported (e.g. RSA) key returns the intended only ES256 keys are supported rather than a leaky jwx-internal export error.

Behavior changes surfaced by v3 (kept intentionally)

  • RSA ≥ 2048-bit enforced: v3 rejects RSA keys under 2048 bits by default. Kept (security-positive); small test fixtures regenerated. TestInsecure1024BitRS512JWT still rejects the weak key.
  • Unsigned/self-attested credentials must use alg:none: v3 rejects an empty compact signature on any other alg at parse time. Production already handles alg:none; affected test fixtures updated. Any external issuer emitting unsigned credentials must set alg:none.
  • No jku / remote JWK fetching is introduced anywhere (v3 flipped the fetch whitelist default to allow-all — audited, we don't use it).

Testing

  • go build ./... clean; go test ./... green (107 packages).
  • Test updates are error-string/wording alignment to v3 and fixture regeneration only — no assertions weakened.

Follow-up

Assisted by AI

reinkrul added 2 commits July 2, 2026 13:17
GetHTTPStatusCode only did a direct type assertion for
HTTPStatusCodeError, so any fmt.Errorf("...: %w", err) wrap between an
error's origin and the Echo error handler silently discarded a
deliberately-set status code, falling back to 500. This affected
RequestServiceAccessToken when the remote presentation definition
endpoint returns an OAuth2 error (e.g. invalid_scope), among other
paths, per #2943.
jwx v2 is deprecated (EOL as of v2.1.7); migrate all direct usage to v3.

- Rewrite imports v2 -> v3 across the codebase.
- jwa algorithm constants -> function calls (jwa.ES256 -> jwa.ES256()).
- Adapt Get()/accessor call sites to v3 signatures ((T, bool) returns).
- jwk.FromRaw -> jwk.Import, (jwk.Key).Raw -> jwk.Export.
- Replace the removed raw jws.NewVerifier with jwsbb.Verify for detached
  signature verification (crypto, vcr JSON-LD proof).
- didkey resolver: use crypto/ecdh for X25519 (jwx/x25519 removed in v3).
- Faithfully replace token.AsMap/PrivateClaims with JSON-based extraction so
  null-valued claims are still tolerated (v3 per-claim Get errors on null).
- azure keyvault: validate key type before jwk.Export for a clear error.
- Point go-did at its jwx-v3 branch (pending a tagged go-did release).

Assisted by AI
Comment thread auth/api/iam/api.go Outdated
Comment thread auth/api/iam/dpop.go
Comment thread auth/api/iam/jar.go Outdated
Comment thread auth/api/iam/s2s_vptoken.go
Comment thread auth/api/iam/validation.go
Comment thread auth/client/iam/client.go Outdated
Comment thread auth/client/iam/client.go
Addresses review feedback on the jwx v3 migration:

- Add crypto/jwx.AsMap(joseObject) helper that converts a jwt.Token's claims
  to a map via JSON (preserving null-valued claims, unlike a per-field Get
  loop which errors on null). Replaces the duplicated marshal/unmarshal blocks
  in auth/client/iam, auth/api/iam/jar, auth/services/oauth, and the vcr test
  helper, and the equivalent loop in spi.FromJWK. The helper checks both the
  marshal and unmarshal errors, so those are no longer ignored.
- Keep the per-field Get loop for JOSE *headers* (DecryptJWE, ExtractProtectedHeaders):
  a JSON round-trip flattens rich header values (e.g. the x5c certificate
  chain) and breaks callers that rely on their concrete Go types.
- Remaining discarded returns are single-value accessors that returned no
  error/bool in v2 (e.g. token.Issuer()), so ignoring the new bool is faithful.

Assisted by AI
@reinkrul reinkrul marked this pull request as ready for review July 3, 2026 14:03
Replaces the interim jwx-v3-migration branch pseudo-version with the tagged
go-did v0.22.0 release (jwx v3). Clears the merge blocker for the jwx v3 migration.

Assisted by AI
@qltysh

qltysh Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

20 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 7): introspectAccessToken 20

; Conflicts:
;	auth/oauth/types.go
;	go.mod
;	go.sum
@qltysh

qltysh Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Qlty


Coverage Impact

⬇️ Merging this pull request will decrease total coverage on master by 0.06%.

Modified Files with Diff Coverage (37)

RatingFile% DiffUncovered Line #s
Coverage rating: C Coverage rating: C
crypto/memory.go100.0%
Coverage rating: C Coverage rating: C
crypto/jwx.go77.2%155-159, 245-246...
Coverage rating: A Coverage rating: A
auth/api/iam/dpop.go100.0%
Coverage rating: A Coverage rating: A
auth/api/iam/params.go100.0%
Coverage rating: B Coverage rating: B
discovery/store.go100.0%
Coverage rating: A Coverage rating: A
core/echo_errors.go100.0%
Coverage rating: B Coverage rating: B
discovery/module.go100.0%
Coverage rating: B Coverage rating: B
auth/api/iam/jar.go100.0%
Coverage rating: F Coverage rating: F
crypto/jwt_profile.go0.0%80-82
Coverage rating: A Coverage rating: A
auth/api/iam/metadata.go100.0%
Coverage rating: B Coverage rating: B
crypto/dpop/dpop.go93.9%221-222
Coverage rating: F Coverage rating: F
crypto/jwx/algorithm.go0.0%54
Coverage rating: B Coverage rating: B
auth/api/iam/api.go66.7%407-408
Coverage rating: B Coverage rating: B
auth/api/iam/openid4vp.go100.0%
Coverage rating: B Coverage rating: B
auth/api/iam/s2s_vptoken.go100.0%
Coverage rating: D Coverage rating: D
crypto/storage/azure/keyvault.go25.0%217-219
Coverage rating: B Coverage rating: B
auth/client/iam/client.go100.0%
Coverage rating: B Coverage rating: B
auth/services/oauth/authz_server.go95.0%531-532
Coverage rating: B Coverage rating: B
auth/api/iam/validation.go100.0%
Coverage rating: C Coverage rating: C
crypto/storage/spi/interface.go60.0%86-87
New Coverage rating: F
crypto/jwx/claims.go0.0%33-42
Coverage rating: B Coverage rating: B
pki/denylist.go100.0%
Coverage rating: B Coverage rating: B
vcr/pe/util.go100.0%
Coverage rating: B Coverage rating: B
vcr/issuer/openid.go100.0%
Coverage rating: A Coverage rating: A
vcr/credential/util.go100.0%
Coverage rating: C Coverage rating: C
vdr/didjwk/resolver.go100.0%
Coverage rating: B Coverage rating: B
network/dag/signing.go100.0%
Coverage rating: C Coverage rating: C
vdr/didnuts/manager.go100.0%
Coverage rating: A Coverage rating: A
network/dag/parser.go100.0%
Coverage rating: A Coverage rating: A
vdr/didkey/resolver.go33.3%85-86
Coverage rating: A Coverage rating: A
network/dag/verifier.go66.7%63-64
Coverage rating: A Coverage rating: A
vcr/pe/presentation_definition.go100.0%
Coverage rating: A Coverage rating: A
vdr/didnuts/validators.go100.0%
Coverage rating: C Coverage rating: C
vdr/didnuts/ambassador.go100.0%
Coverage rating: B Coverage rating: B
vcr/signature/proof/jsonld.go60.0%146-147
Coverage rating: C Coverage rating: C
http/tokenV2/authorized_keys.go100.0%
Coverage rating: B Coverage rating: B
http/tokenV2/middleware.go100.0%
Total84.9%
🤖 Increase coverage with AI coding...
In the `chore/jwx-v3-migration` branch, add test coverage for this new code:

- `auth/api/iam/api.go` -- Line 407-408
- `auth/services/oauth/authz_server.go` -- Line 531-532
- `crypto/dpop/dpop.go` -- Line 221-222
- `crypto/jwt_profile.go` -- Line 80-82
- `crypto/jwx.go` -- Lines 155-159, 245-246, 301-302, 429-433, and 527-539
- `crypto/jwx/algorithm.go` -- Line 54
- `crypto/jwx/claims.go` -- Line 33-42
- `crypto/storage/azure/keyvault.go` -- Line 217-219
- `crypto/storage/spi/interface.go` -- Line 86-87
- `network/dag/verifier.go` -- Line 63-64
- `vcr/signature/proof/jsonld.go` -- Line 146-147
- `vdr/didkey/resolver.go` -- Line 85-86

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

The jwx v3 migration extracts JAR request-object claims via jwx.AsMap
(json.Marshal + Unmarshal into a map), which decodes JSON arrays as
[]interface{} rather than the []string that jwx v2's AsMap produced.
oauthParameters.get only matched []string, so a single-value "aud" claim
resolved to "" and audience validation failed (surfaced by the e2e OAuth
flow: "invalid audience, expected: ..., was: ").

Handle []interface{} single-element arrays too, fulfilling get's documented
"array values with len == 1 are treated as single string values" contract
regardless of whether the value came from query params ([]string) or JSON
claims ([]interface{}). Add a unit test covering the accessor.

Assisted by AI
Comment thread crypto/jwx/jwx_es256k.go Outdated
Comment thread auth/api/iam/jar.go Outdated
return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestObject, Description: "request signature validation failed", InternalError: err}
}
claimsAsMap, err := token.AsMap(ctx)
claimsAsMap, err := jwx.AsMap(token)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The audience check in handleAuthorizeRequestFromHolder now always fails, breaking the JAR authorization flow. This is what's causing the e2e failure on this PR (invalid audience, expected: https://nodeA/oauth2/subjectA, was: ).

jar.validate now uses the new jwx.AsMap() helper, which does a JSON round-trip. jwx v3 always marshals aud as a JSON array (FlattenAudience is not enabled anywhere in the codebase), so after json.Unmarshal the claim comes back as []interface{}{"..."}. The old v2 token.AsMap(ctx) returned the audience as []string.oauthParameters.get() in params.go only handles string and []string, so params.get(jwt.AudienceKey) returns "" and the check at openid4vp.go:99 rejects every request.

The existing unit test doesn't catch this because it puts aud into the oauthParameters map directly as []string, bypassing the JWT parse path.

Comment thread crypto/jwx.go Outdated
Comment thread crypto/jwx.go Outdated
Comment thread vcr/signature/proof/jsonld.go Outdated
Comment thread crypto/jwx.go Outdated
@JorisHeadease

Copy link
Copy Markdown
Contributor

There are also a lot of resolved comments about not ignoring the errors, but the code still ignores them

Comment thread crypto/jwx/jwx_es256k_test.go Outdated
@reinkrul

reinkrul commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

There are also a lot of resolved comments about not ignoring the errors, but the code still ignores them

I checked them on case-by-case basis, and where they're ignored, they were ignored before. The semantic didn't change, as previously the single call basically did what is now done in 2 calls (meaning nil/error-behavior will be the same).

Edit: I added an explicit check for 2 of JWK() cases where the safety comes from another package.

reinkrul added 2 commits July 9, 2026 10:24
For the raw/detached signature verification (ParseJWS and the JSON-LD proof
verifier), use the jws-level jws.VerifierFor(alg) instead of reaching into the
low-level jwsbb.Verify building block.

jws.VerifierFor is the intended v3 replacement for the v2 jws.NewVerifier(alg)
pattern (which no longer registers standard algorithms in v3). For standard
algorithms it delegates to jwsbb.Verify anyway, so the result is identical, but
it stays at the public jws API level and honours any verifier registered via
jws.RegisterVerifier (jwsbb.Verify bypasses the registry by design).

Assisted by AI
The es256k registration file is only compiled with -tags jwx_es256k, so the
jwa constant->function change was missed during the v2->v3 migration.

Assisted by AI
reinkrul added 5 commits July 9, 2026 13:38
Two follow-ups to the jwx v2->v3 migration:

- Null-valued protected header members were rejected. The header extraction
  loops (DecryptJWE, ExtractProtectedHeaders) use per-field Get to preserve
  rich types like the x5c certificate chain, but v3's Get errors on a null
  value, so a JWS/JWE with a null-valued custom header was rejected where v2's
  Headers.AsMap returned nil. Add jwx.HeadersAsMap, which keeps concrete Go
  types and stores a null member as nil (the only failure mode of Get on an
  existing key into interface{}). Use it in both loops.

- Move and fix the previously-uncompilable ES256K test. It lived in package
  jwx but called crypto.ParseJWT (an import cycle), passed the wrong argument
  count, and signed with a P-256 key for a secp256k1 algorithm. It never
  compiled because CI only builds -tags jwx_es256k, never tests it. Moved to
  package crypto and signed with a real secp256k1 key.

Assisted by AI
AsMap was misleadingly generic: it took `any` but is really about a JWT token's
claims. Rename to ClaimsAsMap and type the parameter as jwt.Token for
compile-time safety and clearer intent.

The one non-token caller (spi.FromJWK, a jwk.Key) is not "claims", so it now
does its own json.Marshal/Unmarshal inline. A jwk.Key has no null-valued
members, so it does not need the null-tolerant helper.

Assisted by AI
Its only callers are DecryptJWE and ExtractProtectedHeaders in the crypto
package, so move it out of the exported crypto/jwx API and unexport it. Drop
its unit test.

Assisted by AI
@reinkrul reinkrul requested a review from JorisHeadease July 9, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants