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 d2e4fef5b..160b4de17 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -36,6 +36,7 @@ import ( "github.com/nuts-foundation/nuts-node/v6/core" "github.com/nuts-foundation/nuts-node/v6/crypto" nutsHttp "github.com/nuts-foundation/nuts-node/v6/http" + "github.com/nuts-foundation/nuts-node/v6/vdr/didweb" "github.com/nuts-foundation/nuts-node/v6/vdr/resolver" ) @@ -46,14 +47,34 @@ 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 { + // 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 diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 23c2e3e20..d4f521219 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/v6/core/to" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/v6/auth/oauth" "github.com/nuts-foundation/nuts-node/v6/auth/openid4vci" "github.com/nuts-foundation/nuts-node/v6/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 e70c3064c..02afee7fd 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. ## Security * #4441: Inbound HTTP request bodies are now limited to 1MB on both the public and internal interfaces; larger requests are rejected with HTTP 413 (Request Entity Too Large). Previously no limit was enforced, contrary to what the deployment documentation stated. The heaviest legitimate requests (OAuth POSTs carrying Verifiable Presentations) stay well below this limit, and it matches the ``client_max_body_size 1M`` reverse proxy configuration the documentation recommends. By @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4441 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.