From 23c2a1b8043e964e7c10f2369aa27b36b8b18a40 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 24 Mar 2026 16:36:27 +0100 Subject: [PATCH 1/7] =?UTF-8?q?#4090:=20RED=20=E2=80=94=20e2e=20test=20for?= =?UTF-8?q?=20credential=5Fquery=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a credential_query test to the RFC021 e2e flow: - Issues two NutsOrganizationCredentials with different org names - Uses credential_query to select "Second Org B.V." by name - Verifies extended introspection contains the selected organization Test correctly fails: credential_query is currently ignored, so the default PD matcher picks "Caresoft B.V." instead of "Second Org B.V." Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e-tests/oauth-flow/rfc021/do-test.sh | 78 ++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/e2e-tests/oauth-flow/rfc021/do-test.sh b/e2e-tests/oauth-flow/rfc021/do-test.sh index 2eb90fa14e..deab95131c 100755 --- a/e2e-tests/oauth-flow/rfc021/do-test.sh +++ b/e2e-tests/oauth-flow/rfc021/do-test.sh @@ -207,6 +207,84 @@ else exitWithDockerLogs 1 fi +echo "---------------------------------------" +echo "Test credential_query (DCQL) selection..." +echo "---------------------------------------" +# Issue a second NutsOrganizationCredential with a different org name +REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${VENDOR_B_DID}\", \"credentialSubject\": {\"id\":\"${VENDOR_B_DID}\", \"organization\":{\"name\":\"Second Org B.V.\", \"city\":\"Othertown\"}},\"withStatusList2021Revocation\": true}" +VENDOR_B_CREDENTIAL_2=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/issuer/vc -H "Content-Type:application/json") +if echo $VENDOR_B_CREDENTIAL_2 | grep -q "VerifiableCredential"; then + echo "Second NutsOrganizationCredential issued" +else + echo "FAILED: Could not issue second NutsOrganizationCredential" 1>&2 + echo $VENDOR_B_CREDENTIAL_2 + exitWithDockerLogs 1 +fi + +# Store second credential in wallet +RESPONSE=$(echo $VENDOR_B_CREDENTIAL_2 | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/holder/vendorB/vc -H "Content-Type:application/json") +if echo $RESPONSE == ""; then + echo "Second VC stored in wallet" +else + echo "FAILED: Could not store second NutsOrganizationCredential in wallet" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +# Request access token with credential_query to select the second org credential +REQUEST=$( +cat << EOF +{ + "authorization_server": "https://nodeA/oauth2/vendorA", + "scope": "test", + "credential_query": [ + { + "id": "id_nuts_care_organization_cred", + "claims": [ + { + "path": ["credentialSubject", "organization", "name"], + "values": ["Second Org B.V."] + } + ] + } + ], + "credentials": [ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1" + ], + "type": ["VerifiableCredential", "NutsEmployeeCredential"], + "credentialSubject": { + "name": "Jane Doe", + "roleName": "Nurse", + "identifier": "654321" + } + } + ] +} +EOF +) +RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:28081/internal/auth/v2/vendorB/request-service-access-token -H "Content-Type: application/json" -H "Cache-Control: no-cache") +if echo $RESPONSE | grep -q "access_token"; then + echo "credential_query: access token obtained successfully" +else + echo "FAILED: Could not get access token with credential_query" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +# Verify introspection contains the correct org (Second Org B.V.) +DCQL_ACCESS_TOKEN=$(echo $RESPONSE | sed -E 's/.*"access_token":"([^"]*).*/\1/') +RESPONSE=$(curl -X POST -s --data "token=$DCQL_ACCESS_TOKEN" http://localhost:18081/internal/auth/v2/accesstoken/introspect_extended) +if echo $RESPONSE | grep -q "Second Org B.V."; then + echo "credential_query: correct organization selected" +else + echo "FAILED: credential_query did not select the correct organization" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + echo "------------------------------------" echo "Revoking credential..." echo "------------------------------------" From 6ad332602e925e5ca5cc0cb1df12824945bc7f75 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 25 Mar 2026 06:51:35 +0100 Subject: [PATCH 2/7] #4090: Wire credential_query through API to presenter - OpenAPI spec: add credential_query to ServiceAccessTokenRequest - Regenerate types and mocks - API handler: extract credential_query, convert to dcql.CredentialQuery - Client.RequestRFC021AccessToken: accept credentialQueries parameter - Wallet.BuildSubmission: accept credentialQueries parameter - Presenter: create DCQL selector when credentialQueries is non-empty - All existing tests pass with nil for new parameter Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api.go | 19 +++++++- auth/api/iam/api_test.go | 20 ++++---- auth/api/iam/generated.go | 25 ++++++++++ auth/api/iam/openid4vp.go | 2 +- auth/api/iam/openid4vp_test.go | 4 +- auth/client/iam/interface.go | 3 +- auth/client/iam/mock.go | 9 ++-- auth/client/iam/openid4vp.go | 5 +- auth/client/iam/openid4vp_test.go | 35 +++++++------- docs/_static/auth/v2.yaml | 56 +++++++++++++++++++++++ e2e-tests/browser/client/iam/generated.go | 25 ++++++++++ vcr/holder/interface.go | 5 +- vcr/holder/memory_wallet.go | 5 +- vcr/holder/mock.go | 9 ++-- vcr/holder/presenter.go | 12 ++++- vcr/holder/presenter_test.go | 8 ++-- vcr/holder/sql_wallet.go | 5 +- 17 files changed, 195 insertions(+), 52 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 339d4b0086..1ecc3e3a35 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -56,6 +56,7 @@ import ( "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" + "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didsubject" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -773,8 +774,24 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS if request.Body.TokenType != nil && strings.EqualFold(string(*request.Body.TokenType), AccessTokenTypeBearer) { useDPoP = false } + // Convert credential_query from generated types to dcql types + var credentialQueries []dcql.CredentialQuery + if request.Body.CredentialQuery != nil { + for _, q := range *request.Body.CredentialQuery { + query := dcql.CredentialQuery{ID: q.Id} + for _, c := range q.Claims { + claim := dcql.ClaimsQuery{Path: c.Path} + if c.Values != nil { + claim.Values = *c.Values + } + query.Claims = append(query.Claims, claim) + } + credentialQueries = append(credentialQueries, query) + } + } + clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials) + tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialQueries) if err != nil { // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials return nil, err diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 35efca16da..5be4de20e1 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -353,7 +353,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/oauth2/verifier").Return(&clientMetadata, nil) pdEndpoint := "https://example.com/oauth2/verifier/presentation_definition?scope=test" ctx.iamClient.EXPECT().PresentationDefinition(gomock.Any(), pdEndpoint).Return(&pe.PresentationDefinition{}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{holderDID}, nil, pe.PresentationDefinition{}, gomock.Any()).Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{holderDID}, nil, pe.PresentationDefinition{}, nil, gomock.Any()).Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil) ctx.iamClient.EXPECT().PostAuthorizationResponse(gomock.Any(), vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "https://example.com/oauth2/verifier/response", "state").Return("https://example.com/iam/holder/redirect", nil) res, err := ctx.client.HandleAuthorizeRequest(callCtx, HandleAuthorizeRequestRequestObject{ @@ -886,7 +886,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} request.Params.CacheControl = to.Ptr("no-cache") // Initial call to populate cache - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(response, nil).Times(2) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(response, nil).Times(2) token, err := ctx.client.RequestServiceAccessToken(nil, request) // Test call to check cache is bypassed @@ -907,7 +907,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { TokenType: "Bearer", ExpiresIn: to.Ptr(900), } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(response, nil) token, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -946,7 +946,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("cache expired", func(t *testing.T) { cacheKey := accessTokenRequestCacheKey(request) _ = ctx.client.accessTokenCache().Delete(cacheKey) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) otherToken, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -963,7 +963,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { Scope: "first second", TokenType: &tokenTypeBearer, } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil).Return(&oauth.TokenResponse{}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, nil).Return(&oauth.TokenResponse{}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -972,7 +972,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("ok with expired cache by ttl", func(t *testing.T) { ctx := newTestClient(t) request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -981,7 +981,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { }) t.Run("error - no matching credentials", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(nil, pe.ErrNoCredentials) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(nil, pe.ErrNoCredentials) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -997,8 +997,8 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { ctx.client.storageEngine = mockStorage request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) token1, err := ctx.client.RequestServiceAccessToken(nil, request) require.NoError(t, err) @@ -1023,7 +1023,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { {ID: to.Ptr(ssi.MustParseURI("not empty"))}, } request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil).Return(response, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 5dbe21544d..03edade2d3 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -134,6 +134,31 @@ type ServiceAccessTokenRequest struct { // used to locate the OAuth2 Authorization Server metadata. AuthorizationServer string `json:"authorization_server"` + // CredentialQuery Optional DCQL credential queries (per OpenID4VP section 6.1/6.3) to disambiguate credential + // selection when the wallet contains multiple credentials matching a single input descriptor. + // Each query's id MUST match an input descriptor ID in the Presentation Definition for the + // requested scope. The query's claims specify value filters for selecting the right credential. + // + // The DCQL filter must narrow to exactly one credential per input descriptor. + // Zero matches or multiple matches will result in an error. + CredentialQuery *[]struct { + // Claims Claims queries specifying value filters for credential selection. + Claims []struct { + // Path Claims Path Pointer (OpenID4VP section 7) specifying the path to a claim + // within the credential. Elements can be strings (key lookup), integers + // (array index), or null (array wildcard). Path starts at the credential root. + Path []interface{} `json:"path"` + + // Values Expected values for the claim. The credential matches if the claim value + // equals at least one of the values (OR semantics). + Values *[]interface{} `json:"values,omitempty"` + } `json:"claims"` + + // Id Identifier linking this query to a Presentation Definition input descriptor. + // Must consist of alphanumeric, underscore, or hyphen characters. + Id string `json:"id"` + } `json:"credential_query,omitempty"` + // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. // They must be in the form of a Verifiable Credential in JSON form. // The serialized form (JWT or JSON-LD) in the resulting Verifiable Presentation depends on the capability of the authorizing party. diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 53868ed600..fe6bb045ec 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -318,7 +318,7 @@ func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, subject map[did.DID][]vc.VerifiableCredential{userSession.Wallet.DID: userSession.Wallet.Credentials}, ) } - vp, submission, err := targetWallet.BuildSubmission(ctx, []did.DID{walletDID}, nil, *presentationDefinition, buildParams) + vp, submission, err := targetWallet.BuildSubmission(ctx, []did.DID{walletDID}, nil, *presentationDefinition, nil, buildParams) if err != nil { if errors.Is(err, pe.ErrNoCredentials) { return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: fmt.Sprintf("wallet could not fulfill requirements (PD ID: %s, wallet: %s): %s", presentationDefinition.Id, walletDID, err.Error())}, responseURI, state) diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index bcc4137bdc..1f0135363c 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -345,7 +345,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { putState(ctx, "state", authzCodeSession) ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) ctx.iamClient.EXPECT().PresentationDefinition(gomock.Any(), pdEndpoint).Return(&pe.PresentationDefinition{}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{holderDID}, nil, pe.PresentationDefinition{}, gomock.Any()).Return(nil, nil, assert.AnError) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{holderDID}, nil, pe.PresentationDefinition{}, nil, gomock.Any()).Return(nil, nil, assert.AnError) expectPostError(t, ctx, oauth.ServerError, assert.AnError.Error(), responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderSubjectID, params, pe.WalletOwnerOrganization) @@ -358,7 +358,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { putState(ctx, "state", authzCodeSession) ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) ctx.iamClient.EXPECT().PresentationDefinition(gomock.Any(), pdEndpoint).Return(&pe.PresentationDefinition{}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{holderDID}, nil, pe.PresentationDefinition{}, gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{holderDID}, nil, pe.PresentationDefinition{}, nil, gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) expectPostError(t, ctx, oauth.InvalidRequest, "wallet could not fulfill requirements (PD ID: , wallet: did:web:example.com:iam:holder): missing credentials", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderSubjectID, params, pe.WalletOwnerOrganization) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5016708d9d..c0b935f362 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -22,6 +22,7 @@ import ( "context" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -45,7 +46,7 @@ type Client interface { PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error) // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021. RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, - credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) + credentials []vc.VerifiableCredential, credentialQueries []dcql.CredentialQuery) (*oauth.TokenResponse, error) // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. // oauthIssuer is the URL of the issuer as specified by RFC 8414 (OAuth 2.0 Authorization Server Metadata). diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index add1a059cd..2a144f6aac 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -15,6 +15,7 @@ import ( vc "github.com/nuts-foundation/go-did/vc" oauth "github.com/nuts-foundation/nuts-node/auth/oauth" + dcql "github.com/nuts-foundation/nuts-node/vcr/dcql" pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -194,18 +195,18 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet } // RequestRFC021AccessToken mocks base method. -func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { +func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialQueries []dcql.CredentialQuery) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials) + ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialQueries) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // RequestRFC021AccessToken indicates an expected call of RequestRFC021AccessToken. -func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials any) *gomock.Call { +func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialQueries any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialQueries) } // VerifiableCredentials mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 0f9e370601..2ee5b867d8 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -42,6 +42,7 @@ import ( nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/crypto/dpop" nutsHttp "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -235,7 +236,7 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd } func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, - useDPoP bool, additionalCredentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { + useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialQueries []dcql.CredentialQuery) (*oauth.TokenResponse, error) { iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { @@ -296,7 +297,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID additionalWalletCredentials[subjectDID] = append(additionalWalletCredentials[subjectDID], credential.AutoCorrectSelfAttestedCredential(curr, subjectDID)) } } - vp, submission, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, *presentationDefinition, params) + vp, submission, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, *presentationDefinition, credentialQueries, params) if err != nil { return nil, err } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index e5c4ef6840..cdabdfa832 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -41,6 +41,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" http2 "github.com/nuts-foundation/nuts-node/test/http" + "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didweb" @@ -251,9 +252,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { t.Run("fulfills the Presentation Definition", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -263,9 +264,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { t.Run("no DID fulfills the Presentation Definition", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) assert.ErrorIs(t, err, pe.ErrNoCredentials) assert.Nil(t, response) @@ -275,7 +276,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.authzServerMetadata.DIDMethodsSupported = []string{"other"} ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrPreconditionFailed) @@ -301,8 +302,8 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }, }, } - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, _ []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ []dcql.CredentialQuery, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { // Assert self-attested credentials require.Len(t, additionalCredentials, 2) require.Len(t, additionalCredentials[primaryWalletDID], 1) @@ -312,7 +313,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return createdVP, &pe.PresentationSubmission{}, nil }) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -324,9 +325,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.keyResolver.EXPECT().ResolveKey(primaryWalletDID, nil, resolver.NutsSigningKeyType).Return(primaryKID, nil, nil) ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), primaryKID).Return("dpop", nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -346,9 +347,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, _ = writer.Write(oauthErrorBytes) } ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) require.Error(t, err) oauthError, ok := err.(oauth.OAuth2Error) @@ -365,7 +366,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) require.Error(t, err) assert.True(t, errors.As(err, &oauth.OAuth2Error{})) @@ -375,7 +376,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.metadata = nil - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidClientCall) @@ -389,7 +390,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, _ = writer.Write([]byte("{")) } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrBadGateway) @@ -398,9 +399,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { t.Run("error - failed to build vp", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, assert.AnError) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, assert.AnError) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) assert.Error(t, err) }) diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index c032a1ff64..865494c49f 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -443,6 +443,62 @@ components: } } ] + credential_query: + type: array + description: | + Optional DCQL credential queries (per OpenID4VP section 6.1/6.3) to disambiguate credential + selection when the wallet contains multiple credentials matching a single input descriptor. + Each query's id MUST match an input descriptor ID in the Presentation Definition for the + requested scope. The query's claims specify value filters for selecting the right credential. + + The DCQL filter must narrow to exactly one credential per input descriptor. + Zero matches or multiple matches will result in an error. + items: + type: object + required: + - id + - claims + properties: + id: + type: string + description: | + Identifier linking this query to a Presentation Definition input descriptor. + Must consist of alphanumeric, underscore, or hyphen characters. + example: id_patient_enrollment + claims: + type: array + description: Claims queries specifying value filters for credential selection. + items: + type: object + required: + - path + properties: + path: + type: array + description: | + Claims Path Pointer (OpenID4VP section 7) specifying the path to a claim + within the credential. Elements can be strings (key lookup), integers + (array index), or null (array wildcard). Path starts at the credential root. + items: {} + example: ["credentialSubject", "patientId"] + values: + type: array + description: | + Expected values for the claim. The credential matches if the claim value + equals at least one of the values (OR semantics). + items: {} + example: ["123456789"] + example: [ + { + "id": "id_patient_enrollment", + "claims": [ + { + "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"], + "values": ["123456789"] + } + ] + } + ] token_type: type: string description: "The type of access token that is preferred, default: DPoP" diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 9ed0f8cbac..b1e266112f 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -128,6 +128,31 @@ type ServiceAccessTokenRequest struct { // used to locate the OAuth2 Authorization Server metadata. AuthorizationServer string `json:"authorization_server"` + // CredentialQuery Optional DCQL credential queries (per OpenID4VP section 6.1/6.3) to disambiguate credential + // selection when the wallet contains multiple credentials matching a single input descriptor. + // Each query's id MUST match an input descriptor ID in the Presentation Definition for the + // requested scope. The query's claims specify value filters for selecting the right credential. + // + // The DCQL filter must narrow to exactly one credential per input descriptor. + // Zero matches or multiple matches will result in an error. + CredentialQuery *[]struct { + // Claims Claims queries specifying value filters for credential selection. + Claims []struct { + // Path Claims Path Pointer (OpenID4VP section 7) specifying the path to a claim + // within the credential. Elements can be strings (key lookup), integers + // (array index), or null (array wildcard). Path starts at the credential root. + Path []interface{} `json:"path"` + + // Values Expected values for the claim. The credential matches if the claim value + // equals at least one of the values (OR semantics). + Values *[]interface{} `json:"values,omitempty"` + } `json:"claims"` + + // Id Identifier linking this query to a Presentation Definition input descriptor. + // Must consist of alphanumeric, underscore, or hyphen characters. + Id string `json:"id"` + } `json:"credential_query,omitempty"` + // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. // They must be in the form of a Verifiable Credential in JSON form. // The serialized form (JWT or JSON-LD) in the resulting Verifiable Presentation depends on the capability of the authorizing party. diff --git a/vcr/holder/interface.go b/vcr/holder/interface.go index 0483b17943..a2f8ea8c84 100644 --- a/vcr/holder/interface.go +++ b/vcr/holder/interface.go @@ -25,6 +25,7 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" ) @@ -51,7 +52,9 @@ type Wallet interface { // BuildSubmission builds a Verifiable Presentation based on the given presentation definition. // additionalCredentials can be given to have the submission consider extra credentials that are not in the wallet. - BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) + // credentialQueries optionally specifies DCQL credential queries to disambiguate credential selection + // when multiple credentials match a single input descriptor. + BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialQueries []dcql.CredentialQuery, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) // List returns all credentials in the wallet for the given holder. // If the wallet does not contain any credentials for the given holder, it returns an empty list. diff --git a/vcr/holder/memory_wallet.go b/vcr/holder/memory_wallet.go index 8e466719a0..d865aa4f07 100644 --- a/vcr/holder/memory_wallet.go +++ b/vcr/holder/memory_wallet.go @@ -27,6 +27,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/piprate/json-gold/ld" @@ -59,7 +60,7 @@ func (m memoryWallet) BuildPresentation(ctx context.Context, credentials []vc.Ve }.buildPresentation(ctx, signerDID, credentials, options) } -func (m memoryWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { +func (m memoryWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialQueries []dcql.CredentialQuery, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { wallets := make(map[did.DID][]vc.VerifiableCredential) for _, walletDID := range walletDIDs { wallets[walletDID] = m.credentials[walletDID] @@ -71,7 +72,7 @@ func (m memoryWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, documentLoader: m.documentLoader, signer: m.signer, keyResolver: m.keyResolver, - }.buildSubmission(ctx, wallets, presentationDefinition, params) + }.buildSubmission(ctx, wallets, presentationDefinition, credentialQueries, params) } func (m memoryWallet) List(_ context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) { diff --git a/vcr/holder/mock.go b/vcr/holder/mock.go index bd81a024fd..da628a72fe 100644 --- a/vcr/holder/mock.go +++ b/vcr/holder/mock.go @@ -17,6 +17,7 @@ import ( did "github.com/nuts-foundation/go-did/did" vc "github.com/nuts-foundation/go-did/vc" core "github.com/nuts-foundation/nuts-node/core" + dcql "github.com/nuts-foundation/nuts-node/vcr/dcql" pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -61,9 +62,9 @@ func (mr *MockWalletMockRecorder) BuildPresentation(ctx, credentials, options, s } // BuildSubmission mocks base method. -func (m *MockWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { +func (m *MockWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialQueries []dcql.CredentialQuery, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BuildSubmission", ctx, walletDIDs, additionalCredentials, presentationDefinition, params) + ret := m.ctrl.Call(m, "BuildSubmission", ctx, walletDIDs, additionalCredentials, presentationDefinition, credentialQueries, params) ret0, _ := ret[0].(*vc.VerifiablePresentation) ret1, _ := ret[1].(*pe.PresentationSubmission) ret2, _ := ret[2].(error) @@ -71,9 +72,9 @@ func (m *MockWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, } // BuildSubmission indicates an expected call of BuildSubmission. -func (mr *MockWalletMockRecorder) BuildSubmission(ctx, walletDIDs, additionalCredentials, presentationDefinition, params any) *gomock.Call { +func (mr *MockWalletMockRecorder) BuildSubmission(ctx, walletDIDs, additionalCredentials, presentationDefinition, credentialQueries, params any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildSubmission", reflect.TypeOf((*MockWallet)(nil).BuildSubmission), ctx, walletDIDs, additionalCredentials, presentationDefinition, params) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildSubmission", reflect.TypeOf((*MockWallet)(nil).BuildSubmission), ctx, walletDIDs, additionalCredentials, presentationDefinition, credentialQueries, params) } // Diagnostics mocks base method. diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index 182f434d41..e0dc07bb5e 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -32,6 +32,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" @@ -48,7 +49,7 @@ type presenter struct { } func (p presenter) buildSubmission(ctx context.Context, credentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, - params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + credentialQueries []dcql.CredentialQuery, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { // match against the wallet's credentials // if there's a match, create a VP and call the token endpoint // If the token endpoint succeeds, return the access token @@ -57,6 +58,15 @@ func (p presenter) buildSubmission(ctx context.Context, credentials map[did.DID] for holderDID, creds := range credentials { builder.AddWallet(holderDID, creds) } + // If DCQL credential queries are provided, create a selector that narrows + // credential selection per input descriptor. + if len(credentialQueries) > 0 { + selector, err := pe.NewDCQLSelector(credentialQueries, presentationDefinition, pe.FirstMatchSelector) + if err != nil { + return nil, nil, err + } + builder.SetCredentialSelector(selector) + } // Find supported VP format, matching support from: // - what the local Nuts node supports diff --git a/vcr/holder/presenter_test.go b/vcr/holder/presenter_test.go index efc47e70b8..d28de89c8d 100644 --- a/vcr/holder/presenter_test.go +++ b/vcr/holder/presenter_test.go @@ -315,7 +315,7 @@ func TestPresenter_buildSubmission(t *testing.T) { w := presenter{documentLoader: jsonldManager.DocumentLoader(), signer: keyStore, keyResolver: keyResolver} - vp, submission, err := w.buildSubmission(ctx, credentials, presentationDefinition, BuildParams{Audience: verifierDID.String(), Expires: time.Now().Add(time.Second), Format: vpFormats, Nonce: ""}) + vp, submission, err := w.buildSubmission(ctx, credentials, presentationDefinition, nil, BuildParams{Audience: verifierDID.String(), Expires: time.Now().Add(time.Second), Format: vpFormats, Nonce: ""}) assert.NoError(t, err) require.NotNil(t, vp) @@ -327,7 +327,7 @@ func TestPresenter_buildSubmission(t *testing.T) { w := NewSQLWallet(nil, keyStore, nil, jsonldManager, storageEngine) - vp, submission, err := w.BuildSubmission(ctx, []did.DID{nutsWalletDID}, nil, presentationDefinition, BuildParams{Audience: verifierDID.String(), Expires: time.Now().Add(time.Second), Format: vpFormats, Nonce: ""}) + vp, submission, err := w.BuildSubmission(ctx, []did.DID{nutsWalletDID}, nil, presentationDefinition, nil, BuildParams{Audience: verifierDID.String(), Expires: time.Now().Add(time.Second), Format: vpFormats, Nonce: ""}) assert.ErrorIs(t, err, pe.ErrNoCredentials) assert.Nil(t, vp) @@ -339,7 +339,7 @@ func TestPresenter_buildSubmission(t *testing.T) { w := NewSQLWallet(nil, keyStore, testVerifier{}, jsonldManager, storageEngine) _ = w.Put(context.Background(), credentials[nutsWalletDID]...) - _, _, err := w.BuildSubmission(ctx, []did.DID{nutsWalletDID}, nil, presentationDefinition, BuildParams{Audience: verifierDID.String(), DIDMethods: []string{"test"}, Expires: time.Now().Add(time.Second), Format: vpFormats, Nonce: ""}) + _, _, err := w.BuildSubmission(ctx, []did.DID{nutsWalletDID}, nil, presentationDefinition, nil, BuildParams{Audience: verifierDID.String(), DIDMethods: []string{"test"}, Expires: time.Now().Add(time.Second), Format: vpFormats, Nonce: ""}) assert.ErrorIs(t, err, pe.ErrNoCredentials) }) @@ -351,7 +351,7 @@ func TestPresenter_buildSubmission(t *testing.T) { keyResolver.EXPECT().ResolveKey(webWalletDID, nil, resolver.NutsSigningKeyType).Return(key.KID, key.PublicKey, nil).AnyTimes() w := presenter{documentLoader: jsonldManager.DocumentLoader(), signer: keyStore, keyResolver: keyResolver} - vp, submission, err := w.buildSubmission(ctx, credentials, pe.PresentationDefinition{}, BuildParams{Audience: verifierDID.String(), Expires: time.Now().Add(time.Second), Format: vpFormats, Nonce: ""}) + vp, submission, err := w.buildSubmission(ctx, credentials, pe.PresentationDefinition{}, nil, BuildParams{Audience: verifierDID.String(), Expires: time.Now().Add(time.Second), Format: vpFormats, Nonce: ""}) assert.Nil(t, err) assert.NotNil(t, vp) diff --git a/vcr/holder/sql_wallet.go b/vcr/holder/sql_wallet.go index c66b6b92da..9925d89119 100644 --- a/vcr/holder/sql_wallet.go +++ b/vcr/holder/sql_wallet.go @@ -34,6 +34,7 @@ import ( "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/credential/store" + "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/types" @@ -79,7 +80,7 @@ type BuildParams struct { Nonce string } -func (h sqlWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, credentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { +func (h sqlWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, credentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialQueries []dcql.CredentialQuery, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { if credentials == nil { credentials = make(map[did.DID][]vc.VerifiableCredential) } @@ -97,7 +98,7 @@ func (h sqlWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, cr documentLoader: h.jsonldManager.DocumentLoader(), signer: h.keyStore, keyResolver: h.keyResolver, - }.buildSubmission(ctx, credentials, presentationDefinition, params) + }.buildSubmission(ctx, credentials, presentationDefinition, credentialQueries, params) } func (h sqlWallet) BuildPresentation(ctx context.Context, credentials []vc.VerifiableCredential, options PresentationOptions, signerDID *did.DID, validateVC bool) (*vc.VerifiablePresentation, error) { From 709f1bbe7a47a1e10e532dc39dc3cb4b98e472f9 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 25 Mar 2026 18:02:17 +0100 Subject: [PATCH 3/7] #4090: Wire credential_selection through API to presenter Replace credential_query (DCQL) with credential_selection (named parameters) throughout the callstack. The API accepts a simple map[string]string mapping PD field IDs to expected values, which is propagated through the IAM client, wallet, and presenter to NewSelectionSelector. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api.go | 21 ++---- auth/api/iam/generated.go | 28 ++------ auth/client/iam/interface.go | 3 +- auth/client/iam/mock.go | 9 ++- auth/client/iam/openid4vp.go | 5 +- auth/client/iam/openid4vp_test.go | 3 +- docs/_static/auth/v2.yaml | 64 ++++--------------- e2e-tests/browser/client/iam/generated.go | 28 ++------ e2e-tests/oauth-flow/rfc021/do-test.sh | 34 ++++------ .../node-A/presentationexchangemapping.json | 1 + vcr/holder/interface.go | 5 +- vcr/holder/memory_wallet.go | 6 +- vcr/holder/mock.go | 9 ++- vcr/holder/presenter.go | 11 ++-- vcr/holder/sql_wallet.go | 6 +- 15 files changed, 67 insertions(+), 166 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 1ecc3e3a35..4f6df7a072 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -56,7 +56,6 @@ import ( "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" - "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didsubject" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -774,24 +773,14 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS if request.Body.TokenType != nil && strings.EqualFold(string(*request.Body.TokenType), AccessTokenTypeBearer) { useDPoP = false } - // Convert credential_query from generated types to dcql types - var credentialQueries []dcql.CredentialQuery - if request.Body.CredentialQuery != nil { - for _, q := range *request.Body.CredentialQuery { - query := dcql.CredentialQuery{ID: q.Id} - for _, c := range q.Claims { - claim := dcql.ClaimsQuery{Path: c.Path} - if c.Values != nil { - claim.Values = *c.Values - } - query.Claims = append(query.Claims, claim) - } - credentialQueries = append(credentialQueries, query) - } + // Extract credential_selection from request + var credentialSelection map[string]string + if request.Body.CredentialSelection != nil { + credentialSelection = *request.Body.CredentialSelection } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialQueries) + tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection) if err != nil { // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials return nil, err diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 03edade2d3..859cff5efd 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -134,30 +134,14 @@ type ServiceAccessTokenRequest struct { // used to locate the OAuth2 Authorization Server metadata. AuthorizationServer string `json:"authorization_server"` - // CredentialQuery Optional DCQL credential queries (per OpenID4VP section 6.1/6.3) to disambiguate credential - // selection when the wallet contains multiple credentials matching a single input descriptor. - // Each query's id MUST match an input descriptor ID in the Presentation Definition for the - // requested scope. The query's claims specify value filters for selecting the right credential. + // CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple + // credentials matching a single input descriptor. Each key must match a field id declared + // in the Presentation Definition's input descriptor constraints. The value narrows the + // match to credentials where that field equals the given value. // - // The DCQL filter must narrow to exactly one credential per input descriptor. + // The selection must narrow to exactly one credential per input descriptor. // Zero matches or multiple matches will result in an error. - CredentialQuery *[]struct { - // Claims Claims queries specifying value filters for credential selection. - Claims []struct { - // Path Claims Path Pointer (OpenID4VP section 7) specifying the path to a claim - // within the credential. Elements can be strings (key lookup), integers - // (array index), or null (array wildcard). Path starts at the credential root. - Path []interface{} `json:"path"` - - // Values Expected values for the claim. The credential matches if the claim value - // equals at least one of the values (OR semantics). - Values *[]interface{} `json:"values,omitempty"` - } `json:"claims"` - - // Id Identifier linking this query to a Presentation Definition input descriptor. - // Must consist of alphanumeric, underscore, or hyphen characters. - Id string `json:"id"` - } `json:"credential_query,omitempty"` + CredentialSelection *map[string]string `json:"credential_selection,omitempty"` // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. // They must be in the form of a Verifiable Credential in JSON form. diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index c0b935f362..7fc5b2925b 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -22,7 +22,6 @@ import ( "context" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" - "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -46,7 +45,7 @@ type Client interface { PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error) // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021. RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, - credentials []vc.VerifiableCredential, credentialQueries []dcql.CredentialQuery) (*oauth.TokenResponse, error) + credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. // oauthIssuer is the URL of the issuer as specified by RFC 8414 (OAuth 2.0 Authorization Server Metadata). diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 2a144f6aac..b6ad933a61 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -15,7 +15,6 @@ import ( vc "github.com/nuts-foundation/go-did/vc" oauth "github.com/nuts-foundation/nuts-node/auth/oauth" - dcql "github.com/nuts-foundation/nuts-node/vcr/dcql" pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -195,18 +194,18 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet } // RequestRFC021AccessToken mocks base method. -func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialQueries []dcql.CredentialQuery) (*oauth.TokenResponse, error) { +func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialQueries) + ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // RequestRFC021AccessToken indicates an expected call of RequestRFC021AccessToken. -func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialQueries any) *gomock.Call { +func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialQueries) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection) } // VerifiableCredentials mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 2ee5b867d8..bf7f8fef68 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -42,7 +42,6 @@ import ( nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/crypto/dpop" nutsHttp "github.com/nuts-foundation/nuts-node/http" - "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -236,7 +235,7 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd } func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, - useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialQueries []dcql.CredentialQuery) (*oauth.TokenResponse, error) { + useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) { iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { @@ -297,7 +296,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID additionalWalletCredentials[subjectDID] = append(additionalWalletCredentials[subjectDID], credential.AutoCorrectSelfAttestedCredential(curr, subjectDID)) } } - vp, submission, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, *presentationDefinition, credentialQueries, params) + vp, submission, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, *presentationDefinition, credentialSelection, params) if err != nil { return nil, err } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index cdabdfa832..c14ff83a37 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -41,7 +41,6 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" http2 "github.com/nuts-foundation/nuts-node/test/http" - "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didweb" @@ -303,7 +302,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }, } ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, _ []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ []dcql.CredentialQuery, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + DoAndReturn(func(_ context.Context, _ []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { // Assert self-attested credentials require.Len(t, additionalCredentials, 2) require.Len(t, additionalCredentials[primaryWalletDID], 1) diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 865494c49f..fd36b4f386 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -443,62 +443,20 @@ components: } } ] - credential_query: - type: array + credential_selection: + type: object description: | - Optional DCQL credential queries (per OpenID4VP section 6.1/6.3) to disambiguate credential - selection when the wallet contains multiple credentials matching a single input descriptor. - Each query's id MUST match an input descriptor ID in the Presentation Definition for the - requested scope. The query's claims specify value filters for selecting the right credential. + Optional key-value mapping for credential selection when the wallet contains multiple + credentials matching a single input descriptor. Each key must match a field id declared + in the Presentation Definition's input descriptor constraints. The value narrows the + match to credentials where that field equals the given value. - The DCQL filter must narrow to exactly one credential per input descriptor. + The selection must narrow to exactly one credential per input descriptor. Zero matches or multiple matches will result in an error. - items: - type: object - required: - - id - - claims - properties: - id: - type: string - description: | - Identifier linking this query to a Presentation Definition input descriptor. - Must consist of alphanumeric, underscore, or hyphen characters. - example: id_patient_enrollment - claims: - type: array - description: Claims queries specifying value filters for credential selection. - items: - type: object - required: - - path - properties: - path: - type: array - description: | - Claims Path Pointer (OpenID4VP section 7) specifying the path to a claim - within the credential. Elements can be strings (key lookup), integers - (array index), or null (array wildcard). Path starts at the credential root. - items: {} - example: ["credentialSubject", "patientId"] - values: - type: array - description: | - Expected values for the claim. The credential matches if the claim value - equals at least one of the values (OR semantics). - items: {} - example: ["123456789"] - example: [ - { - "id": "id_patient_enrollment", - "claims": [ - { - "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"], - "values": ["123456789"] - } - ] - } - ] + additionalProperties: + type: string + example: + patient_id: "123456789" token_type: type: string description: "The type of access token that is preferred, default: DPoP" diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index b1e266112f..51c1f31772 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -128,30 +128,14 @@ type ServiceAccessTokenRequest struct { // used to locate the OAuth2 Authorization Server metadata. AuthorizationServer string `json:"authorization_server"` - // CredentialQuery Optional DCQL credential queries (per OpenID4VP section 6.1/6.3) to disambiguate credential - // selection when the wallet contains multiple credentials matching a single input descriptor. - // Each query's id MUST match an input descriptor ID in the Presentation Definition for the - // requested scope. The query's claims specify value filters for selecting the right credential. + // CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple + // credentials matching a single input descriptor. Each key must match a field id declared + // in the Presentation Definition's input descriptor constraints. The value narrows the + // match to credentials where that field equals the given value. // - // The DCQL filter must narrow to exactly one credential per input descriptor. + // The selection must narrow to exactly one credential per input descriptor. // Zero matches or multiple matches will result in an error. - CredentialQuery *[]struct { - // Claims Claims queries specifying value filters for credential selection. - Claims []struct { - // Path Claims Path Pointer (OpenID4VP section 7) specifying the path to a claim - // within the credential. Elements can be strings (key lookup), integers - // (array index), or null (array wildcard). Path starts at the credential root. - Path []interface{} `json:"path"` - - // Values Expected values for the claim. The credential matches if the claim value - // equals at least one of the values (OR semantics). - Values *[]interface{} `json:"values,omitempty"` - } `json:"claims"` - - // Id Identifier linking this query to a Presentation Definition input descriptor. - // Must consist of alphanumeric, underscore, or hyphen characters. - Id string `json:"id"` - } `json:"credential_query,omitempty"` + CredentialSelection *map[string]string `json:"credential_selection,omitempty"` // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. // They must be in the form of a Verifiable Credential in JSON form. diff --git a/e2e-tests/oauth-flow/rfc021/do-test.sh b/e2e-tests/oauth-flow/rfc021/do-test.sh index deab95131c..c88e8ee9d5 100755 --- a/e2e-tests/oauth-flow/rfc021/do-test.sh +++ b/e2e-tests/oauth-flow/rfc021/do-test.sh @@ -207,9 +207,9 @@ else exitWithDockerLogs 1 fi -echo "---------------------------------------" -echo "Test credential_query (DCQL) selection..." -echo "---------------------------------------" +echo "-------------------------------------------" +echo "Test credential_selection (named params)..." +echo "-------------------------------------------" # Issue a second NutsOrganizationCredential with a different org name REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${VENDOR_B_DID}\", \"credentialSubject\": {\"id\":\"${VENDOR_B_DID}\", \"organization\":{\"name\":\"Second Org B.V.\", \"city\":\"Othertown\"}},\"withStatusList2021Revocation\": true}" VENDOR_B_CREDENTIAL_2=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/issuer/vc -H "Content-Type:application/json") @@ -231,23 +231,15 @@ else exitWithDockerLogs 1 fi -# Request access token with credential_query to select the second org credential +# Request access token with credential_selection to select the second org credential REQUEST=$( cat << EOF { "authorization_server": "https://nodeA/oauth2/vendorA", "scope": "test", - "credential_query": [ - { - "id": "id_nuts_care_organization_cred", - "claims": [ - { - "path": ["credentialSubject", "organization", "name"], - "values": ["Second Org B.V."] - } - ] - } - ], + "credential_selection": { + "organization_name": "Second Org B.V." + }, "credentials": [ { "@context": [ @@ -267,20 +259,20 @@ EOF ) RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:28081/internal/auth/v2/vendorB/request-service-access-token -H "Content-Type: application/json" -H "Cache-Control: no-cache") if echo $RESPONSE | grep -q "access_token"; then - echo "credential_query: access token obtained successfully" + echo "credential_selection: access token obtained successfully" else - echo "FAILED: Could not get access token with credential_query" 1>&2 + echo "FAILED: Could not get access token with credential_selection" 1>&2 echo $RESPONSE exitWithDockerLogs 1 fi # Verify introspection contains the correct org (Second Org B.V.) -DCQL_ACCESS_TOKEN=$(echo $RESPONSE | sed -E 's/.*"access_token":"([^"]*).*/\1/') -RESPONSE=$(curl -X POST -s --data "token=$DCQL_ACCESS_TOKEN" http://localhost:18081/internal/auth/v2/accesstoken/introspect_extended) +SELECTION_ACCESS_TOKEN=$(echo $RESPONSE | sed -E 's/.*"access_token":"([^"]*).*/\1/') +RESPONSE=$(curl -X POST -s --data "token=$SELECTION_ACCESS_TOKEN" http://localhost:18081/internal/auth/v2/accesstoken/introspect_extended) if echo $RESPONSE | grep -q "Second Org B.V."; then - echo "credential_query: correct organization selected" + echo "credential_selection: correct organization selected" else - echo "FAILED: credential_query did not select the correct organization" 1>&2 + echo "FAILED: credential_selection did not select the correct organization" 1>&2 echo $RESPONSE exitWithDockerLogs 1 fi diff --git a/e2e-tests/oauth-flow/rfc021/node-A/presentationexchangemapping.json b/e2e-tests/oauth-flow/rfc021/node-A/presentationexchangemapping.json index 4e0cd617c5..31ea2fa6d8 100644 --- a/e2e-tests/oauth-flow/rfc021/node-A/presentationexchangemapping.json +++ b/e2e-tests/oauth-flow/rfc021/node-A/presentationexchangemapping.json @@ -31,6 +31,7 @@ } }, { + "id": "organization_name", "path": [ "$.credentialSubject.organization.name" ], diff --git a/vcr/holder/interface.go b/vcr/holder/interface.go index a2f8ea8c84..5776f30f6f 100644 --- a/vcr/holder/interface.go +++ b/vcr/holder/interface.go @@ -25,7 +25,6 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" - "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" ) @@ -52,9 +51,9 @@ type Wallet interface { // BuildSubmission builds a Verifiable Presentation based on the given presentation definition. // additionalCredentials can be given to have the submission consider extra credentials that are not in the wallet. - // credentialQueries optionally specifies DCQL credential queries to disambiguate credential selection + // credentialSelection optionally maps PD field IDs to expected values to disambiguate credential selection // when multiple credentials match a single input descriptor. - BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialQueries []dcql.CredentialQuery, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) + BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialSelection map[string]string, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) // List returns all credentials in the wallet for the given holder. // If the wallet does not contain any credentials for the given holder, it returns an empty list. diff --git a/vcr/holder/memory_wallet.go b/vcr/holder/memory_wallet.go index d865aa4f07..9adbb3af0f 100644 --- a/vcr/holder/memory_wallet.go +++ b/vcr/holder/memory_wallet.go @@ -27,7 +27,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" - "github.com/nuts-foundation/nuts-node/vcr/dcql" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/piprate/json-gold/ld" @@ -60,7 +60,7 @@ func (m memoryWallet) BuildPresentation(ctx context.Context, credentials []vc.Ve }.buildPresentation(ctx, signerDID, credentials, options) } -func (m memoryWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialQueries []dcql.CredentialQuery, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { +func (m memoryWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialSelection map[string]string, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { wallets := make(map[did.DID][]vc.VerifiableCredential) for _, walletDID := range walletDIDs { wallets[walletDID] = m.credentials[walletDID] @@ -72,7 +72,7 @@ func (m memoryWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, documentLoader: m.documentLoader, signer: m.signer, keyResolver: m.keyResolver, - }.buildSubmission(ctx, wallets, presentationDefinition, credentialQueries, params) + }.buildSubmission(ctx, wallets, presentationDefinition, credentialSelection, params) } func (m memoryWallet) List(_ context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) { diff --git a/vcr/holder/mock.go b/vcr/holder/mock.go index da628a72fe..e12b0f8019 100644 --- a/vcr/holder/mock.go +++ b/vcr/holder/mock.go @@ -17,7 +17,6 @@ import ( did "github.com/nuts-foundation/go-did/did" vc "github.com/nuts-foundation/go-did/vc" core "github.com/nuts-foundation/nuts-node/core" - dcql "github.com/nuts-foundation/nuts-node/vcr/dcql" pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -62,9 +61,9 @@ func (mr *MockWalletMockRecorder) BuildPresentation(ctx, credentials, options, s } // BuildSubmission mocks base method. -func (m *MockWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialQueries []dcql.CredentialQuery, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { +func (m *MockWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialSelection map[string]string, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BuildSubmission", ctx, walletDIDs, additionalCredentials, presentationDefinition, credentialQueries, params) + ret := m.ctrl.Call(m, "BuildSubmission", ctx, walletDIDs, additionalCredentials, presentationDefinition, credentialSelection, params) ret0, _ := ret[0].(*vc.VerifiablePresentation) ret1, _ := ret[1].(*pe.PresentationSubmission) ret2, _ := ret[2].(error) @@ -72,9 +71,9 @@ func (m *MockWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, } // BuildSubmission indicates an expected call of BuildSubmission. -func (mr *MockWalletMockRecorder) BuildSubmission(ctx, walletDIDs, additionalCredentials, presentationDefinition, credentialQueries, params any) *gomock.Call { +func (mr *MockWalletMockRecorder) BuildSubmission(ctx, walletDIDs, additionalCredentials, presentationDefinition, credentialSelection, params any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildSubmission", reflect.TypeOf((*MockWallet)(nil).BuildSubmission), ctx, walletDIDs, additionalCredentials, presentationDefinition, credentialQueries, params) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildSubmission", reflect.TypeOf((*MockWallet)(nil).BuildSubmission), ctx, walletDIDs, additionalCredentials, presentationDefinition, credentialSelection, params) } // Diagnostics mocks base method. diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index e0dc07bb5e..e9e70eeea3 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -32,7 +32,6 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vcr/dcql" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" @@ -49,7 +48,7 @@ type presenter struct { } func (p presenter) buildSubmission(ctx context.Context, credentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, - credentialQueries []dcql.CredentialQuery, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + credentialSelection map[string]string, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { // match against the wallet's credentials // if there's a match, create a VP and call the token endpoint // If the token endpoint succeeds, return the access token @@ -58,10 +57,10 @@ func (p presenter) buildSubmission(ctx context.Context, credentials map[did.DID] for holderDID, creds := range credentials { builder.AddWallet(holderDID, creds) } - // If DCQL credential queries are provided, create a selector that narrows - // credential selection per input descriptor. - if len(credentialQueries) > 0 { - selector, err := pe.NewDCQLSelector(credentialQueries, presentationDefinition, pe.FirstMatchSelector) + // If credential selection is provided, create a selector that narrows + // credential selection per input descriptor by field ID values. + if len(credentialSelection) > 0 { + selector, err := pe.NewSelectionSelector(credentialSelection, presentationDefinition, pe.FirstMatchSelector) if err != nil { return nil, nil, err } diff --git a/vcr/holder/sql_wallet.go b/vcr/holder/sql_wallet.go index 9925d89119..7323332558 100644 --- a/vcr/holder/sql_wallet.go +++ b/vcr/holder/sql_wallet.go @@ -34,7 +34,7 @@ import ( "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/credential/store" - "github.com/nuts-foundation/nuts-node/vcr/dcql" + "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/types" @@ -80,7 +80,7 @@ type BuildParams struct { Nonce string } -func (h sqlWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, credentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialQueries []dcql.CredentialQuery, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { +func (h sqlWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, credentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, credentialSelection map[string]string, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { if credentials == nil { credentials = make(map[did.DID][]vc.VerifiableCredential) } @@ -98,7 +98,7 @@ func (h sqlWallet) BuildSubmission(ctx context.Context, walletDIDs []did.DID, cr documentLoader: h.jsonldManager.DocumentLoader(), signer: h.keyStore, keyResolver: h.keyResolver, - }.buildSubmission(ctx, credentials, presentationDefinition, credentialQueries, params) + }.buildSubmission(ctx, credentials, presentationDefinition, credentialSelection, params) } func (h sqlWallet) BuildPresentation(ctx context.Context, credentials []vc.VerifiableCredential, options PresentationOptions, signerDID *did.DID, validateVC bool) (*vc.VerifiablePresentation, error) { From df5890aa417e2d551bb4d3411df4da0c04b06ca7 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 25 Mar 2026 18:28:44 +0100 Subject: [PATCH 4/7] #4090: Fix shellcheck issues, add presenter credential_selection test - Double-quote shell variables in e2e test to prevent globbing/splitting - Use single-quoted heredoc for JSON literal - Fix empty-response check to use proper test syntax - Add unit test for buildSubmission with credential_selection Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e-tests/oauth-flow/rfc021/do-test.sh | 26 ++++++------ vcr/holder/presenter_test.go | 57 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/e2e-tests/oauth-flow/rfc021/do-test.sh b/e2e-tests/oauth-flow/rfc021/do-test.sh index c88e8ee9d5..c4dba8743b 100755 --- a/e2e-tests/oauth-flow/rfc021/do-test.sh +++ b/e2e-tests/oauth-flow/rfc021/do-test.sh @@ -212,28 +212,28 @@ echo "Test credential_selection (named params)..." echo "-------------------------------------------" # Issue a second NutsOrganizationCredential with a different org name REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${VENDOR_B_DID}\", \"credentialSubject\": {\"id\":\"${VENDOR_B_DID}\", \"organization\":{\"name\":\"Second Org B.V.\", \"city\":\"Othertown\"}},\"withStatusList2021Revocation\": true}" -VENDOR_B_CREDENTIAL_2=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/issuer/vc -H "Content-Type:application/json") -if echo $VENDOR_B_CREDENTIAL_2 | grep -q "VerifiableCredential"; then +VENDOR_B_CREDENTIAL_2=$(echo "$REQUEST" | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/issuer/vc -H "Content-Type:application/json") +if echo "$VENDOR_B_CREDENTIAL_2" | grep -q "VerifiableCredential"; then echo "Second NutsOrganizationCredential issued" else echo "FAILED: Could not issue second NutsOrganizationCredential" 1>&2 - echo $VENDOR_B_CREDENTIAL_2 + echo "$VENDOR_B_CREDENTIAL_2" exitWithDockerLogs 1 fi # Store second credential in wallet -RESPONSE=$(echo $VENDOR_B_CREDENTIAL_2 | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/holder/vendorB/vc -H "Content-Type:application/json") -if echo $RESPONSE == ""; then +RESPONSE=$(echo "$VENDOR_B_CREDENTIAL_2" | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/holder/vendorB/vc -H "Content-Type:application/json") +if [ "$RESPONSE" == "" ]; then echo "Second VC stored in wallet" else echo "FAILED: Could not store second NutsOrganizationCredential in wallet" 1>&2 - echo $RESPONSE + echo "$RESPONSE" exitWithDockerLogs 1 fi # Request access token with credential_selection to select the second org credential REQUEST=$( -cat << EOF +cat <<'EOF' { "authorization_server": "https://nodeA/oauth2/vendorA", "scope": "test", @@ -257,23 +257,23 @@ cat << EOF } EOF ) -RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:28081/internal/auth/v2/vendorB/request-service-access-token -H "Content-Type: application/json" -H "Cache-Control: no-cache") -if echo $RESPONSE | grep -q "access_token"; then +RESPONSE=$(echo "$REQUEST" | curl -X POST -s --data-binary @- http://localhost:28081/internal/auth/v2/vendorB/request-service-access-token -H "Content-Type: application/json" -H "Cache-Control: no-cache") +if echo "$RESPONSE" | grep -q "access_token"; then echo "credential_selection: access token obtained successfully" else echo "FAILED: Could not get access token with credential_selection" 1>&2 - echo $RESPONSE + echo "$RESPONSE" exitWithDockerLogs 1 fi # Verify introspection contains the correct org (Second Org B.V.) -SELECTION_ACCESS_TOKEN=$(echo $RESPONSE | sed -E 's/.*"access_token":"([^"]*).*/\1/') +SELECTION_ACCESS_TOKEN=$(echo "$RESPONSE" | sed -E 's/.*"access_token":"([^"]*).*/\1/') RESPONSE=$(curl -X POST -s --data "token=$SELECTION_ACCESS_TOKEN" http://localhost:18081/internal/auth/v2/accesstoken/introspect_extended) -if echo $RESPONSE | grep -q "Second Org B.V."; then +if echo "$RESPONSE" | grep -q "Second Org B.V."; then echo "credential_selection: correct organization selected" else echo "FAILED: credential_selection did not select the correct organization" 1>&2 - echo $RESPONSE + echo "$RESPONSE" exitWithDockerLogs 1 fi diff --git a/vcr/holder/presenter_test.go b/vcr/holder/presenter_test.go index d28de89c8d..9671b719b1 100644 --- a/vcr/holder/presenter_test.go +++ b/vcr/holder/presenter_test.go @@ -343,6 +343,63 @@ func TestPresenter_buildSubmission(t *testing.T) { assert.ErrorIs(t, err, pe.ErrNoCredentials) }) + t.Run("ok - credential_selection narrows to correct credential", func(t *testing.T) { + resetStore(t, storageEngine.GetSQLDatabase()) + ctrl := gomock.NewController(t) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(nutsWalletDID, nil, resolver.NutsSigningKeyType).Return(key.KID, key.PublicKey, nil) + + // Two NutsOrganizationCredentials with different org names, same subject DID + vc1 := test.ValidNutsOrganizationCredential(t) // org name: "Because we care B.V." + vc2JSON := `{ + "@context": ["https://nuts.nl/credentials/v1","https://www.w3.org/2018/credentials/v1","https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json"], + "type": ["NutsOrganizationCredential", "VerifiableCredential"], + "issuer": "did:nuts:CuE3qeFGGLhEAS3gKzhMCeqd1dGa9at5JCbmCfyMU2Ey", + "issuanceDate": "2022-06-01T15:34:40.65319+02:00", + "credentialSubject": {"id": "did:nuts:CuE3qeFGGLhEAS3gKzhMCeqd1dGa9at5JCbmCfyMU2Ey", "organization": {"name": "Second Org B.V.", "city": "Othertown"}} + }` + var vc2 vc.VerifiableCredential + require.NoError(t, vc2.UnmarshalJSON([]byte(vc2JSON))) + + // PD with org_name field ID + pdWithSelection := pe.PresentationDefinition{ + InputDescriptors: []*pe.InputDescriptor{ + { + Id: "org_cred", + Constraints: &pe.Constraints{ + Fields: []pe.Field{ + { + Path: []string{"$.type"}, + Filter: &pe.Filter{Type: "string", Const: to.Ptr("NutsOrganizationCredential")}, + }, + { + Id: to.Ptr("org_name"), + Path: []string{"$.credentialSubject.organization.name"}, + }, + }, + }, + }, + }, + } + + twoOrgCreds := map[did.DID][]vc.VerifiableCredential{ + nutsWalletDID: {vc1, vc2}, + } + w := presenter{documentLoader: jsonldManager.DocumentLoader(), signer: keyStore, keyResolver: keyResolver} + + vp, submission, err := w.buildSubmission(ctx, twoOrgCreds, pdWithSelection, + map[string]string{"org_name": "Second Org B.V."}, + BuildParams{Audience: verifierDID.String(), Expires: time.Now().Add(time.Second), Format: vpFormats, Nonce: ""}) + + require.NoError(t, err) + require.NotNil(t, vp) + require.NotNil(t, submission) + // The VP should contain vc2 (Second Org B.V.), not vc1 + require.Len(t, vp.VerifiableCredential, 1) + subjects := vp.VerifiableCredential[0].CredentialSubject + require.Len(t, subjects, 1) + assert.Equal(t, "Second Org B.V.", subjects[0]["organization"].(map[string]interface{})["name"]) + }) t.Run("ok - empty presentation", func(t *testing.T) { resetStore(t, storageEngine.GetSQLDatabase()) ctrl := gomock.NewController(t) From ad7d6963aa4917a54d75c06ee65995eb1454e1fe Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 27 Mar 2026 08:37:40 +0100 Subject: [PATCH 5/7] fixup: Use renamed NewFieldSelector in presenter Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/holder/presenter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index e9e70eeea3..8163534e8b 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -60,7 +60,7 @@ func (p presenter) buildSubmission(ctx context.Context, credentials map[did.DID] // If credential selection is provided, create a selector that narrows // credential selection per input descriptor by field ID values. if len(credentialSelection) > 0 { - selector, err := pe.NewSelectionSelector(credentialSelection, presentationDefinition, pe.FirstMatchSelector) + selector, err := pe.NewFieldSelector(credentialSelection, presentationDefinition, pe.FirstMatchSelector) if err != nil { return nil, nil, err } From dea295f981cc087f2d17eb153660c2620bb6e83e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 27 Mar 2026 12:00:22 +0100 Subject: [PATCH 6/7] #4122: Address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Capitalize "field id" → "field ID" in OpenAPI spec - Add godoc for credentials and credentialSelection parameters - Document default behavior when credential_selection is omitted - Add comment explaining FirstMatchSelector fallback - Add comment explaining nil credential_selection is safe (read-only) Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api.go | 3 ++- auth/client/iam/interface.go | 2 ++ docs/_static/auth/v2.yaml | 5 ++++- vcr/holder/presenter.go | 3 +++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 4f6df7a072..bedbba113d 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -773,7 +773,8 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS if request.Body.TokenType != nil && strings.EqualFold(string(*request.Body.TokenType), AccessTokenTypeBearer) { useDPoP = false } - // Extract credential_selection from request + // Extract credential_selection from request. + // nil is safe here: downstream code only reads via len() and range. var credentialSelection map[string]string if request.Body.CredentialSelection != nil { credentialSelection = *request.Body.CredentialSelection diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 7fc5b2925b..5ccf9caaf5 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -44,6 +44,8 @@ type Client interface { // PresentationDefinition returns the presentation definition from the given endpoint. PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error) // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021. + // credentials are additional VCs to include alongside wallet-stored credentials. + // credentialSelection maps PD field IDs to expected values to disambiguate when multiple credentials match an input descriptor. RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index fd36b4f386..76d99cb7be 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -447,12 +447,15 @@ components: type: object description: | Optional key-value mapping for credential selection when the wallet contains multiple - credentials matching a single input descriptor. Each key must match a field id declared + credentials matching a single input descriptor. Each key must match a field ID declared in the Presentation Definition's input descriptor constraints. The value narrows the match to credentials where that field equals the given value. The selection must narrow to exactly one credential per input descriptor. Zero matches or multiple matches will result in an error. + + When omitted and multiple credentials match an input descriptor, + the first matching credential is used. additionalProperties: type: string example: diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index 8163534e8b..0f2a095f1d 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -59,6 +59,9 @@ func (p presenter) buildSubmission(ctx context.Context, credentials map[did.DID] } // If credential selection is provided, create a selector that narrows // credential selection per input descriptor by field ID values. + // FirstMatchSelector is the fallback for input descriptors not targeted + // by the selection keys — the caller only needs to specify keys for + // descriptors where they want deterministic, explicit credential selection. if len(credentialSelection) > 0 { selector, err := pe.NewFieldSelector(credentialSelection, presentationDefinition, pe.FirstMatchSelector) if err != nil { From 80325907d0b75dbbf6f1026afc8e88c128dcbaf9 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 27 Mar 2026 12:36:15 +0100 Subject: [PATCH 7/7] #4122: Remove fallback parameter from NewFieldSelector call The builder now handles the fallback to FirstMatchSelector when a selector returns (nil, nil), so the explicit fallback argument is no longer needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/holder/presenter.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go index 0f2a095f1d..4c7fda522e 100644 --- a/vcr/holder/presenter.go +++ b/vcr/holder/presenter.go @@ -59,11 +59,8 @@ func (p presenter) buildSubmission(ctx context.Context, credentials map[did.DID] } // If credential selection is provided, create a selector that narrows // credential selection per input descriptor by field ID values. - // FirstMatchSelector is the fallback for input descriptors not targeted - // by the selection keys — the caller only needs to specify keys for - // descriptors where they want deterministic, explicit credential selection. if len(credentialSelection) > 0 { - selector, err := pe.NewFieldSelector(credentialSelection, presentationDefinition, pe.FirstMatchSelector) + selector, err := pe.NewFieldSelector(credentialSelection, presentationDefinition) if err != nil { return nil, nil, err }