Bug: fix VP JWT marshalling flattening single-entry arrays incorrectly#3958
Bug: fix VP JWT marshalling flattening single-entry arrays incorrectly#3958
Conversation
|
Coverage Impact ⬇️ Merging this pull request will decrease total coverage on Modified Files with Diff Coverage (2)
🛟 Help
|
…ngraded dependencies - Update go-did to v0.18.1 which makes ExpiresAt optional (*time.Time), restoring the old behaviour of omitting the exp claim when no expiry is set - Restore avast/retry-go/v4 v4.7.0, gorm.io/gorm v1.31.1, and eko/gocache/lib/v4 v4.2.3 which were accidentally downgraded in a merge conflict Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backwards Compatibility Report1. JWT VP
|
| Holder | Verifier | Submission path | VP verifiableCredential |
Resolution |
|---|---|---|---|---|
| new | old | $.verifiableCredential[0] |
array | index extracts the string ✅ |
| old | new | $.verifiableCredential |
string | JSONPath returns the string directly ✅ |
| new | new | $.verifiableCredential[0] |
array | ✅ |
| old | old | $.verifiableCredential |
string | ✅ (unchanged) |
Additionally, ParseVerifiablePresentation applies Plural(verifiableCredentialKey) during parsing, so both wire forms are normalised correctly.
3. JWT VP expiry ✅ Compatible
ExpiresAt in go-did v0.18.1 is *time.Time (optional). options.ProofOptions.Expires is passed directly — when nil, no exp claim is emitted, identical to master's behaviour.
4. Dependencies ✅ Up to date
All three previously-downgraded dependencies have been restored:
| Dependency | Was (bad merge) | Now |
|---|---|---|
avast/retry-go/v4 |
v4.6.1 | v4.7.0 |
gorm.io/gorm |
v1.30.2 | v1.31.1 |
eko/gocache/lib/v4 |
v4.2.0 | v4.2.3 |
Additionally, go-did was upgraded from v0.18.0 → v0.18.1 to fix the mandatory ExpiresAt constraint.
Overall verdict: the PR is backwards compatible.
There was a problem hiding this comment.
Pull request overview
Fixes incorrect JWT Verifiable Presentation (VP) JSON marshalling semantics (single-entry arrays being flattened) by upgrading go-did and aligning Nuts-node VP creation/submission logic with JWT VP expectations.
Changes:
- Bump
github.com/nuts-foundation/go-didfromv0.18.0tov0.18.1(fixing JWT VP marshalling semantics). - Switch JWT VP creation in
presentertogo-did’s JWT VP creation helper and adjust PEX submission mapping path for JWT single-VC cases. - Add/extend tests for issue #3957 to validate JWT VP
typeis encoded as an array.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
vcr/pe/presentation_submission.go |
Adjusts single-VC mapping path depending on VP format (JWT uses array indexing). |
vcr/pe/presentation_submission_test.go |
Updates expected JWT descriptor map path to $.verifiableCredential[0]. |
vcr/holder/presenter.go |
Replaces manual JWT VP claim construction/signing with vc.CreateJWTVerifiablePresentation. |
vcr/holder/presenter_test.go |
Adds regression test for JWT VP type array marshalling; updates JWT test options. |
auth/client/iam/openid4vp.go |
Import reordering only. |
go.mod |
Updates github.com/nuts-foundation/go-did to v0.18.1. |
go.sum |
Adds checksums for updated/affected dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return vc.CreateJWTVerifiablePresentation(ctx, subjectDID.URI(), credentials, vc.PresentationOptions{ | ||
| AdditionalContexts: options.AdditionalContexts, | ||
| AdditionalTypes: options.AdditionalTypes, | ||
| AdditionalProofProperties: options.ProofOptions.AdditionalProperties, | ||
| Holder: options.Holder, | ||
| Nonce: options.ProofOptions.Nonce, | ||
| Audience: options.ProofOptions.Domain, | ||
| IssuedAt: &options.ProofOptions.Created, | ||
| ExpiresAt: options.ProofOptions.Expires, |
There was a problem hiding this comment.
In JWT mode this always passes a non-nil IssuedAt pointer, even when options.ProofOptions.Created is the zero time. Previously, when Created was zero, the code defaulted the JWT time claim(s) to time.Now(); now a zero Created could produce an invalid/negative Unix timestamp (or an unexpectedly old value) in the JWT. Consider only setting IssuedAt when Created is non-zero (otherwise pass nil or set it to time.Now() first) to preserve the prior behavior and avoid generating invalid tokens.
| vpAsMap := result.JWT().PrivateClaims()["vp"].(map[string]any) | ||
| t.Run("make sure type now marshalls as array", func(t *testing.T) { | ||
| typeProp := vpAsMap["type"].([]any) |
There was a problem hiding this comment.
These direct type assertions (to map[string]any and []any) will panic if the JWT claims shape changes or if the regression reappears (e.g., type becomes a string again). Prefer using the comma-ok form or require.IsType/require.Contains to fail the test with a clear assertion message instead of a panic.
| vpAsMap := result.JWT().PrivateClaims()["vp"].(map[string]any) | |
| t.Run("make sure type now marshalls as array", func(t *testing.T) { | |
| typeProp := vpAsMap["type"].([]any) | |
| vpClaim, ok := result.JWT().PrivateClaims()["vp"] | |
| require.True(t, ok, "vp claim must be present in JWT private claims") | |
| vpAsMap, ok := vpClaim.(map[string]any) | |
| require.True(t, ok, "vp claim must be a map[string]any, got %T", vpClaim) | |
| t.Run("make sure type now marshalls as array", func(t *testing.T) { | |
| typeVal, ok := vpAsMap["type"] | |
| require.True(t, ok, "vp.type must be present") | |
| typeProp, ok := typeVal.([]any) | |
| require.True(t, ok, "vp.type must be a []any, got %T", typeVal) |
| t.Run("make sure type now marshalls as array", func(t *testing.T) { | ||
| typeProp := vpAsMap["type"].([]any) | ||
| assert.Equal(t, []any{"VerifiablePresentation"}, typeProp) | ||
| }) |
There was a problem hiding this comment.
The PR description mentions the flattening bug also affected @context and verifiableCredential in JWT VPs, but this new regression test only asserts the type field. Consider extending this test to also assert that vp.@context and vp.verifiableCredential are encoded as arrays (even for a single entry), so the full intended fix is covered by automated tests.
| }) | |
| }) | |
| t.Run("make sure @context now marshalls as array", func(t *testing.T) { | |
| contextProp, ok := vpAsMap["@context"].([]any) | |
| require.True(t, ok, "@context must be encoded as an array") | |
| assert.GreaterOrEqual(t, len(contextProp), 1, "@context array must have at least one entry") | |
| }) | |
| t.Run("make sure verifiableCredential now marshalls as array", func(t *testing.T) { | |
| vcProp, ok := vpAsMap["verifiableCredential"].([]any) | |
| require.True(t, ok, "verifiableCredential must be encoded as an array") | |
| assert.Len(t, vcProp, 1, "single VC presentation must still use an array") | |
| }) |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

We used JSON-LD marshalling semantics for JWT VPs, which is incorrect; single-entry arrays shouldn't be flattened to singular values.
Found during LSPxNuts hackaton.
This applied to the following fields:
typeverifiableCredential@contextFixes #3957
TODO: