From 8c7084d7efa9f234dcf791bb06c3870b88396847 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 25 Mar 2026 17:33:02 +0100 Subject: [PATCH 1/5] =?UTF-8?q?#4120:=20Selection=20CredentialSelector=20?= =?UTF-8?q?=E2=80=94=20filter=20candidates=20by=20PD=20field=20ID=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- vcr/pe/selector.go | 118 +++++++++++++++++++++ vcr/pe/selector_test.go | 220 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 vcr/pe/selector.go create mode 100644 vcr/pe/selector_test.go diff --git a/vcr/pe/selector.go b/vcr/pe/selector.go new file mode 100644 index 0000000000..8f472ce23a --- /dev/null +++ b/vcr/pe/selector.go @@ -0,0 +1,118 @@ +/* + * 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 +} + +// NewSelectionSelector creates a CredentialSelector that filters candidates using +// named field ID values from the credential_selection parameter. +func NewSelectionSelector(selection map[string]string, pd PresentationDefinition, fallback CredentialSelector) (CredentialSelector, error) { + descriptorSelections := make(map[string][]fieldSelection) + + 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, + }) + } + } + } + + // Validate all selection keys match at least one field ID in the PD. + for key := range selection { + found := false + for _, sels := range descriptorSelections { + for _, sel := range sels { + if sel.fieldID == key { + found = true + break + } + } + if found { + break + } + } + if !found { + 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 fallback(descriptor, candidates) + } + + var matched []vc.VerifiableCredential + for _, candidate := range candidates { + if descriptor.Constraints == nil { + continue + } + isMatch, values, err := matchConstraint(descriptor.Constraints, candidate) + if err != nil || !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 + } + if str, ok := resolved.(string); ok { + if str != sel.expected { + return false + } + } else if fmt.Sprintf("%v", resolved) != 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..f7ce6fb06e --- /dev/null +++ b/vcr/pe/selector_test.go @@ -0,0 +1,220 @@ +/* + * 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" +) + +// nopeSelector returns a fallback that fails the test if called. +func nopeSelector(t *testing.T) CredentialSelector { + return func(_ InputDescriptor, _ []vc.VerifiableCredential) (*vc.VerifiableCredential, error) { + t.Fatal("fallback selector should not be called") + return nil, nil + } +} + +func TestNewSelectionSelector(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 := NewSelectionSelector(map[string]string{ + "patient_id": "456", + }, pd, nopeSelector(t)) + 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 := NewSelectionSelector(map[string]string{ + "patient_id": "nonexistent", + }, pd, nopeSelector(t)) + 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 := NewSelectionSelector(map[string]string{ + "patient_id": "456", + }, pd, nopeSelector(t)) + 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 := NewSelectionSelector(map[string]string{ + "nonexistent_field": "value", + }, pd, FirstMatchSelector) + + assert.ErrorContains(t, err, "nonexistent_field") + }) + t.Run("no selection keys for descriptor falls back to default", func(t *testing.T) { + // Selection targets patient_credential, but we call with a different descriptor. + selector, err := NewSelectionSelector(map[string]string{ + "patient_id": "456", + }, pd, FirstMatchSelector) + require.NoError(t, err) + + otherDescriptor := InputDescriptor{Id: "other_descriptor"} + result, err := selector( + otherDescriptor, + []vc.VerifiableCredential{vc1, vc2}, + ) + + require.NoError(t, err) + require.NotNil(t, result) + // FirstMatchSelector picks vc1 + assert.Equal(t, &id1, result.ID) + }) + 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 := NewSelectionSelector(map[string]string{ + "patient_id": "123", + "org_city": "Amsterdam", + }, andPD, nopeSelector(t)) + 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 := NewSelectionSelector(map[string]string{ + "ura": "URA-002", + "bsn": "BSN-111", + }, multiPD, nopeSelector(t)) + 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) + }) +} From 4ec5a9aea0ad0695df6fbac71ff43327fbef48a2 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 25 Mar 2026 17:38:01 +0100 Subject: [PATCH 2/5] #4120: Simplify key validation with matchedKeys set Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/pe/selector.go | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/vcr/pe/selector.go b/vcr/pe/selector.go index 8f472ce23a..bf3febf34f 100644 --- a/vcr/pe/selector.go +++ b/vcr/pe/selector.go @@ -33,6 +33,7 @@ type fieldSelection struct { // named field ID values from the credential_selection parameter. func NewSelectionSelector(selection map[string]string, pd PresentationDefinition, fallback CredentialSelector) (CredentialSelector, error) { descriptorSelections := make(map[string][]fieldSelection) + matchedKeys := make(map[string]bool) for _, desc := range pd.InputDescriptors { if desc.Constraints == nil { @@ -47,25 +48,14 @@ func NewSelectionSelector(selection map[string]string, pd PresentationDefinition 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 { - found := false - for _, sels := range descriptorSelections { - for _, sel := range sels { - if sel.fieldID == key { - found = true - break - } - } - if found { - break - } - } - if !found { + if !matchedKeys[key] { return nil, fmt.Errorf("credential_selection key '%s' does not match any field id in the presentation definition", key) } } From 9d3681d5e579872f1b22b3a69629605dadaf1fe0 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 27 Mar 2026 08:26:54 +0100 Subject: [PATCH 3/5] =?UTF-8?q?-=20Rename=20NewSelectionSelector=20?= =?UTF-8?q?=E2=86=92=20NewFieldSelector=20for=20clarity=20-=20Return=20mat?= =?UTF-8?q?chConstraint=20errors=20instead=20of=20swallowing=20them=20-=20?= =?UTF-8?q?Simplify=20matchesSelections=20to=20assert=20string=20type=20di?= =?UTF-8?q?rectly=20-=20Add=20comment=20explaining=20constant-only=20match?= =?UTF-8?q?ing=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- vcr/pe/selector.go | 20 +++++++++++--------- vcr/pe/selector_test.go | 16 ++++++++-------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/vcr/pe/selector.go b/vcr/pe/selector.go index bf3febf34f..92113dc69c 100644 --- a/vcr/pe/selector.go +++ b/vcr/pe/selector.go @@ -29,9 +29,11 @@ type fieldSelection struct { expected string } -// NewSelectionSelector creates a CredentialSelector that filters candidates using -// named field ID values from the credential_selection parameter. -func NewSelectionSelector(selection map[string]string, pd PresentationDefinition, fallback CredentialSelector) (CredentialSelector, error) { +// 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. +func NewFieldSelector(selection map[string]string, pd PresentationDefinition, fallback CredentialSelector) (CredentialSelector, error) { descriptorSelections := make(map[string][]fieldSelection) matchedKeys := make(map[string]bool) @@ -72,7 +74,10 @@ func NewSelectionSelector(selection map[string]string, pd PresentationDefinition continue } isMatch, values, err := matchConstraint(descriptor.Constraints, candidate) - if err != nil || !isMatch { + if err != nil { + return nil, fmt.Errorf("input descriptor '%s': %w", descriptor.Id, err) + } + if !isMatch { continue } if matchesSelections(values, selections) { @@ -96,11 +101,8 @@ func matchesSelections(values map[string]interface{}, selections []fieldSelectio if !ok { return false } - if str, ok := resolved.(string); ok { - if str != sel.expected { - return false - } - } else if fmt.Sprintf("%v", resolved) != sel.expected { + str, ok := resolved.(string) + if !ok || str != sel.expected { return false } } diff --git a/vcr/pe/selector_test.go b/vcr/pe/selector_test.go index f7ce6fb06e..49196d7c9c 100644 --- a/vcr/pe/selector_test.go +++ b/vcr/pe/selector_test.go @@ -36,7 +36,7 @@ func nopeSelector(t *testing.T) CredentialSelector { } } -func TestNewSelectionSelector(t *testing.T) { +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"}}}) @@ -58,7 +58,7 @@ func TestNewSelectionSelector(t *testing.T) { } t.Run("selection picks the right credential by field value", func(t *testing.T) { - selector, err := NewSelectionSelector(map[string]string{ + selector, err := NewFieldSelector(map[string]string{ "patient_id": "456", }, pd, nopeSelector(t)) require.NoError(t, err) @@ -73,7 +73,7 @@ func TestNewSelectionSelector(t *testing.T) { assert.Equal(t, &id2, result.ID) }) t.Run("zero matches returns ErrNoCredentials", func(t *testing.T) { - selector, err := NewSelectionSelector(map[string]string{ + selector, err := NewFieldSelector(map[string]string{ "patient_id": "nonexistent", }, pd, nopeSelector(t)) require.NoError(t, err) @@ -90,7 +90,7 @@ func TestNewSelectionSelector(t *testing.T) { // without narrowing to one should fail. id3 := ssi.MustParseURI("3") vc3 := credentialToJSONLD(vc.VerifiableCredential{ID: &id3, CredentialSubject: []map[string]any{{"patientId": "456"}}}) - selector, err := NewSelectionSelector(map[string]string{ + selector, err := NewFieldSelector(map[string]string{ "patient_id": "456", }, pd, nopeSelector(t)) require.NoError(t, err) @@ -103,7 +103,7 @@ func TestNewSelectionSelector(t *testing.T) { assert.ErrorIs(t, err, ErrMultipleCredentials) }) t.Run("unknown selection key returns construction error", func(t *testing.T) { - _, err := NewSelectionSelector(map[string]string{ + _, err := NewFieldSelector(map[string]string{ "nonexistent_field": "value", }, pd, FirstMatchSelector) @@ -111,7 +111,7 @@ func TestNewSelectionSelector(t *testing.T) { }) t.Run("no selection keys for descriptor falls back to default", func(t *testing.T) { // Selection targets patient_credential, but we call with a different descriptor. - selector, err := NewSelectionSelector(map[string]string{ + selector, err := NewFieldSelector(map[string]string{ "patient_id": "456", }, pd, FirstMatchSelector) require.NoError(t, err) @@ -148,7 +148,7 @@ func TestNewSelectionSelector(t *testing.T) { idB := ssi.MustParseURI("B") vcB := credentialToJSONLD(vc.VerifiableCredential{ID: &idB, CredentialSubject: []map[string]any{{"patientId": "123", "city": "Rotterdam"}}}) - selector, err := NewSelectionSelector(map[string]string{ + selector, err := NewFieldSelector(map[string]string{ "patient_id": "123", "org_city": "Amsterdam", }, andPD, nopeSelector(t)) @@ -193,7 +193,7 @@ func TestNewSelectionSelector(t *testing.T) { 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 := NewSelectionSelector(map[string]string{ + selector, err := NewFieldSelector(map[string]string{ "ura": "URA-002", "bsn": "BSN-111", }, multiPD, nopeSelector(t)) From 6b62b2d9aefd3075e42e4d381fa6084e42871d6e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 27 Mar 2026 12:34:27 +0100 Subject: [PATCH 4/5] #4121: Remove fallback parameter from NewFieldSelector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- vcr/pe/presentation_definition.go | 8 +++++++- vcr/pe/selector.go | 6 ++++-- vcr/pe/selector_test.go | 30 ++++++++++-------------------- 3 files changed, 21 insertions(+), 23 deletions(-) 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 index 92113dc69c..5dcf0af07c 100644 --- a/vcr/pe/selector.go +++ b/vcr/pe/selector.go @@ -33,7 +33,9 @@ type fieldSelection struct { // 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. -func NewFieldSelector(selection map[string]string, pd PresentationDefinition, fallback CredentialSelector) (CredentialSelector, error) { +// 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) @@ -65,7 +67,7 @@ func NewFieldSelector(selection map[string]string, pd PresentationDefinition, fa return func(descriptor InputDescriptor, candidates []vc.VerifiableCredential) (*vc.VerifiableCredential, error) { selections, ok := descriptorSelections[descriptor.Id] if !ok { - return fallback(descriptor, candidates) + return nil, nil } var matched []vc.VerifiableCredential diff --git a/vcr/pe/selector_test.go b/vcr/pe/selector_test.go index 49196d7c9c..fff063be2a 100644 --- a/vcr/pe/selector_test.go +++ b/vcr/pe/selector_test.go @@ -28,14 +28,6 @@ import ( "github.com/stretchr/testify/require" ) -// nopeSelector returns a fallback that fails the test if called. -func nopeSelector(t *testing.T) CredentialSelector { - return func(_ InputDescriptor, _ []vc.VerifiableCredential) (*vc.VerifiableCredential, error) { - t.Fatal("fallback selector should not be called") - return nil, nil - } -} - func TestNewFieldSelector(t *testing.T) { id1 := ssi.MustParseURI("1") id2 := ssi.MustParseURI("2") @@ -60,7 +52,7 @@ func TestNewFieldSelector(t *testing.T) { t.Run("selection picks the right credential by field value", func(t *testing.T) { selector, err := NewFieldSelector(map[string]string{ "patient_id": "456", - }, pd, nopeSelector(t)) + }, pd) require.NoError(t, err) result, err := selector( @@ -75,7 +67,7 @@ func TestNewFieldSelector(t *testing.T) { t.Run("zero matches returns ErrNoCredentials", func(t *testing.T) { selector, err := NewFieldSelector(map[string]string{ "patient_id": "nonexistent", - }, pd, nopeSelector(t)) + }, pd) require.NoError(t, err) _, err = selector( @@ -92,7 +84,7 @@ func TestNewFieldSelector(t *testing.T) { vc3 := credentialToJSONLD(vc.VerifiableCredential{ID: &id3, CredentialSubject: []map[string]any{{"patientId": "456"}}}) selector, err := NewFieldSelector(map[string]string{ "patient_id": "456", - }, pd, nopeSelector(t)) + }, pd) require.NoError(t, err) _, err = selector( @@ -105,15 +97,15 @@ func TestNewFieldSelector(t *testing.T) { t.Run("unknown selection key returns construction error", func(t *testing.T) { _, err := NewFieldSelector(map[string]string{ "nonexistent_field": "value", - }, pd, FirstMatchSelector) + }, pd) assert.ErrorContains(t, err, "nonexistent_field") }) - t.Run("no selection keys for descriptor falls back to default", func(t *testing.T) { + 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, FirstMatchSelector) + }, pd) require.NoError(t, err) otherDescriptor := InputDescriptor{Id: "other_descriptor"} @@ -122,10 +114,8 @@ func TestNewFieldSelector(t *testing.T) { []vc.VerifiableCredential{vc1, vc2}, ) - require.NoError(t, err) - require.NotNil(t, result) - // FirstMatchSelector picks vc1 - assert.Equal(t, &id1, result.ID) + assert.NoError(t, err) + assert.Nil(t, result) }) t.Run("multiple selection keys use AND semantics", func(t *testing.T) { andPD := PresentationDefinition{ @@ -151,7 +141,7 @@ func TestNewFieldSelector(t *testing.T) { selector, err := NewFieldSelector(map[string]string{ "patient_id": "123", "org_city": "Amsterdam", - }, andPD, nopeSelector(t)) + }, andPD) require.NoError(t, err) result, err := selector( @@ -196,7 +186,7 @@ func TestNewFieldSelector(t *testing.T) { selector, err := NewFieldSelector(map[string]string{ "ura": "URA-002", "bsn": "BSN-111", - }, multiPD, nopeSelector(t)) + }, multiPD) require.NoError(t, err) // First descriptor: selects vcB (URA-002) From 80d8af66e43a8a9d3279d4015e2a04198e89b555 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 30 Mar 2026 15:02:05 +0000 Subject: [PATCH 5/5] #4090: Wire credential_selection through API to presenter (#4122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #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 +- 19 files changed, 237 insertions(+), 52 deletions(-) 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) {