Skip to content

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

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

chore: migrate from lestrrat-go/jwx v2 (EOL) to v3#4391
reinkrul wants to merge 6 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
Comment on lines +405 to +406
key, _ := token.DPoP.Headers.JWK()
hash, _ := key.Thumbprint(crypto.SHA256)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

let's not ignore these errors

Comment thread auth/api/iam/dpop.go
return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil
}
// check if the jti is already used, if not add it to the store for the duration of the access token lifetime
jti, _ := dpopToken.Token.JwtID()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

what if an error is returned?

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 = 6): 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 increase total coverage on master by 0.01%.

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.go82.1%131-132, 165-169...
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.go100.0%
Coverage rating: F Coverage rating: F
crypto/jwx/algorithm.go0.0%54
Coverage rating: B Coverage rating: B
auth/api/iam/api.go100.0%
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.go100.0%
New Coverage rating: F
crypto/jwx/claims.go0.0%30-39
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.go100.0%
Coverage rating: C Coverage rating: C
http/tokenV2/authorized_keys.go100.0%
Coverage rating: B Coverage rating: B
http/tokenV2/middleware.go100.0%
Total88.1%
🤖 Increase coverage with AI coding...
In the `chore/jwx-v3-migration` branch, add test coverage for this new code:

- `auth/services/oauth/authz_server.go` -- Line 531-532
- `crypto/jwt_profile.go` -- Line 80-82
- `crypto/jwx.go` -- Lines 131-132, 165-169, 255-256, 420-421, and 516-528
- `crypto/jwx/algorithm.go` -- Line 54
- `crypto/jwx/claims.go` -- Line 30-39
- `crypto/storage/azure/keyvault.go` -- Line 217-219
- `network/dag/verifier.go` -- Line 63-64
- `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
import "github.com/lestrrat-go/jwx/v3/jwa"

func init() {
AddSupportedAlgorithm(jwa.ES256K)

@JorisHeadease JorisHeadease Jul 8, 2026

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.

➜  nuts-node git:(chore/jwx-v3-migration) go build -tags jwx_es256k ./crypto/...
# github.com/nuts-foundation/nuts-node/crypto/jwx
crypto/jwx/jwx_es256k.go:26:24: cannot use jwa.ES256K (value of type func() jwa.SignatureAlgorithm) as jwa.SignatureAlgorithm value in argument to AddSupportedAlgorithm
Suggested change
AddSupportedAlgorithm(jwa.ES256K)
AddSupportedAlgorithm(jwa.ES256K())

Comment thread auth/api/iam/jar.go
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
Comment on lines +128 to +130
for _, k := range protectedHeaders.Keys() {
var v interface{}
if err = protectedHeaders.Get(k, &v); err != nil {

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 v2 version protectedHeaders.AsMap(ctx) properly returned null-members as a nil in the map.

This new per-field Get loop instead fails on them (failed to assign value for field "...": source value is invalid (<nil>)), so a JWS/JWE with a null-valued custom protected header is now rejected. The doc comment on crypto/jwx/claims.go AsMap() describes exactly this pitfall:

v3's per-field Get returns an error for a null-valued member, whereas the JSON round-trip preserves null members as nil - matching v2's AsMap behaviour and avoiding rejection of otherwise-valid input.

We probably want to handle null-valued members separately in this loop, keeping the concrete Go types for rich headers like x5c.

Comment thread crypto/jwx.go
// such as the x5c certificate chain keep their concrete Go types for callers.
for _, k := range protectedHeaders.Keys() {
var v interface{}
if err := protectedHeaders.Get(k, &v); err != nil {

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.

}
challenge := fmt.Sprintf("%s.%s", splittedJws[0], tbv)
if err = jswVerifier.Verify([]byte(challenge), sig, key); err != nil {
if err = jwsbb.Verify(key, alg.String(), []byte(challenge), sig); err != nil {

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.

Are you sure we want to use this over jws.VerifierFor(alg) here? The building block mentions the following:

// Package jwsbb provides the building blocks (hence the name "bb") for JWS operations.
// It should be thought of as a low-level API, almost akin to internal packages
// that should not be used directly by users of the jwx package. However, these exist
// to provide a more efficient way to perform JWS operations without the overhead of
// the higher-level jws package to power-users who know what they are doing.
//
// This package is currently considered EXPERIMENTAL, and the API may change
// without notice. It is not recommended to use this package unless you are
// fully aware of the implications of using it.
//
// All bb packages in jwx follow the same design principles:
// 1. Does minimal checking of input parameters (for performance); callers need to ensure that the parameters are valid.
// 2. All exported functions are strongly typed (i.e. they do not take `any` types unless they absolutely have to).
// 3. Does not rely on other public jwx packages (they are standalone, except for internal packages).
//
// This implementation uses github.com/lestrrat-go/dsig as the underlying signature provider.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I missed the experimental flag... I'll see if we can use the usual jws.Verifier

Comment thread crypto/jwx.go
payload = append(payload, part...)
}
err = verifier.Verify(payload, signature.Signature(), key)
err = jwsbb.Verify(key, alg.String(), payload, signature.Signature())

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.

@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

t.Run("test ES256K", func(t *testing.T) {
ecKey := test.GenerateECKey()
token := jwt.New()
signature, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256K, ecKey))

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.

Suggested change
signature, _ := jwt.Sign(token, jwt.WithKey(jwa.ES256K(), ecKey))

@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).

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