diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go
index 339d4b0086..bedbba113d 100644
--- a/auth/api/iam/api.go
+++ b/auth/api/iam/api.go
@@ -773,8 +773,15 @@ 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.
+ // 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
+ }
+
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, 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/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..859cff5efd 100644
--- a/auth/api/iam/generated.go
+++ b/auth/api/iam/generated.go
@@ -134,6 +134,15 @@ type ServiceAccessTokenRequest struct {
// used to locate the OAuth2 Authorization Server metadata.
AuthorizationServer string `json:"authorization_server"`
+ // 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 selection must narrow to exactly one credential per input descriptor.
+ // Zero matches or multiple matches will result in an error.
+ 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.
// 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..5ccf9caaf5 100644
--- a/auth/client/iam/interface.go
+++ b/auth/client/iam/interface.go
@@ -44,8 +44,10 @@ 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) (*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 add1a059cd..b6ad933a61 100644
--- a/auth/client/iam/mock.go
+++ b/auth/client/iam/mock.go
@@ -194,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) (*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)
+ 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 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)
+ 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 0f9e370601..bf7f8fef68 100644
--- a/auth/client/iam/openid4vp.go
+++ b/auth/client/iam/openid4vp.go
@@ -235,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) (*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 {
@@ -296,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, 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 e5c4ef6840..c14ff83a37 100644
--- a/auth/client/iam/openid4vp_test.go
+++ b/auth/client/iam/openid4vp_test.go
@@ -251,9 +251,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 +263,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 +275,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 +301,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, _ 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)
@@ -312,7 +312,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 +324,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 +346,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 +365,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 +375,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 +389,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 +398,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..76d99cb7be 100644
--- a/docs/_static/auth/v2.yaml
+++ b/docs/_static/auth/v2.yaml
@@ -443,6 +443,23 @@ components:
}
}
]
+ credential_selection:
+ 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
+ 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:
+ 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 9ed0f8cbac..51c1f31772 100644
--- a/e2e-tests/browser/client/iam/generated.go
+++ b/e2e-tests/browser/client/iam/generated.go
@@ -128,6 +128,15 @@ type ServiceAccessTokenRequest struct {
// used to locate the OAuth2 Authorization Server metadata.
AuthorizationServer string `json:"authorization_server"`
+ // 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 selection must narrow to exactly one credential per input descriptor.
+ // Zero matches or multiple matches will result in an error.
+ 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.
// The serialized form (JWT or JSON-LD) in the resulting Verifiable Presentation depends on the capability of the authorizing party.
diff --git a/e2e-tests/oauth-flow/rfc021/do-test.sh b/e2e-tests/oauth-flow/rfc021/do-test.sh
index 2eb90fa14e..c4dba8743b 100755
--- a/e2e-tests/oauth-flow/rfc021/do-test.sh
+++ b/e2e-tests/oauth-flow/rfc021/do-test.sh
@@ -207,6 +207,76 @@ else
exitWithDockerLogs 1
fi
+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")
+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 [ "$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_selection to select the second org credential
+REQUEST=$(
+cat <<'EOF'
+{
+ "authorization_server": "https://nodeA/oauth2/vendorA",
+ "scope": "test",
+ "credential_selection": {
+ "organization_name": "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_selection: access token obtained successfully"
+else
+ 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.)
+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_selection: correct organization selected"
+else
+ echo "FAILED: credential_selection did not select the correct organization" 1>&2
+ echo "$RESPONSE"
+ exitWithDockerLogs 1
+fi
+
echo "------------------------------------"
echo "Revoking credential..."
echo "------------------------------------"
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 0483b17943..5776f30f6f 100644
--- a/vcr/holder/interface.go
+++ b/vcr/holder/interface.go
@@ -51,7 +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.
- BuildSubmission(ctx context.Context, walletDIDs []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, presentationDefinition pe.PresentationDefinition, params BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error)
+ // 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, 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 8e466719a0..9adbb3af0f 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/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, 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]
@@ -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, 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 bd81a024fd..e12b0f8019 100644
--- a/vcr/holder/mock.go
+++ b/vcr/holder/mock.go
@@ -61,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, 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, 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)
@@ -71,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, 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, 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 182f434d41..4c7fda522e 100644
--- a/vcr/holder/presenter.go
+++ b/vcr/holder/presenter.go
@@ -48,7 +48,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) {
+ 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
@@ -57,6 +57,15 @@ func (p presenter) buildSubmission(ctx context.Context, credentials map[did.DID]
for holderDID, creds := range credentials {
builder.AddWallet(holderDID, creds)
}
+ // 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.NewFieldSelector(credentialSelection, presentationDefinition)
+ 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..9671b719b1 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,10 +339,67 @@ 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)
})
+ 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)
@@ -351,7 +408,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..7323332558 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/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, credentialSelection map[string]string, 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, credentialSelection, params)
}
func (h sqlWallet) BuildPresentation(ctx context.Context, credentials []vc.VerifiableCredential, options PresentationOptions, signerDID *did.DID, validateVC bool) (*vc.VerifiablePresentation, error) {
diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go
index df41b6907f..1b982e4f43 100644
--- a/vcr/pe/presentation_definition.go
+++ b/vcr/pe/presentation_definition.go
@@ -74,14 +74,20 @@ type PresentationContext struct {
// ErrUnsupportedFilter is returned when a filter uses unsupported features.
// Other errors can be returned for faulty JSON paths or regex patterns.
func (presentationDefinition PresentationDefinition) Match(vcs []vc.VerifiableCredential) ([]vc.VerifiableCredential, []InputDescriptorMappingObject, error) {
+ return presentationDefinition.MatchWithSelector(vcs, FirstMatchSelector)
+}
+
+// MatchWithSelector matches the VCs against the presentation definition using the provided CredentialSelector.
+// The selector is called for each input descriptor with all matching VCs, and must pick one (or return nil).
+func (presentationDefinition PresentationDefinition) MatchWithSelector(vcs []vc.VerifiableCredential, selector CredentialSelector) ([]vc.VerifiableCredential, []InputDescriptorMappingObject, error) {
var selectedVCs []vc.VerifiableCredential
var descriptorMaps []InputDescriptorMappingObject
var err error
if len(presentationDefinition.SubmissionRequirements) > 0 {
- if descriptorMaps, selectedVCs, err = presentationDefinition.matchSubmissionRequirements(vcs); err != nil {
+ if descriptorMaps, selectedVCs, err = presentationDefinition.matchSubmissionRequirements(vcs, selector); err != nil {
return nil, nil, err
}
- } else if descriptorMaps, selectedVCs, err = presentationDefinition.matchBasic(vcs); err != nil {
+ } else if descriptorMaps, selectedVCs, err = presentationDefinition.matchBasic(vcs, selector); err != nil {
return nil, nil, err
}
@@ -136,15 +142,12 @@ func (presentationDefinition PresentationDefinition) CredentialsRequired() bool
return len(presentationDefinition.InputDescriptors) > 0
}
-func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.VerifiableCredential) ([]Candidate, error) {
+func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.VerifiableCredential, selector CredentialSelector) ([]Candidate, error) {
var candidates []Candidate
for _, inputDescriptor := range presentationDefinition.InputDescriptors {
- // we create an empty Candidate. If a VC matches, it'll be attached to the Candidate.
- // if no VC matches, the Candidate will have an nil VC which is detected later on for SubmissionRequirement rules.
- match := Candidate{
- InputDescriptor: *inputDescriptor,
- }
+ // Collect all matching VCs for this input descriptor
+ var matchingVCs []vc.VerifiableCredential
for _, credential := range vcs {
isMatch, err := matchCredential(*inputDescriptor, credential)
if err != nil {
@@ -152,19 +155,38 @@ func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.V
}
// InputDescriptor formats must be a subset of the PresentationDefinition formats, so it must satisfy both.
if isMatch && matchFormat(presentationDefinition.Format, credential) && matchFormat(inputDescriptor.Format, credential) {
- match.VC = &credential
- break
+ matchingVCs = append(matchingVCs, credential)
}
}
- candidates = append(candidates, match)
+ // Use the selector to pick one credential from the candidates.
+ // (nil, nil) means the selector has no opinion — fall back to FirstMatchSelector.
+ selected, err := selector(*inputDescriptor, matchingVCs)
+ if err != nil {
+ if errors.Is(err, ErrNoCredentials) {
+ // Treat as "no match" — submission requirements may still accept this
+ // (e.g., pick rules with min: 0).
+ selected = nil
+ } else {
+ return nil, err
+ }
+ } else if selected == nil && len(matchingVCs) > 0 {
+ selected, err = FirstMatchSelector(*inputDescriptor, matchingVCs)
+ if err != nil {
+ return nil, err
+ }
+ }
+ candidates = append(candidates, Candidate{
+ InputDescriptor: *inputDescriptor,
+ VC: selected,
+ })
}
return candidates, nil
}
-func (presentationDefinition PresentationDefinition) matchBasic(vcs []vc.VerifiableCredential) ([]InputDescriptorMappingObject, []vc.VerifiableCredential, error) {
+func (presentationDefinition PresentationDefinition) matchBasic(vcs []vc.VerifiableCredential, selector CredentialSelector) ([]InputDescriptorMappingObject, []vc.VerifiableCredential, error) {
// do the constraints check
- candidates, err := presentationDefinition.matchConstraints(vcs)
+ candidates, err := presentationDefinition.matchConstraints(vcs, selector)
if err != nil {
return nil, nil, err
}
@@ -201,9 +223,9 @@ func (presentationDefinition PresentationDefinition) matchBasic(vcs []vc.Verifia
return descriptors, matchingCredentials, nil
}
-func (presentationDefinition PresentationDefinition) matchSubmissionRequirements(vcs []vc.VerifiableCredential) ([]InputDescriptorMappingObject, []vc.VerifiableCredential, error) {
+func (presentationDefinition PresentationDefinition) matchSubmissionRequirements(vcs []vc.VerifiableCredential, selector CredentialSelector) ([]InputDescriptorMappingObject, []vc.VerifiableCredential, error) {
// first we use the constraint matching algorithm to get the matching credentials
- candidates, err := presentationDefinition.matchConstraints(vcs)
+ candidates, err := presentationDefinition.matchConstraints(vcs, selector)
if err != nil {
return nil, nil, err
}
diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go
index 9c220c3bee..0d8e19068d 100644
--- a/vcr/pe/presentation_definition_test.go
+++ b/vcr/pe/presentation_definition_test.go
@@ -446,6 +446,40 @@ func TestMatch(t *testing.T) {
})
}
+func TestPresentationDefinition_MatchWithSelector_SubmissionRequirements(t *testing.T) {
+ // test.Empty PD has: pick rule, min: 0, max: 1, group "A"
+ // Two input descriptors matching $.id == "1" and $.id == "2"
+ var pd PresentationDefinition
+ require.NoError(t, json.Unmarshal([]byte(test.Empty), &pd))
+
+ t.Run("Match with no matching VCs succeeds (min: 0 allows empty)", func(t *testing.T) {
+ // Old behavior: no VCs match, FirstMatchSelector returns (nil, nil),
+ // submission requirement pick rule with min: 0 accepts zero fulfilled descriptors.
+ vcs, mappings, err := pd.Match([]vc.VerifiableCredential{})
+
+ require.NoError(t, err)
+ assert.Empty(t, vcs)
+ assert.Empty(t, mappings)
+ })
+ t.Run("MatchWithSelector with ErrNoCredentials succeeds (min: 0 allows empty)", func(t *testing.T) {
+ // A custom selector that returns ErrNoCredentials when no candidates match.
+ // With min: 0, this should behave identically to Match — the submission
+ // requirement allows zero fulfilled descriptors.
+ strictSelector := func(_ InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
+ if len(candidates) == 0 {
+ return nil, ErrNoCredentials
+ }
+ return &candidates[0], nil
+ }
+
+ vcs, mappings, err := pd.MatchWithSelector([]vc.VerifiableCredential{}, strictSelector)
+
+ require.NoError(t, err)
+ assert.Empty(t, vcs)
+ assert.Empty(t, mappings)
+ })
+}
+
func TestPresentationDefinition_CredentialsRequired(t *testing.T) {
t.Run("no input descriptors", func(t *testing.T) {
pd := PresentationDefinition{}
diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go
index 1d78ed137f..fcd011152f 100644
--- a/vcr/pe/presentation_submission.go
+++ b/vcr/pe/presentation_submission.go
@@ -52,6 +52,7 @@ type PresentationSubmissionBuilder struct {
holders []did.DID
presentationDefinition PresentationDefinition
wallets [][]vc.VerifiableCredential
+ credentialSelector CredentialSelector
}
// PresentationSubmissionBuilder returns a new PresentationSubmissionBuilder.
@@ -62,6 +63,13 @@ func (presentationDefinition PresentationDefinition) PresentationSubmissionBuild
}
}
+// SetCredentialSelector configures a custom CredentialSelector for picking credentials
+// when multiple match an input descriptor. If not set, FirstMatchSelector is used.
+func (b *PresentationSubmissionBuilder) SetCredentialSelector(selector CredentialSelector) *PresentationSubmissionBuilder {
+ b.credentialSelector = selector
+ return b
+}
+
// AddWallet adds credentials from a wallet that may be used to create the PresentationSubmission.
func (b *PresentationSubmissionBuilder) AddWallet(holder did.DID, vcs []vc.VerifiableCredential) *PresentationSubmissionBuilder {
b.holders = append(b.holders, holder)
@@ -107,8 +115,13 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis
var inputDescriptorMappingObjects []InputDescriptorMappingObject
var selectedDID *did.DID
+ selector := b.credentialSelector
+ if selector == nil {
+ selector = FirstMatchSelector
+ }
+
for i, walletVCs := range b.wallets {
- vcs, mappingObjects, err := b.presentationDefinition.Match(walletVCs)
+ vcs, mappingObjects, err := b.presentationDefinition.MatchWithSelector(walletVCs, selector)
if err == nil {
selectedVCs = vcs
inputDescriptorMappingObjects = mappingObjects
diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go
index 5388e234ef..614777ff0c 100644
--- a/vcr/pe/presentation_submission_test.go
+++ b/vcr/pe/presentation_submission_test.go
@@ -20,6 +20,7 @@ package pe
import (
"encoding/json"
+ "errors"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
@@ -194,6 +195,88 @@ func TestPresentationSubmissionBuilder_Build(t *testing.T) {
})
}
+func TestPresentationSubmissionBuilder_SetCredentialSelector(t *testing.T) {
+ holder := did.MustParseDID("did:example:1")
+ id1 := ssi.MustParseURI("1")
+ id2 := ssi.MustParseURI("2")
+ // Both VCs have a "field" key — a broad PD will match both
+ vc1 := credentialToJSONLD(vc.VerifiableCredential{ID: &id1, CredentialSubject: []map[string]any{{"field": "a"}}})
+ vc2 := credentialToJSONLD(vc.VerifiableCredential{ID: &id2, CredentialSubject: []map[string]any{{"field": "b"}}})
+
+ // PD with a single input descriptor that matches any VC with a "field" key
+ var pd PresentationDefinition
+ require.NoError(t, json.Unmarshal([]byte(`{
+ "id": "test",
+ "input_descriptors": [{
+ "id": "match_field",
+ "constraints": {
+ "fields": [{
+ "path": ["$.credentialSubject.field"]
+ }]
+ }
+ }]
+ }`), &pd))
+
+ t.Run("custom selector picks second credential", func(t *testing.T) {
+ lastSelector := func(_ InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
+ if len(candidates) == 0 {
+ return nil, nil
+ }
+ return &candidates[len(candidates)-1], nil
+ }
+
+ builder := pd.PresentationSubmissionBuilder()
+ builder.AddWallet(holder, []vc.VerifiableCredential{vc1, vc2})
+ builder.SetCredentialSelector(lastSelector)
+
+ _, signInstruction, err := builder.Build("ldp_vp")
+
+ require.NoError(t, err)
+ require.Len(t, signInstruction.VerifiableCredentials, 1)
+ assert.Equal(t, &id2, signInstruction.VerifiableCredentials[0].ID)
+ })
+ t.Run("without selector uses first match (default)", func(t *testing.T) {
+ builder := pd.PresentationSubmissionBuilder()
+ builder.AddWallet(holder, []vc.VerifiableCredential{vc1, vc2})
+
+ _, signInstruction, err := builder.Build("ldp_vp")
+
+ require.NoError(t, err)
+ require.Len(t, signInstruction.VerifiableCredentials, 1)
+ assert.Equal(t, &id1, signInstruction.VerifiableCredentials[0].ID)
+ })
+ t.Run("selector receives all matching candidates", func(t *testing.T) {
+ var receivedCandidates []vc.VerifiableCredential
+ spySelector := func(_ InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
+ receivedCandidates = candidates
+ return &candidates[0], nil
+ }
+
+ builder := pd.PresentationSubmissionBuilder()
+ builder.AddWallet(holder, []vc.VerifiableCredential{vc1, vc2})
+ builder.SetCredentialSelector(spySelector)
+ builder.Build("ldp_vp")
+
+ require.Len(t, receivedCandidates, 2)
+ assert.Equal(t, &id1, receivedCandidates[0].ID)
+ assert.Equal(t, &id2, receivedCandidates[1].ID)
+ })
+ t.Run("selector error propagates", func(t *testing.T) {
+ selectorErr := errors.New("no matching credential for this input descriptor")
+ errorSelector := func(_ InputDescriptor, _ []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
+ return nil, selectorErr
+ }
+
+ builder := pd.PresentationSubmissionBuilder()
+ builder.AddWallet(holder, []vc.VerifiableCredential{vc1})
+ builder.SetCredentialSelector(errorSelector)
+
+ _, _, err := builder.Build("ldp_vp")
+
+ assert.ErrorIs(t, err, selectorErr)
+ })
+}
+
func TestPresentationSubmission_Resolve(t *testing.T) {
id1 := ssi.MustParseURI("1")
id2 := ssi.MustParseURI("2")
diff --git a/vcr/pe/selector.go b/vcr/pe/selector.go
new file mode 100644
index 0000000000..d38fa35c7c
--- /dev/null
+++ b/vcr/pe/selector.go
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package pe
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/nuts-foundation/go-did/vc"
+)
+
+// CredentialSelector picks one credential from a list of candidates that all match a given input descriptor.
+// It is called by matchConstraints after collecting all matching VCs for an input descriptor.
+//
+// Return values:
+// - (*vc, nil): a credential was selected successfully.
+// - (nil, nil): no credential was selected. The input descriptor is not fulfilled, which may
+// be acceptable depending on submission requirements (e.g., pick rules with min: 0).
+// - (nil, ErrNoCredentials): no candidates matched the selector's criteria. Treated as a soft
+// failure: the input descriptor is not fulfilled, but submission requirements may still accept
+// this (e.g., pick rules with min: 0).
+// - (nil, ErrMultipleCredentials): multiple candidates matched but the selector requires exactly one.
+// This is a hard failure — the match is aborted.
+// - (nil, other error): any other error is a hard failure.
+//
+// Selectors that are lenient (like FirstMatchSelector) may return (nil, nil) to let the caller decide.
+type CredentialSelector func(descriptor InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error)
+
+// FirstMatchSelector is the default CredentialSelector that picks the first matching credential.
+// This preserves the existing behavior of matchConstraints.
+func FirstMatchSelector(_ InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
+ if len(candidates) == 0 {
+ return nil, nil
+ }
+ return &candidates[0], nil
+}
+
+type fieldSelection struct {
+ fieldID string
+ expected string
+}
+
+// NewFieldSelector creates a CredentialSelector that filters candidates by
+// matching PD field ID values from the credential_selection parameter.
+// Only constant (equality) matching is supported; pattern-based filters are
+// already evaluated by matchConstraint before the selector runs.
+// Returns (nil, nil) for input descriptors without matching selection keys,
+// signalling the builder to apply its default selector.
+func NewFieldSelector(selection map[string]string, pd PresentationDefinition) (CredentialSelector, error) {
+ descriptorSelections := make(map[string][]fieldSelection)
+ matchedKeys := make(map[string]bool)
+
+ for _, desc := range pd.InputDescriptors {
+ if desc.Constraints == nil {
+ continue
+ }
+ for _, field := range desc.Constraints.Fields {
+ if field.Id == nil {
+ continue
+ }
+ if expected, ok := selection[*field.Id]; ok {
+ descriptorSelections[desc.Id] = append(descriptorSelections[desc.Id], fieldSelection{
+ fieldID: *field.Id,
+ expected: expected,
+ })
+ matchedKeys[*field.Id] = true
+ }
+ }
+ }
+
+ // Validate all selection keys match at least one field ID in the PD.
+ for key := range selection {
+ if !matchedKeys[key] {
+ return nil, fmt.Errorf("credential_selection key '%s' does not match any field id in the presentation definition", key)
+ }
+ }
+
+ return func(descriptor InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) {
+ selections, ok := descriptorSelections[descriptor.Id]
+ if !ok {
+ return nil, nil
+ }
+
+ var matched []vc.VerifiableCredential
+ for _, candidate := range candidates {
+ if descriptor.Constraints == nil {
+ continue
+ }
+ isMatch, values, err := matchConstraint(descriptor.Constraints, candidate)
+ if err != nil {
+ return nil, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, err)
+ }
+ if !isMatch {
+ continue
+ }
+ if matchesSelections(values, selections) {
+ matched = append(matched, candidate)
+ }
+ }
+
+ if len(matched) == 0 {
+ return nil, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, ErrNoCredentials)
+ }
+ if len(matched) > 1 {
+ return nil, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, ErrMultipleCredentials)
+ }
+ return &matched[0], nil
+ }, nil
+}
+
+func matchesSelections(values map[string]interface{}, selections []fieldSelection) bool {
+ for _, sel := range selections {
+ resolved, ok := values[sel.fieldID]
+ if !ok {
+ return false
+ }
+ var str string
+ switch v := resolved.(type) {
+ case string:
+ str = v
+ case float64:
+ str = strconv.FormatFloat(v, 'f', -1, 64)
+ case bool:
+ str = strconv.FormatBool(v)
+ default:
+ return false
+ }
+ if str != sel.expected {
+ return false
+ }
+ }
+ return true
+}
diff --git a/vcr/pe/selector_test.go b/vcr/pe/selector_test.go
new file mode 100644
index 0000000000..d740b17b6e
--- /dev/null
+++ b/vcr/pe/selector_test.go
@@ -0,0 +1,240 @@
+/*
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package pe
+
+import (
+ "testing"
+
+ ssi "github.com/nuts-foundation/go-did"
+ "github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/core/to"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewFieldSelector(t *testing.T) {
+ id1 := ssi.MustParseURI("1")
+ id2 := ssi.MustParseURI("2")
+ vc1 := credentialToJSONLD(vc.VerifiableCredential{ID: &id1, CredentialSubject: []map[string]any{{"patientId": "123"}}})
+ vc2 := credentialToJSONLD(vc.VerifiableCredential{ID: &id2, CredentialSubject: []map[string]any{{"patientId": "456"}}})
+ pd := PresentationDefinition{
+ InputDescriptors: []*InputDescriptor{
+ {
+ Id: "patient_credential",
+ Constraints: &Constraints{
+ Fields: []Field{
+ {
+ Id: to.Ptr("patient_id"),
+ Path: []string{"$.credentialSubject.patientId"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ t.Run("selection picks the right credential by field value", func(t *testing.T) {
+ selector, err := NewFieldSelector(map[string]string{
+ "patient_id": "456",
+ }, pd)
+ require.NoError(t, err)
+
+ result, err := selector(
+ *pd.InputDescriptors[0],
+ []vc.VerifiableCredential{vc1, vc2},
+ )
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Equal(t, &id2, result.ID)
+ })
+ t.Run("zero matches returns ErrNoCredentials", func(t *testing.T) {
+ selector, err := NewFieldSelector(map[string]string{
+ "patient_id": "nonexistent",
+ }, pd)
+ require.NoError(t, err)
+
+ _, err = selector(
+ *pd.InputDescriptors[0],
+ []vc.VerifiableCredential{vc1, vc2},
+ )
+
+ assert.ErrorIs(t, err, ErrNoCredentials)
+ })
+ t.Run("multiple matches returns ErrMultipleCredentials", func(t *testing.T) {
+ // Both VCs have a patientId field — selecting on a field that exists in both
+ // without narrowing to one should fail.
+ id3 := ssi.MustParseURI("3")
+ vc3 := credentialToJSONLD(vc.VerifiableCredential{ID: &id3, CredentialSubject: []map[string]any{{"patientId": "456"}}})
+ selector, err := NewFieldSelector(map[string]string{
+ "patient_id": "456",
+ }, pd)
+ require.NoError(t, err)
+
+ _, err = selector(
+ *pd.InputDescriptors[0],
+ []vc.VerifiableCredential{vc2, vc3},
+ )
+
+ assert.ErrorIs(t, err, ErrMultipleCredentials)
+ })
+ t.Run("matches numeric field values", func(t *testing.T) {
+ numPD := PresentationDefinition{
+ InputDescriptors: []*InputDescriptor{
+ {
+ Id: "room_access",
+ Constraints: &Constraints{
+ Fields: []Field{
+ {Id: to.Ptr("floor"), Path: []string{"$.credentialSubject.floor"}},
+ },
+ },
+ },
+ },
+ }
+ idA := ssi.MustParseURI("A")
+ idB := ssi.MustParseURI("B")
+ // JSON numbers unmarshal to float64 in Go
+ vcA := credentialToJSONLD(vc.VerifiableCredential{ID: &idA, CredentialSubject: []map[string]any{{"floor": float64(1)}}})
+ vcB := credentialToJSONLD(vc.VerifiableCredential{ID: &idB, CredentialSubject: []map[string]any{{"floor": float64(3)}}})
+
+ selector, err := NewFieldSelector(map[string]string{
+ "floor": "3",
+ }, numPD)
+ require.NoError(t, err)
+
+ result, err := selector(*numPD.InputDescriptors[0], []vc.VerifiableCredential{vcA, vcB})
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Equal(t, &idB, result.ID)
+ })
+ t.Run("unknown selection key returns construction error", func(t *testing.T) {
+ _, err := NewFieldSelector(map[string]string{
+ "nonexistent_field": "value",
+ }, pd)
+
+ assert.ErrorContains(t, err, "nonexistent_field")
+ })
+ t.Run("no selection keys for descriptor returns nil nil", func(t *testing.T) {
+ // Selection targets patient_credential, but we call with a different descriptor.
+ selector, err := NewFieldSelector(map[string]string{
+ "patient_id": "456",
+ }, pd)
+ require.NoError(t, err)
+
+ otherDescriptor := InputDescriptor{Id: "other_descriptor"}
+ result, err := selector(
+ otherDescriptor,
+ []vc.VerifiableCredential{vc1, vc2},
+ )
+
+ assert.NoError(t, err)
+ assert.Nil(t, result)
+ })
+ t.Run("multiple selection keys use AND semantics", func(t *testing.T) {
+ andPD := PresentationDefinition{
+ InputDescriptors: []*InputDescriptor{
+ {
+ Id: "enrollment",
+ Constraints: &Constraints{
+ Fields: []Field{
+ {Id: to.Ptr("patient_id"), Path: []string{"$.credentialSubject.patientId"}},
+ {Id: to.Ptr("org_city"), Path: []string{"$.credentialSubject.city"}},
+ },
+ },
+ },
+ },
+ }
+ // vc matching both criteria
+ idA := ssi.MustParseURI("A")
+ vcA := credentialToJSONLD(vc.VerifiableCredential{ID: &idA, CredentialSubject: []map[string]any{{"patientId": "123", "city": "Amsterdam"}}})
+ // vc matching only patient_id
+ idB := ssi.MustParseURI("B")
+ vcB := credentialToJSONLD(vc.VerifiableCredential{ID: &idB, CredentialSubject: []map[string]any{{"patientId": "123", "city": "Rotterdam"}}})
+
+ selector, err := NewFieldSelector(map[string]string{
+ "patient_id": "123",
+ "org_city": "Amsterdam",
+ }, andPD)
+ require.NoError(t, err)
+
+ result, err := selector(
+ *andPD.InputDescriptors[0],
+ []vc.VerifiableCredential{vcA, vcB},
+ )
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Equal(t, &idA, result.ID)
+ })
+ t.Run("multiple descriptors with independent selection keys", func(t *testing.T) {
+ multiPD := PresentationDefinition{
+ InputDescriptors: []*InputDescriptor{
+ {
+ Id: "org_credential",
+ Constraints: &Constraints{
+ Fields: []Field{
+ {Id: to.Ptr("ura"), Path: []string{"$.credentialSubject.ura"}},
+ },
+ },
+ },
+ {
+ Id: "patient_enrollment",
+ Constraints: &Constraints{
+ Fields: []Field{
+ {Id: to.Ptr("bsn"), Path: []string{"$.credentialSubject.bsn"}},
+ },
+ },
+ },
+ },
+ }
+ idA := ssi.MustParseURI("A")
+ idB := ssi.MustParseURI("B")
+ idC := ssi.MustParseURI("C")
+ idD := ssi.MustParseURI("D")
+ vcA := credentialToJSONLD(vc.VerifiableCredential{ID: &idA, CredentialSubject: []map[string]any{{"ura": "URA-001"}}})
+ vcB := credentialToJSONLD(vc.VerifiableCredential{ID: &idB, CredentialSubject: []map[string]any{{"ura": "URA-002"}}})
+ vcC := credentialToJSONLD(vc.VerifiableCredential{ID: &idC, CredentialSubject: []map[string]any{{"bsn": "BSN-111"}}})
+ vcD := credentialToJSONLD(vc.VerifiableCredential{ID: &idD, CredentialSubject: []map[string]any{{"bsn": "BSN-222"}}})
+
+ selector, err := NewFieldSelector(map[string]string{
+ "ura": "URA-002",
+ "bsn": "BSN-111",
+ }, multiPD)
+ require.NoError(t, err)
+
+ // First descriptor: selects vcB (URA-002)
+ result, err := selector(
+ *multiPD.InputDescriptors[0],
+ []vc.VerifiableCredential{vcA, vcB},
+ )
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Equal(t, &idB, result.ID)
+
+ // Second descriptor: selects vcC (BSN-111)
+ result, err = selector(
+ *multiPD.InputDescriptors[1],
+ []vc.VerifiableCredential{vcC, vcD},
+ )
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Equal(t, &idC, result.ID)
+ })
+}
diff --git a/vcr/pe/types.go b/vcr/pe/types.go
index 6224a4debe..56253ded78 100644
--- a/vcr/pe/types.go
+++ b/vcr/pe/types.go
@@ -22,6 +22,7 @@ package pe
import "errors"
var ErrNoCredentials = errors.New("missing credentials")
+var ErrMultipleCredentials = errors.New("multiple matching credentials")
// PresentationDefinitionClaimFormatDesignations (replaces generated one)
type PresentationDefinitionClaimFormatDesignations map[string]map[string][]string