From a9487a05d6dea26e4943707555cea73123eb1b3e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 24 Mar 2026 09:12:15 +0100 Subject: [PATCH 1/5] #4088: Extract CredentialSelector and refactor matchConstraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces CredentialSelector function type and FirstMatchSelector. Refactors matchConstraints to collect all matching VCs per input descriptor, then call the selector to pick one. Match delegates to MatchWithSelector with FirstMatchSelector. No behavior change — all existing tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/pe/presentation_definition.go | 54 ++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index df41b6907f..5b992a3488 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -49,6 +49,20 @@ func ParsePresentationDefinition(raw []byte) (*PresentationDefinition, error) { return &result, nil } +// 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. +// Returning nil indicates no credential was selected (input descriptor not fulfilled). +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 +} + // Candidate is a struct that holds the result of a match between an input descriptor and a VC // A non-matching VC also leads to a Candidate, but without a VC. type Candidate struct { @@ -74,14 +88,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 +156,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 +169,26 @@ 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 + selected, err := selector(*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 +225,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 } From 3c1e24040af66be9a459fe3b7e430539af16b20c Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 24 Mar 2026 09:16:30 +0100 Subject: [PATCH 2/5] #4088: Add SetCredentialSelector to PresentationSubmissionBuilder Adds SetCredentialSelector method to configure a custom CredentialSelector on the builder. Build passes it through to MatchWithSelector. Falls back to FirstMatchSelector when not set. Tests verify: custom selector picks non-first credential, default behavior preserved, selector receives all matching candidates. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/pe/presentation_submission.go | 15 +++++- vcr/pe/presentation_submission_test.go | 68 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) 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..0fcb446dc2 100644 --- a/vcr/pe/presentation_submission_test.go +++ b/vcr/pe/presentation_submission_test.go @@ -194,6 +194,74 @@ 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) + }) +} + func TestPresentationSubmission_Resolve(t *testing.T) { id1 := ssi.MustParseURI("1") id2 := ssi.MustParseURI("2") From 2834d58e1eac8409e2fe8cbb19bc57ad25a4f3b5 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 24 Mar 2026 09:26:45 +0100 Subject: [PATCH 3/5] #4088: Document CredentialSelector contract, add ErrMultipleCredentials CredentialSelector doc now describes return value semantics: when to return nil (unfulfilled), ErrNoCredentials (no match), or ErrMultipleCredentials (ambiguous). Adds error propagation test. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/pe/presentation_definition.go | 11 ++++++++++- vcr/pe/presentation_submission_test.go | 15 +++++++++++++++ vcr/pe/types.go | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 5b992a3488..4b7a202595 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -51,7 +51,16 @@ func ParsePresentationDefinition(raw []byte) (*PresentationDefinition, error) { // 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. -// Returning nil indicates no credential was selected (input descriptor not fulfilled). +// +// 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. +// - (nil, ErrMultipleCredentials): multiple candidates matched but the selector requires exactly one. +// +// Selectors that enforce strict matching should return ErrNoCredentials or ErrMultipleCredentials. +// 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. diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go index 0fcb446dc2..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" @@ -260,6 +261,20 @@ func TestPresentationSubmissionBuilder_SetCredentialSelector(t *testing.T) { 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) { 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 From 43860e3d0106ac96e54bb13f835cef7e22deee29 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 25 Mar 2026 17:04:00 +0100 Subject: [PATCH 4/5] #4088: Soft-fail ErrNoCredentials for submission requirements compatibility When a CredentialSelector returns ErrNoCredentials, treat it as a nil selection (unfulfilled descriptor) instead of a hard failure. This allows submission requirements with pick rules (e.g., min: 0) to evaluate whether zero fulfilled descriptors is acceptable. ErrMultipleCredentials and other errors remain hard failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/pe/presentation_definition.go | 15 +++++++++--- vcr/pe/presentation_definition_test.go | 34 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 4b7a202595..9d640439f7 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -56,10 +56,13 @@ func ParsePresentationDefinition(raw []byte) (*PresentationDefinition, error) { // - (*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. +// - (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 enforce strict matching should return ErrNoCredentials or ErrMultipleCredentials. // 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) @@ -184,7 +187,13 @@ func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.V // Use the selector to pick one credential from the candidates selected, err := selector(*inputDescriptor, matchingVCs) if err != nil { - return nil, err + 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 + } } candidates = append(candidates, Candidate{ InputDescriptor: *inputDescriptor, 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{} From cd32a176469b202ad4550fd2a51cfbf8a004e990 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 30 Mar 2026 15:03:10 +0000 Subject: [PATCH 5/5] =?UTF-8?q?#4120:=20Selection=20CredentialSelector=20?= =?UTF-8?q?=E2=80=94=20filter=20candidates=20by=20PD=20field=20ID=20values?= =?UTF-8?q?=20(#4121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #4120: Selection CredentialSelector — filter candidates by PD field ID values NewSelectionSelector creates a CredentialSelector that filters candidates using named field ID values from the credential_selection API parameter. Selection keys are validated against PD field IDs at construction time. Co-Authored-By: Claude Opus 4.6 (1M context) * #4120: Simplify key validation with matchedKeys set Co-Authored-By: Claude Opus 4.6 (1M context) * - Rename NewSelectionSelector → NewFieldSelector for clarity - Return matchConstraint errors instead of swallowing them - Simplify matchesSelections to assert string type directly - Add comment explaining constant-only matching design Co-Authored-By: Claude Opus 4.6 (1M context) * #4121: Remove fallback parameter from NewFieldSelector Selectors are not composable — default behavior belongs in the builder. NewFieldSelector now returns (nil, nil) for unmatched descriptors, and the builder falls back to FirstMatchSelector when a selector returns (nil, nil) with candidates available. Co-Authored-By: Claude Opus 4.6 (1M context) * #4090: Wire credential_selection through API to presenter (#4122) * #4090: RED — e2e test for credential_query selection Adds a credential_query test to the RFC021 e2e flow: - Issues two NutsOrganizationCredentials with different org names - Uses credential_query to select "Second Org B.V." by name - Verifies extended introspection contains the selected organization Test correctly fails: credential_query is currently ignored, so the default PD matcher picks "Caresoft B.V." instead of "Second Org B.V." Co-Authored-By: Claude Opus 4.6 (1M context) * #4090: Wire credential_query through API to presenter - OpenAPI spec: add credential_query to ServiceAccessTokenRequest - Regenerate types and mocks - API handler: extract credential_query, convert to dcql.CredentialQuery - Client.RequestRFC021AccessToken: accept credentialQueries parameter - Wallet.BuildSubmission: accept credentialQueries parameter - Presenter: create DCQL selector when credentialQueries is non-empty - All existing tests pass with nil for new parameter Co-Authored-By: Claude Opus 4.6 (1M context) * #4090: Wire credential_selection through API to presenter Replace credential_query (DCQL) with credential_selection (named parameters) throughout the callstack. The API accepts a simple map[string]string mapping PD field IDs to expected values, which is propagated through the IAM client, wallet, and presenter to NewSelectionSelector. Co-Authored-By: Claude Opus 4.6 (1M context) * #4090: Fix shellcheck issues, add presenter credential_selection test - Double-quote shell variables in e2e test to prevent globbing/splitting - Use single-quoted heredoc for JSON literal - Fix empty-response check to use proper test syntax - Add unit test for buildSubmission with credential_selection Co-Authored-By: Claude Opus 4.6 (1M context) * fixup: Use renamed NewFieldSelector in presenter Co-Authored-By: Claude Opus 4.6 (1M context) * #4122: Address review feedback - Capitalize "field id" → "field ID" in OpenAPI spec - Add godoc for credentials and credentialSelection parameters - Document default behavior when credential_selection is omitted - Add comment explaining FirstMatchSelector fallback - Add comment explaining nil credential_selection is safe (read-only) Co-Authored-By: Claude Opus 4.6 (1M context) * #4122: Remove fallback parameter from NewFieldSelector call The builder now handles the fallback to FirstMatchSelector when a selector returns (nil, nil), so the explicit fallback argument is no longer needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api.go | 9 +- auth/api/iam/api_test.go | 20 +- auth/api/iam/generated.go | 9 + auth/api/iam/openid4vp.go | 2 +- auth/api/iam/openid4vp_test.go | 4 +- auth/client/iam/interface.go | 4 +- auth/client/iam/mock.go | 8 +- auth/client/iam/openid4vp.go | 4 +- auth/client/iam/openid4vp_test.go | 34 +-- docs/_static/auth/v2.yaml | 17 ++ e2e-tests/browser/client/iam/generated.go | 9 + e2e-tests/oauth-flow/rfc021/do-test.sh | 70 ++++++ .../node-A/presentationexchangemapping.json | 1 + vcr/holder/interface.go | 4 +- vcr/holder/memory_wallet.go | 5 +- vcr/holder/mock.go | 8 +- vcr/holder/presenter.go | 11 +- vcr/holder/presenter_test.go | 65 +++++- vcr/holder/sql_wallet.go | 5 +- vcr/pe/presentation_definition.go | 8 +- vcr/pe/selector.go | 112 ++++++++++ vcr/pe/selector_test.go | 210 ++++++++++++++++++ 22 files changed, 566 insertions(+), 53 deletions(-) create mode 100644 vcr/pe/selector.go create mode 100644 vcr/pe/selector_test.go 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 9d640439f7..206257e02b 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -184,7 +184,8 @@ func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.V matchingVCs = append(matchingVCs, credential) } } - // Use the selector to pick one credential from the candidates + // 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) { @@ -194,6 +195,11 @@ func (presentationDefinition PresentationDefinition) matchConstraints(vcs []vc.V } 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, diff --git a/vcr/pe/selector.go b/vcr/pe/selector.go new file mode 100644 index 0000000000..5dcf0af07c --- /dev/null +++ b/vcr/pe/selector.go @@ -0,0 +1,112 @@ +/* + * 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" + + "github.com/nuts-foundation/go-did/vc" +) + +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 + } + str, ok := resolved.(string) + if !ok || 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..fff063be2a --- /dev/null +++ b/vcr/pe/selector_test.go @@ -0,0 +1,210 @@ +/* + * 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("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) + }) +}