Skip to content

chore: migrate from lestrrat-go/jwx v2 (EOL) to v3 (V6.2 backport of #4391)#4411

Open
reinkrul wants to merge 2 commits into
V6.2from
chore/jwx-v3-migration-v6.2
Open

chore: migrate from lestrrat-go/jwx v2 (EOL) to v3 (V6.2 backport of #4391)#4411
reinkrul wants to merge 2 commits into
V6.2from
chore/jwx-v3-migration-v6.2

Conversation

@reinkrul

@reinkrul reinkrul commented Jul 15, 2026

Copy link
Copy Markdown
Member

Backport of #4391 to V6.2.

Scope note

This is a targeted backport of the jwx v2 → v3 migration only. V6.2 has diverged from master in ways that made a direct cherry-pick non-mechanical; the following were deliberately kept out of scope:

  • JWTProfile (crypto/jwt_profile.go, from Introduce JWTProfile for declarative JWT validation #4191) doesn't exist on V6.2. ParseJWT keeps its V6.2 signature (options ...jwt.ParseOption); the profile-based required-claims/max-validity/clock-skew logic from master was dropped, along with the tests that exercise it.
  • RS256 in SupportedAlgorithms (from feat(crypto): accept RS256 in SupportedAlgorithms #4252, unrelated to jwx) was not added — V6.2's algorithm allowlist is unchanged apart from the v2→v3 syntax translation. As a corollary, authz_server_test.go's "wrong signing algorithm" test and crypto/jwx_test.go's "unsupported algorithm" test keep their original RSA/RS256 fixtures (RS256 stays unsupported here) rather than master's HS256 swap — master only switched because feat(crypto): accept RS256 in SupportedAlgorithms #4252 made RS256 supported there, not because of anything in jwx v3 itself (verified empirically: jwx v3 signs fine with a 512-bit RSA key).
  • vc.CreateJWTVerifiablePresentation-based rewrite of buildJWTPresentation (a separate, unrelated presenter refactor) doesn't exist on V6.2 either. The two master-only tests that depend on it (#3957 VP-type marshalling, #4299 holder-as-iss) were dropped; V6.2's original manual claims-map construction in presenter.go was kept, just with imports bumped to v3.
  • A handful of call sites untouched by the cherry-pick's conflict hunks (vcr/issuer/openid.go's raw jws.ParseString/.Type() re-parse, vcr/holder/presenter.go's manual claims map, authz_server.go's RFC003 max-validity check, an error-string assertion in auth/api/iam/openid4vci_test.go) still used v2-style single-return accessors or stale error strings under the hood; these were caught by a full go build ./... / go test -count=1 ./... pass and translated to v3's (T, bool) shape / updated wording.

Dependency

go-did bumped v0.17.0 → v0.22.0 (same target as master), required for jwx v3. The intermediate go-did changes (v0.18.0–v0.21.0, mostly additive: JWT VP creation support, iss/iat claim handling) are pulled in as a side effect of the version bump but are not otherwise used by V6.2 code.

Test changes

  • crypto/jwx_test.go: RS256/RSA-2048 kept unsupported; error-string alignment for v3's more verbose parse errors.
  • vcr/issuer/openid_test.go, auth/api/iam/openid4vci_test.go: updated expected error strings to v3's more verbose parse-error wrapping.

Testing

go build ./... and go build -tags jwx_es256k ./... clean; go test -count=1 ./... and go test -tags jwx_es256k ./crypto/... green (fresh run, no cache).

Verification methodology

Beyond building/testing, I diffed every file changed by this backport against the corresponding change in #4391 (master), file by file: 93 of 103 shared files are byte-identical in their change-set to master's; the other 10 differ only for the reasons documented above.

Assisted by AI

* fix: unwrap error chain when resolving HTTP status code

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.

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

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

* refactor(jwx): centralize JWT claims-to-map into jwx.AsMap; check errors

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

* chore: pin go-did to tagged release v0.22.0

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

* fix(iam): handle []interface{} array claims in oauthParameters.get

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

* refactor(jwx): use jws.VerifierFor over the low-level jwsbb.Verify

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

* fix(jwx): jwa.ES256K() function call under jwx_es256k build tag

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

* fix(jwx): tolerate null-valued JOSE header members; restore ES256K test

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

* refactor(jwx): rename AsMap to ClaimsAsMap, type it as jwt.Token

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

* refactor(jwx): make headersAsMap an unexported crypto-package helper

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

* docs(jwx): drop headers caveat from ClaimsAsMap doc

Assisted by AI

* JWK missing checks

(cherry picked from commit 9bf07c1)
@qltysh

qltysh Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

22 new issues

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

…ix v3 error-string drift in openid4vci_test.go

The RSA512->HS256 swap in authz_server_test.go's "wrong signing
algorithm" test was copied from master, but master's swap was caused
by an unrelated, already-excluded commit (RS256 becoming a supported
algorithm, #4252) rather than by jwx v3 itself. jwx v3 signs fine with
a 512-bit RSA key (verified empirically), so V6.2's original RSA/RS256
test works unchanged aside from the jwa.RS256 -> jwa.RS256() syntax
translation - restored it to keep RS256 (still unsupported on V6.2)
under test instead of an unrelated algorithm.

Also fixes an untouched-by-conflict v2-style error string assertion in
auth/api/iam/openid4vci_test.go, caught by a full -count=1 test run.

Assisted by AI
@qltysh

qltysh Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Qlty


Coverage Impact

⬇️ Merging this pull request will decrease total coverage on V6.2 by 0.04%.

Modified Files with Diff Coverage (36)

RatingFile% DiffUncovered Line #s
Coverage rating: C Coverage rating: C
crypto/memory.go100.0%
Coverage rating: B Coverage rating: B
crypto/jwx.go80.0%154-158, 230-231...
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: 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%415-416
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.3%535-536
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%
Total86.6%
🤖 Increase coverage with AI coding...
In the `chore/jwx-v3-migration-v6.2` branch, add test coverage for this new code:

- `auth/api/iam/api.go` -- Line 415-416
- `auth/services/oauth/authz_server.go` -- Line 535-536
- `crypto/dpop/dpop.go` -- Line 221-222
- `crypto/jwx.go` -- Lines 154-158, 230-231, 358-362, and 456-468
- `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.

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