From 75b7c828e5ed2d12107ee331ef6593bcbb55efa5 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 2 Jul 2026 17:54:23 +0200 Subject: [PATCH 1/2] feat: make wallet_did optional in request-credential API v2 API consumers (EHRs) operate on subjects, not DIDs, so requiring wallet_did forces an extra round-trip just to pick a DID to pass back in. When omitted, wallet_did now defaults to the subject's sole did:web DID; it fails loud with a 400 if the subject has zero or multiple did:web DIDs. Fixes #4365 Assisted by AI --- auth/api/iam/api.go | 20 +++++++++++ auth/api/iam/api_test.go | 38 ++++++++++++++++++++ auth/api/iam/generated.go | 4 ++- auth/api/iam/openid4vci.go | 25 ++++++++----- auth/api/iam/openid4vci_test.go | 44 +++++++++++++++++++++-- docs/_static/auth/v2.yaml | 6 ++-- docs/pages/release_notes.rst | 1 + e2e-tests/browser/client/iam/generated.go | 4 ++- 8 files changed, 128 insertions(+), 14 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 6386a3bc2..ddd2caa24 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -60,6 +60,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -1009,6 +1010,25 @@ func (r Wrapper) subjectOwns(ctx context.Context, subjectID string, subjectDID d return false, nil } +// subjectWebDID returns the subject's sole did:web DID. +// It fails loud (InvalidInputError) if the subject has zero or multiple did:web DIDs. +func (r Wrapper) subjectWebDID(ctx context.Context, subjectID string) (*did.DID, error) { + dids, err := r.subjectManager.ListDIDs(ctx, subjectID) + if err != nil { + return nil, err + } + var webDIDs []did.DID + for _, curr := range dids { + if curr.Method == didweb.MethodName { + webDIDs = append(webDIDs, curr) + } + } + if len(webDIDs) != 1 { + return nil, core.InvalidInputError("wallet_did is required: subject does not have exactly one did:web DID (found %d)", len(webDIDs)) + } + return &webDIDs[0], nil +} + // todo select did method, and not the scheme func (r Wrapper) determineClientDID(ctx context.Context, authServerMetadata oauth.AuthorizationServerMetadata, subjectID string) (*did.DID, error) { if !authServerMetadata.SupportsClientIDScheme(entityClientIDScheme) { diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 01a1e031a..45d441090 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1516,6 +1516,44 @@ func TestWrapper_subjectOwns(t *testing.T) { }) } +func TestWrapper_subjectWebDID(t *testing.T) { + t.Run("ok - sole did:web DID", func(t *testing.T) { + ctx := newTestClient(t) + + result, err := ctx.client.subjectWebDID(context.Background(), holderSubjectID) + + require.NoError(t, err) + assert.Equal(t, holderDID, *result) + }) + t.Run("error - multiple did:web DIDs", func(t *testing.T) { + subjectID := "multi-web" + otherWebDID := did.MustParseDID("did:web:example.com:iam:other") + ctx := newTestClient(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{holderDID, otherWebDID}, nil) + + _, err := ctx.client.subjectWebDID(context.Background(), subjectID) + + assert.ErrorContains(t, err, "wallet_did is required") + }) + t.Run("error - no did:web DIDs", func(t *testing.T) { + subjectID := "no-web" + natsDID := did.MustParseDID("did:nuts:12345") + ctx := newTestClient(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{natsDID}, nil) + + _, err := ctx.client.subjectWebDID(context.Background(), subjectID) + + assert.ErrorContains(t, err, "wallet_did is required") + }) + t.Run("unknown subject", func(t *testing.T) { + ctx := newTestClient(t) + + _, err := ctx.client.subjectWebDID(context.Background(), unknownSubjectID) + + assert.ErrorIs(t, err, didsubject.ErrSubjectNotFound) + }) +} + func TestWrapper_accessTokenRequestCacheKey(t *testing.T) { expected := "080c4170aa372da86b76c6b5db2c3c08a532c7c8454b5d2f3fb0e1d239e59758" key := accessTokenRequestCacheKey(RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: &RequestServiceAccessTokenJSONRequestBody{Scope: "test"}}) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 13e0eeeda..66a735c0b 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -283,7 +283,9 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { RedirectUri string `json:"redirect_uri"` // WalletDid The DID to which the Verifiable Credential must be issued. Must be owned by the given subject. - WalletDid string `json:"wallet_did"` + // If omitted, defaults to the subject's sole did:web DID. Omitting it fails with a 400 if the + // subject has zero or multiple did:web DIDs; in that case, wallet_did must be specified explicitly. + WalletDid *string `json:"wallet_did,omitempty"` } // RequestServiceAccessTokenParams defines parameters for RequestServiceAccessToken. diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index c5817da2c..e30e9068e 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -45,14 +45,23 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // why did oapi-codegen generate a pointer for the body?? return nil, core.InvalidInputError("missing request body") } - walletDID, err := did.ParseDID(request.Body.WalletDid) - if err != nil { - return nil, core.InvalidInputError("invalid wallet DID") - } - if owned, err := r.subjectOwns(ctx, request.SubjectID, *walletDID); err != nil { - return nil, err - } else if !owned { - return nil, core.InvalidInputError("wallet DID does not belong to the subject") + var walletDID *did.DID + var err error + if request.Body.WalletDid != nil { + walletDID, err = did.ParseDID(*request.Body.WalletDid) + if err != nil { + return nil, core.InvalidInputError("invalid wallet DID") + } + if owned, err := r.subjectOwns(ctx, request.SubjectID, *walletDID); err != nil { + return nil, err + } else if !owned { + return nil, core.InvalidInputError("wallet DID does not belong to the subject") + } + } else { + walletDID, err = r.subjectWebDID(ctx, request.SubjectID) + if err != nil { + return nil, err + } } // Parse the issuer issuer := request.Body.Issuer diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index da39e4122..5475632db 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -28,6 +28,7 @@ import ( "github.com/nuts-foundation/nuts-node/core/to" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/crypto" @@ -60,7 +61,7 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential", Format: to.Ptr("vc+sd-jwt")}}, Issuer: issuerClientID, RedirectUri: redirectURI, - WalletDid: holderDID.String(), + WalletDid: to.Ptr(holderDID.String()), }, }) require.NoError(t, err) @@ -77,6 +78,45 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.Equal(t, "code", redirectUri.Query().Get("response_type")) assert.Equal(t, `[{"credential_configuration_id":"UniversityDegreeCredential","format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) }) + t.Run("ok - wallet_did omitted, defaults to subject's sole did:web DID", func(t *testing.T) { + ctx := newTestClient(t) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) + req.Body.WalletDid = nil + + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + require.NoError(t, err) + redirectUri, _ := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + var stored OAuthSession + require.NoError(t, ctx.client.oauthClientStateStore().Get(redirectUri.Query().Get("state"), &stored)) + assert.Equal(t, holderDID, *stored.OwnDID) + }) + t.Run("error - wallet_did omitted, subject has multiple did:web DIDs", func(t *testing.T) { + subjectID := "multi-web" + otherWebDID := did.MustParseDID("did:web:example.com:iam:other") + ctx := newTestClient(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{holderDID, otherWebDID}, nil) + req := requestCredentials(subjectID, issuerClientID, redirectURI) + req.Body.WalletDid = nil + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + assert.ErrorContains(t, err, "wallet_did is required") + }) + t.Run("error - wallet_did omitted, subject has no did:web DIDs", func(t *testing.T) { + subjectID := "no-web" + natsDID := did.MustParseDID("did:nuts:12345") + ctx := newTestClient(t) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{natsDID}, nil) + req := requestCredentials(subjectID, issuerClientID, redirectURI) + req.Body.WalletDid = nil + + _, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + assert.ErrorContains(t, err, "wallet_did is required") + }) t.Run("ok - authorization_request_params merged into authorization request", func(t *testing.T) { ctx := newTestClient(t) ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) @@ -234,7 +274,7 @@ func requestCredentials(subjectID string, issuer string, redirectURI string) Req AuthorizationDetails: []AuthorizationDetail{{Type: "openid_credential", CredentialConfigurationId: "UniversityDegreeCredential"}}, Issuer: issuer, RedirectUri: redirectURI, - WalletDid: holderDID.String(), + WalletDid: to.Ptr(holderDID.String()), }, } } diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 254a6cd44..5a4023fb5 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -152,11 +152,13 @@ paths: - issuer - authorization_details - redirect_uri - - wallet_did properties: wallet_did: type: string - description: The DID to which the Verifiable Credential must be issued. Must be owned by the given subject. + description: | + The DID to which the Verifiable Credential must be issued. Must be owned by the given subject. + If omitted, defaults to the subject's sole did:web DID. Omitting it fails with a 400 if the + subject has zero or multiple did:web DIDs; in that case, wallet_did must be specified explicitly. example: did:web:example.com issuer: type: string diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index ac9c1b36c..2f707803c 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -12,6 +12,7 @@ Unreleased * #4078: Add the experimental RFC 7523 ``jwt-bearer`` two-VP token request flow, gated behind ``auth.experimental.jwtbearerclient`` (default ``false``, subject to change) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4227 * #4078: Expose the experimental two-VP flow on ``POST /internal/auth/v2/{subjectID}/request-service-access-token`` via the optional ``service_provider_subject_id`` body field by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4228 * #4233: ``request-credential`` API gains an optional ``credential_request_params`` JSON object overlaid on top of the OpenID4VCI Credential Request body sent to the issuer. Lets the wallet talk to issuers that accept additional fields, or to override the credential request entirely. +* #4365: ``request-credential`` API's ``wallet_did`` is now optional. When omitted, it defaults to the subject's sole ``did:web`` DID; the call fails with a 400 if the subject has zero or multiple ``did:web`` DIDs. **************** Peanut (v6.2.8) diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 8eb4c6a33..f3e67c12a 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -277,7 +277,9 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { RedirectUri string `json:"redirect_uri"` // WalletDid The DID to which the Verifiable Credential must be issued. Must be owned by the given subject. - WalletDid string `json:"wallet_did"` + // If omitted, defaults to the subject's sole did:web DID. Omitting it fails with a 400 if the + // subject has zero or multiple did:web DIDs; in that case, wallet_did must be specified explicitly. + WalletDid *string `json:"wallet_did,omitempty"` } // RequestServiceAccessTokenParams defines parameters for RequestServiceAccessToken. From 501e43f491c0600c5caacb0ad7026685f43e6964 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 2 Jul 2026 17:58:39 +0200 Subject: [PATCH 2/2] refactor: inline did:web selection into RequestOpenid4VCICredentialIssuance subjectWebDID looked like a general-purpose helper next to subjectOwns/ determineClientDID, but it's only ever used by (and only makes sense for) this one call site, including its error message. Assisted by AI --- auth/api/iam/api.go | 20 -------------------- auth/api/iam/api_test.go | 38 -------------------------------------- auth/api/iam/openid4vci.go | 14 +++++++++++++- 3 files changed, 13 insertions(+), 59 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index ddd2caa24..6386a3bc2 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -60,7 +60,6 @@ import ( "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -1010,25 +1009,6 @@ func (r Wrapper) subjectOwns(ctx context.Context, subjectID string, subjectDID d return false, nil } -// subjectWebDID returns the subject's sole did:web DID. -// It fails loud (InvalidInputError) if the subject has zero or multiple did:web DIDs. -func (r Wrapper) subjectWebDID(ctx context.Context, subjectID string) (*did.DID, error) { - dids, err := r.subjectManager.ListDIDs(ctx, subjectID) - if err != nil { - return nil, err - } - var webDIDs []did.DID - for _, curr := range dids { - if curr.Method == didweb.MethodName { - webDIDs = append(webDIDs, curr) - } - } - if len(webDIDs) != 1 { - return nil, core.InvalidInputError("wallet_did is required: subject does not have exactly one did:web DID (found %d)", len(webDIDs)) - } - return &webDIDs[0], nil -} - // todo select did method, and not the scheme func (r Wrapper) determineClientDID(ctx context.Context, authServerMetadata oauth.AuthorizationServerMetadata, subjectID string) (*did.DID, error) { if !authServerMetadata.SupportsClientIDScheme(entityClientIDScheme) { diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 45d441090..01a1e031a 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1516,44 +1516,6 @@ func TestWrapper_subjectOwns(t *testing.T) { }) } -func TestWrapper_subjectWebDID(t *testing.T) { - t.Run("ok - sole did:web DID", func(t *testing.T) { - ctx := newTestClient(t) - - result, err := ctx.client.subjectWebDID(context.Background(), holderSubjectID) - - require.NoError(t, err) - assert.Equal(t, holderDID, *result) - }) - t.Run("error - multiple did:web DIDs", func(t *testing.T) { - subjectID := "multi-web" - otherWebDID := did.MustParseDID("did:web:example.com:iam:other") - ctx := newTestClient(t) - ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{holderDID, otherWebDID}, nil) - - _, err := ctx.client.subjectWebDID(context.Background(), subjectID) - - assert.ErrorContains(t, err, "wallet_did is required") - }) - t.Run("error - no did:web DIDs", func(t *testing.T) { - subjectID := "no-web" - natsDID := did.MustParseDID("did:nuts:12345") - ctx := newTestClient(t) - ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{natsDID}, nil) - - _, err := ctx.client.subjectWebDID(context.Background(), subjectID) - - assert.ErrorContains(t, err, "wallet_did is required") - }) - t.Run("unknown subject", func(t *testing.T) { - ctx := newTestClient(t) - - _, err := ctx.client.subjectWebDID(context.Background(), unknownSubjectID) - - assert.ErrorIs(t, err, didsubject.ErrSubjectNotFound) - }) -} - func TestWrapper_accessTokenRequestCacheKey(t *testing.T) { expected := "080c4170aa372da86b76c6b5db2c3c08a532c7c8454b5d2f3fb0e1d239e59758" key := accessTokenRequestCacheKey(RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: &RequestServiceAccessTokenJSONRequestBody{Scope: "test"}}) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index e30e9068e..edcfe37e9 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -35,6 +35,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" nutsHttp "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -58,10 +59,21 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques return nil, core.InvalidInputError("wallet DID does not belong to the subject") } } else { - walletDID, err = r.subjectWebDID(ctx, request.SubjectID) + // wallet_did omitted: default to the subject's sole did:web DID. + dids, err := r.subjectManager.ListDIDs(ctx, request.SubjectID) if err != nil { return nil, err } + var webDIDs []did.DID + for _, curr := range dids { + if curr.Method == didweb.MethodName { + webDIDs = append(webDIDs, curr) + } + } + if len(webDIDs) != 1 { + return nil, core.InvalidInputError("wallet_did is required: subject does not have exactly one did:web DID (found %d)", len(webDIDs)) + } + walletDID = &webDIDs[0] } // Parse the issuer issuer := request.Body.Issuer