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