From 81818a364cba8f9e0d33bc6c0b7705e1587c178b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:53:59 +0200 Subject: [PATCH 01/37] Initialize branch for PR From 8c7379108d89626db6ff9c98cb8c01c2f290d22e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 16:27:54 +0200 Subject: [PATCH 02/37] Load client PD block from policy config Adds the WalletOwnerClient constant and threads an optional `client` PresentationDefinition through the policy config loader. Consumers iterating WalletOwnerMapping see the new entry only when a profile declares a `client` block; existing org-only profiles are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- policy/local.go | 4 ++ policy/local_test.go | 17 ++++++ policy/test/client/with_org_client_user.json | 61 ++++++++++++++++++++ vcr/pe/policy.go | 3 + 4 files changed, 85 insertions(+) create mode 100644 policy/test/client/with_org_client_user.json diff --git a/policy/local.go b/policy/local.go index b8f6d02a04..f4e7e4971a 100644 --- a/policy/local.go +++ b/policy/local.go @@ -204,6 +204,7 @@ func (b *LocalPDP) loadFromFile(filename string) error { // credentialProfileConfig holds the configuration for a single credential profile. type credentialProfileConfig struct { Organization *validatingPresentationDefinition `json:"organization,omitempty"` + Client *validatingPresentationDefinition `json:"client,omitempty"` User *validatingPresentationDefinition `json:"user,omitempty"` ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` } @@ -213,6 +214,9 @@ func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { if c.Organization != nil { m[pe.WalletOwnerOrganization] = pe.PresentationDefinition(*c.Organization) } + if c.Client != nil { + m[pe.WalletOwnerClient] = pe.PresentationDefinition(*c.Client) + } if c.User != nil { m[pe.WalletOwnerUser] = pe.PresentationDefinition(*c.User) } diff --git a/policy/local_test.go b/policy/local_test.go index b25e931436..2306812fa5 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -21,6 +21,7 @@ package policy import ( "context" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/pe" "testing" "github.com/stretchr/testify/assert" @@ -128,6 +129,22 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { }) } +func TestLocalPDP_ClientPD(t *testing.T) { + t.Run("loads organization, client, and user PDs from a single profile", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/client/with_org_client_user.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), "example-scope") + require.NoError(t, err) + + assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerOrganization) + assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerClient) + assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerUser) + assert.Equal(t, "pd_client", match.WalletOwnerMapping[pe.WalletOwnerClient].Id) + }) +} + func TestLocalPDP_ScopePolicyConfig(t *testing.T) { t.Run("scope_policy parsed from config", func(t *testing.T) { store := LocalPDP{} diff --git a/policy/test/client/with_org_client_user.json b/policy/test/client/with_org_client_user.json new file mode 100644 index 0000000000..f9250305a0 --- /dev/null +++ b/policy/test/client/with_org_client_user.json @@ -0,0 +1,61 @@ +{ + "example-scope": { + "organization": { + "id": "pd_organization", + "input_descriptors": [ + { + "id": "id_org_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + } + ] + } + } + ] + }, + "client": { + "id": "pd_client", + "input_descriptors": [ + { + "id": "id_client_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "ServiceProviderCredential" + } + } + ] + } + } + ] + }, + "user": { + "id": "pd_user", + "input_descriptors": [ + { + "id": "id_user_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "EmployeeCredential" + } + } + ] + } + } + ] + } + } +} diff --git a/vcr/pe/policy.go b/vcr/pe/policy.go index 32d067320f..1d0811df1c 100644 --- a/vcr/pe/policy.go +++ b/vcr/pe/policy.go @@ -29,4 +29,7 @@ const ( WalletOwnerOrganization = WalletOwnerType("organization") // WalletOwnerUser is used in a WalletOwnerMapping when the PresentationDefinition is intended for a user WalletOwnerUser = WalletOwnerType("user") + // WalletOwnerClient is used in a WalletOwnerMapping when the PresentationDefinition is intended for an OAuth client + // (e.g. a service provider acting on behalf of an organization in the RFC 7523 jwt-bearer flow). + WalletOwnerClient = WalletOwnerType("client") ) From 97309961549cb699e179b319ae2a4b869911e48e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 16:29:26 +0200 Subject: [PATCH 03/37] Guard schema validation of the client PD block Locks the invariant that the client field uses the validating type, so a malformed client PD (e.g. missing input_descriptors) is rejected at load time with the same schema error as organization and user. Co-Authored-By: Claude Opus 4.7 (1M context) --- policy/local_test.go | 7 ++++++ policy/test/invalid/invalid_client_pd.json | 26 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 policy/test/invalid/invalid_client_pd.json diff --git a/policy/local_test.go b/policy/local_test.go index 2306812fa5..efc93bd781 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -143,6 +143,13 @@ func TestLocalPDP_ClientPD(t *testing.T) { assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerUser) assert.Equal(t, "pd_client", match.WalletOwnerMapping[pe.WalletOwnerClient].Id) }) + t.Run("malformed client PD is rejected with a schema-validation error", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/invalid/invalid_client_pd.json") + + assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") + }) } func TestLocalPDP_ScopePolicyConfig(t *testing.T) { diff --git a/policy/test/invalid/invalid_client_pd.json b/policy/test/invalid/invalid_client_pd.json new file mode 100644 index 0000000000..ac36c75a06 --- /dev/null +++ b/policy/test/invalid/invalid_client_pd.json @@ -0,0 +1,26 @@ +{ + "example-scope": { + "organization": { + "id": "pd_organization", + "input_descriptors": [ + { + "id": "id_org_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + } + ] + } + } + ] + }, + "client": { + "id": "pd_client_missing_input_descriptors" + } + } +} From 7f5d69eb3a39e4f31c74775675610dd9b270a9d7 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 16:30:05 +0200 Subject: [PATCH 04/37] Allow client-only credential profiles in policy config Relaxes the load-time check from "at least one of organization or user" to "at least one of organization, client, or user", so a profile that only configures the OAuth-client PD loads. The error message is updated to list all three valid blocks. Co-Authored-By: Claude Opus 4.7 (1M context) --- policy/local.go | 4 ++-- policy/local_test.go | 12 ++++++++++++ policy/test/client/client_only.json | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 policy/test/client/client_only.json diff --git a/policy/local.go b/policy/local.go index f4e7e4971a..fe5c1d0618 100644 --- a/policy/local.go +++ b/policy/local.go @@ -190,8 +190,8 @@ func (b *LocalPDP) loadFromFile(filename string) error { if profile.ScopePolicy == "" { profile.ScopePolicy = ScopePolicyProfileOnly } - if profile.Organization == nil && profile.User == nil { - return fmt.Errorf("credential profile %q must define at least one of 'organization' or 'user' (file=%s)", scope, filename) + if profile.Organization == nil && profile.Client == nil && profile.User == nil { + return fmt.Errorf("credential profile %q must define at least one of 'organization', 'client', or 'user' (file=%s)", scope, filename) } if !profile.ScopePolicy.valid() { return fmt.Errorf("invalid scope_policy %q for scope %q (file=%s)", profile.ScopePolicy, scope, filename) diff --git a/policy/local_test.go b/policy/local_test.go index efc93bd781..d6219161c9 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -150,6 +150,18 @@ func TestLocalPDP_ClientPD(t *testing.T) { assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") }) + t.Run("a profile with only a client PD loads", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/client/client_only.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), "client-only-scope") + require.NoError(t, err) + + assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerClient) + assert.NotContains(t, match.WalletOwnerMapping, pe.WalletOwnerOrganization) + assert.NotContains(t, match.WalletOwnerMapping, pe.WalletOwnerUser) + }) } func TestLocalPDP_ScopePolicyConfig(t *testing.T) { diff --git a/policy/test/client/client_only.json b/policy/test/client/client_only.json new file mode 100644 index 0000000000..149631a2ef --- /dev/null +++ b/policy/test/client/client_only.json @@ -0,0 +1,23 @@ +{ + "client-only-scope": { + "client": { + "id": "pd_client_only", + "input_descriptors": [ + { + "id": "id_client_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "ServiceProviderCredential" + } + } + ] + } + } + ] + } + } +} From f1c1054b6743bbd0fa65f5ac3e76d2ad07f3e8f4 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 16:50:53 +0200 Subject: [PATCH 05/37] Self-review fixes for client PD support - Fold the new client-PD subtests into TestStore_LoadFromFile so loading is exercised in one place, and assert WalletOwnerMapping contents instead of just length so an org-only profile no longer accidentally qualifies as a passing absence-of-client check. - Cover the new "no PD defined" validation branch with a dedicated fixture (invalid/no_pds.json), and tighten the malformed-client fixture to contain only a malformed client block so the schema error can only originate from that field. - Document the client block in the operator policy guide, including the RFC021 vs RFC 7523 audience caveat, and add a release-notes entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/pages/deployment/policy.rst | 6 +- docs/pages/release_notes.rst | 1 + policy/local_test.go | 86 ++++++++++++---------- policy/test/invalid/invalid_client_pd.json | 21 +----- policy/test/invalid/no_pds.json | 5 ++ 5 files changed, 59 insertions(+), 60 deletions(-) create mode 100644 policy/test/invalid/no_pds.json diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index d2abaa117e..f7164687e8 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -74,7 +74,11 @@ JSON documents used for policies must have the following structure: Where ``example_scope`` is the scope that the presentation definition is associated with. The ``presentation_definition`` object contains the presentation definition that should be used for the given scope. -The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization`` and ``user``. +The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization``, ``client`` and ``user``. + +The ``client`` block describes the credentials that an OAuth client (a service provider acting on behalf of a healthcare provider in the RFC 7523 ``jwt-bearer`` flow) must present. +It is consulted only when the node initiates an outbound RFC 7523 token request and is ignored when the node acts as a verifier in an RFC021 ``vp_token-bearer`` flow. +A profile may define any combination of ``organization``, ``client`` and ``user`` blocks; at least one is required. OAuth2 Token Introspection field mapping ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index b4bcede5f8..68478183d2 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -8,6 +8,7 @@ Unreleased ## New features * #4063: Enable ``storage.debug`` flag to log go-leia performance issues (full table scans, suboptimal index usage) by @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4064 +* #4078: Allow policy profiles to define a ``client`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 **************** Peanut (v6.2.1) diff --git a/policy/local_test.go b/policy/local_test.go index d6219161c9..ce4f06a4ba 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -29,14 +29,41 @@ import ( ) func TestStore_LoadFromFile(t *testing.T) { - t.Run("loads the mapping from the file", func(t *testing.T) { + t.Run("loads an organization-only profile and exposes it via WalletOwnerMapping", func(t *testing.T) { store := LocalPDP{} err := store.loadFromFile("test/definition_mapping.json") require.NoError(t, err) - assert.Len(t, store.mapping, 1) - assert.NotNil(t, store.mapping["example-scope"]) + require.Len(t, store.mapping, 1) + mapping := store.mapping["example-scope"].toWalletOwnerMapping() + assert.Contains(t, mapping, pe.WalletOwnerOrganization) + assert.NotContains(t, mapping, pe.WalletOwnerClient) + assert.NotContains(t, mapping, pe.WalletOwnerUser) + }) + + t.Run("loads organization, client, and user PDs from a single profile", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/client/with_org_client_user.json") + + require.NoError(t, err) + mapping := store.mapping["example-scope"].toWalletOwnerMapping() + assert.Equal(t, "pd_organization", mapping[pe.WalletOwnerOrganization].Id) + assert.Equal(t, "pd_client", mapping[pe.WalletOwnerClient].Id) + assert.Equal(t, "pd_user", mapping[pe.WalletOwnerUser].Id) + }) + + t.Run("loads a profile that defines only a client PD", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/client/client_only.json") + + require.NoError(t, err) + mapping := store.mapping["client-only-scope"].toWalletOwnerMapping() + assert.Equal(t, "pd_client_only", mapping[pe.WalletOwnerClient].Id) + assert.NotContains(t, mapping, pe.WalletOwnerOrganization) + assert.NotContains(t, mapping, pe.WalletOwnerUser) }) t.Run("returns an error if the file doesn't exist", func(t *testing.T) { @@ -47,13 +74,29 @@ func TestStore_LoadFromFile(t *testing.T) { assert.Error(t, err) }) - t.Run("returns an error if a presentation definition is invalid", func(t *testing.T) { + t.Run("returns an error if the organization PD is invalid", func(t *testing.T) { store := LocalPDP{} err := store.loadFromFile("test/invalid/invalid_definition_mapping.json") assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") }) + + t.Run("returns an error if the client PD is invalid", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/invalid/invalid_client_pd.json") + + assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") + }) + + t.Run("returns an error if no PD is defined for a profile", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/invalid/no_pds.json") + + assert.ErrorContains(t, err, "must define at least one of 'organization', 'client', or 'user'") + }) } func TestLocalPDP_FindCredentialProfile(t *testing.T) { @@ -129,41 +172,6 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { }) } -func TestLocalPDP_ClientPD(t *testing.T) { - t.Run("loads organization, client, and user PDs from a single profile", func(t *testing.T) { - store := LocalPDP{} - err := store.loadFromFile("test/client/with_org_client_user.json") - require.NoError(t, err) - - match, err := store.FindCredentialProfile(context.Background(), "example-scope") - require.NoError(t, err) - - assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerOrganization) - assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerClient) - assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerUser) - assert.Equal(t, "pd_client", match.WalletOwnerMapping[pe.WalletOwnerClient].Id) - }) - t.Run("malformed client PD is rejected with a schema-validation error", func(t *testing.T) { - store := LocalPDP{} - - err := store.loadFromFile("test/invalid/invalid_client_pd.json") - - assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") - }) - t.Run("a profile with only a client PD loads", func(t *testing.T) { - store := LocalPDP{} - err := store.loadFromFile("test/client/client_only.json") - require.NoError(t, err) - - match, err := store.FindCredentialProfile(context.Background(), "client-only-scope") - require.NoError(t, err) - - assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerClient) - assert.NotContains(t, match.WalletOwnerMapping, pe.WalletOwnerOrganization) - assert.NotContains(t, match.WalletOwnerMapping, pe.WalletOwnerUser) - }) -} - func TestLocalPDP_ScopePolicyConfig(t *testing.T) { t.Run("scope_policy parsed from config", func(t *testing.T) { store := LocalPDP{} diff --git a/policy/test/invalid/invalid_client_pd.json b/policy/test/invalid/invalid_client_pd.json index ac36c75a06..ec5915e570 100644 --- a/policy/test/invalid/invalid_client_pd.json +++ b/policy/test/invalid/invalid_client_pd.json @@ -1,24 +1,5 @@ { - "example-scope": { - "organization": { - "id": "pd_organization", - "input_descriptors": [ - { - "id": "id_org_cred", - "constraints": { - "fields": [ - { - "path": ["$.type"], - "filter": { - "type": "string", - "const": "NutsOrganizationCredential" - } - } - ] - } - } - ] - }, + "client-only-malformed-scope": { "client": { "id": "pd_client_missing_input_descriptors" } diff --git a/policy/test/invalid/no_pds.json b/policy/test/invalid/no_pds.json new file mode 100644 index 0000000000..177d82047c --- /dev/null +++ b/policy/test/invalid/no_pds.json @@ -0,0 +1,5 @@ +{ + "no-pd-scope": { + "scope_policy": "profile_only" + } +} From ab3c606e1bbab4f48ded778634ab9c8c02889176 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:20:41 +0200 Subject: [PATCH 06/37] Rename client wallet-owner type to service_provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original "client" name overloaded an already-busy term in this codebase (httpClient, authzenClient, the OAuth client_id form param) and required a comment to disambiguate. Switching to the domain term "service_provider" — the role this PD describes in the LSPxNuts delegation model — removes that ambiguity and matches the snake_case convention used by sibling compound keys (wallet_owner_type, scope_policy, auth.experimental.jwt_bearer_client). Renames the WalletOwnerClient constant, the credentialProfileConfig field, the JSON config key, the test fixtures and directory, the operator-doc paragraph, and the release-notes entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/pages/deployment/policy.rst | 6 ++--- docs/pages/release_notes.rst | 2 +- policy/local.go | 16 +++++++------- policy/local_test.go | 22 +++++++++---------- policy/test/invalid/invalid_client_pd.json | 7 ------ .../invalid/invalid_service_provider_pd.json | 7 ++++++ .../service_provider_only.json} | 8 +++---- .../with_org_sp_user.json} | 6 ++--- vcr/pe/policy.go | 6 ++--- 9 files changed, 40 insertions(+), 40 deletions(-) delete mode 100644 policy/test/invalid/invalid_client_pd.json create mode 100644 policy/test/invalid/invalid_service_provider_pd.json rename policy/test/{client/client_only.json => service_provider/service_provider_only.json} (72%) rename policy/test/{client/with_org_client_user.json => service_provider/with_org_sp_user.json} (93%) diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index f7164687e8..f3b8bb6b87 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -74,11 +74,11 @@ JSON documents used for policies must have the following structure: Where ``example_scope`` is the scope that the presentation definition is associated with. The ``presentation_definition`` object contains the presentation definition that should be used for the given scope. -The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization``, ``client`` and ``user``. +The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization``, ``service_provider`` and ``user``. -The ``client`` block describes the credentials that an OAuth client (a service provider acting on behalf of a healthcare provider in the RFC 7523 ``jwt-bearer`` flow) must present. +The ``service_provider`` block describes the credentials that a service provider acting on behalf of a healthcare provider (the OAuth client in the RFC 7523 ``jwt-bearer`` flow) must present. It is consulted only when the node initiates an outbound RFC 7523 token request and is ignored when the node acts as a verifier in an RFC021 ``vp_token-bearer`` flow. -A profile may define any combination of ``organization``, ``client`` and ``user`` blocks; at least one is required. +A profile may define any combination of ``organization``, ``service_provider`` and ``user`` blocks; at least one is required. OAuth2 Token Introspection field mapping ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index 68478183d2..7168d3f8e5 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -8,7 +8,7 @@ Unreleased ## New features * #4063: Enable ``storage.debug`` flag to log go-leia performance issues (full table scans, suboptimal index usage) by @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4064 -* #4078: Allow policy profiles to define a ``client`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 +* #4078: Allow policy profiles to define a ``service_provider`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 **************** Peanut (v6.2.1) diff --git a/policy/local.go b/policy/local.go index fe5c1d0618..7ea5abd549 100644 --- a/policy/local.go +++ b/policy/local.go @@ -190,8 +190,8 @@ func (b *LocalPDP) loadFromFile(filename string) error { if profile.ScopePolicy == "" { profile.ScopePolicy = ScopePolicyProfileOnly } - if profile.Organization == nil && profile.Client == nil && profile.User == nil { - return fmt.Errorf("credential profile %q must define at least one of 'organization', 'client', or 'user' (file=%s)", scope, filename) + if profile.Organization == nil && profile.ServiceProvider == nil && profile.User == nil { + return fmt.Errorf("credential profile %q must define at least one of 'organization', 'service_provider', or 'user' (file=%s)", scope, filename) } if !profile.ScopePolicy.valid() { return fmt.Errorf("invalid scope_policy %q for scope %q (file=%s)", profile.ScopePolicy, scope, filename) @@ -203,10 +203,10 @@ func (b *LocalPDP) loadFromFile(filename string) error { // credentialProfileConfig holds the configuration for a single credential profile. type credentialProfileConfig struct { - Organization *validatingPresentationDefinition `json:"organization,omitempty"` - Client *validatingPresentationDefinition `json:"client,omitempty"` - User *validatingPresentationDefinition `json:"user,omitempty"` - ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` + Organization *validatingPresentationDefinition `json:"organization,omitempty"` + ServiceProvider *validatingPresentationDefinition `json:"service_provider,omitempty"` + User *validatingPresentationDefinition `json:"user,omitempty"` + ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` } func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { @@ -214,8 +214,8 @@ func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { if c.Organization != nil { m[pe.WalletOwnerOrganization] = pe.PresentationDefinition(*c.Organization) } - if c.Client != nil { - m[pe.WalletOwnerClient] = pe.PresentationDefinition(*c.Client) + if c.ServiceProvider != nil { + m[pe.WalletOwnerServiceProvider] = pe.PresentationDefinition(*c.ServiceProvider) } if c.User != nil { m[pe.WalletOwnerUser] = pe.PresentationDefinition(*c.User) diff --git a/policy/local_test.go b/policy/local_test.go index ce4f06a4ba..139b12702d 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -38,30 +38,30 @@ func TestStore_LoadFromFile(t *testing.T) { require.Len(t, store.mapping, 1) mapping := store.mapping["example-scope"].toWalletOwnerMapping() assert.Contains(t, mapping, pe.WalletOwnerOrganization) - assert.NotContains(t, mapping, pe.WalletOwnerClient) + assert.NotContains(t, mapping, pe.WalletOwnerServiceProvider) assert.NotContains(t, mapping, pe.WalletOwnerUser) }) - t.Run("loads organization, client, and user PDs from a single profile", func(t *testing.T) { + t.Run("loads organization, service_provider, and user PDs from a single profile", func(t *testing.T) { store := LocalPDP{} - err := store.loadFromFile("test/client/with_org_client_user.json") + err := store.loadFromFile("test/service_provider/with_org_sp_user.json") require.NoError(t, err) mapping := store.mapping["example-scope"].toWalletOwnerMapping() assert.Equal(t, "pd_organization", mapping[pe.WalletOwnerOrganization].Id) - assert.Equal(t, "pd_client", mapping[pe.WalletOwnerClient].Id) + assert.Equal(t, "pd_service_provider", mapping[pe.WalletOwnerServiceProvider].Id) assert.Equal(t, "pd_user", mapping[pe.WalletOwnerUser].Id) }) - t.Run("loads a profile that defines only a client PD", func(t *testing.T) { + t.Run("loads a profile that defines only a service_provider PD", func(t *testing.T) { store := LocalPDP{} - err := store.loadFromFile("test/client/client_only.json") + err := store.loadFromFile("test/service_provider/service_provider_only.json") require.NoError(t, err) - mapping := store.mapping["client-only-scope"].toWalletOwnerMapping() - assert.Equal(t, "pd_client_only", mapping[pe.WalletOwnerClient].Id) + mapping := store.mapping["service-provider-only-scope"].toWalletOwnerMapping() + assert.Equal(t, "pd_service_provider_only", mapping[pe.WalletOwnerServiceProvider].Id) assert.NotContains(t, mapping, pe.WalletOwnerOrganization) assert.NotContains(t, mapping, pe.WalletOwnerUser) }) @@ -82,10 +82,10 @@ func TestStore_LoadFromFile(t *testing.T) { assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") }) - t.Run("returns an error if the client PD is invalid", func(t *testing.T) { + t.Run("returns an error if the service_provider PD is invalid", func(t *testing.T) { store := LocalPDP{} - err := store.loadFromFile("test/invalid/invalid_client_pd.json") + err := store.loadFromFile("test/invalid/invalid_service_provider_pd.json") assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") }) @@ -95,7 +95,7 @@ func TestStore_LoadFromFile(t *testing.T) { err := store.loadFromFile("test/invalid/no_pds.json") - assert.ErrorContains(t, err, "must define at least one of 'organization', 'client', or 'user'") + assert.ErrorContains(t, err, "must define at least one of 'organization', 'service_provider', or 'user'") }) } diff --git a/policy/test/invalid/invalid_client_pd.json b/policy/test/invalid/invalid_client_pd.json deleted file mode 100644 index ec5915e570..0000000000 --- a/policy/test/invalid/invalid_client_pd.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "client-only-malformed-scope": { - "client": { - "id": "pd_client_missing_input_descriptors" - } - } -} diff --git a/policy/test/invalid/invalid_service_provider_pd.json b/policy/test/invalid/invalid_service_provider_pd.json new file mode 100644 index 0000000000..5687658bcd --- /dev/null +++ b/policy/test/invalid/invalid_service_provider_pd.json @@ -0,0 +1,7 @@ +{ + "service-provider-malformed-scope": { + "service_provider": { + "id": "pd_service_provider_missing_input_descriptors" + } + } +} diff --git a/policy/test/client/client_only.json b/policy/test/service_provider/service_provider_only.json similarity index 72% rename from policy/test/client/client_only.json rename to policy/test/service_provider/service_provider_only.json index 149631a2ef..ae3c883ecf 100644 --- a/policy/test/client/client_only.json +++ b/policy/test/service_provider/service_provider_only.json @@ -1,10 +1,10 @@ { - "client-only-scope": { - "client": { - "id": "pd_client_only", + "service-provider-only-scope": { + "service_provider": { + "id": "pd_service_provider_only", "input_descriptors": [ { - "id": "id_client_cred", + "id": "id_sp_cred", "constraints": { "fields": [ { diff --git a/policy/test/client/with_org_client_user.json b/policy/test/service_provider/with_org_sp_user.json similarity index 93% rename from policy/test/client/with_org_client_user.json rename to policy/test/service_provider/with_org_sp_user.json index f9250305a0..35d00a0b2b 100644 --- a/policy/test/client/with_org_client_user.json +++ b/policy/test/service_provider/with_org_sp_user.json @@ -19,11 +19,11 @@ } ] }, - "client": { - "id": "pd_client", + "service_provider": { + "id": "pd_service_provider", "input_descriptors": [ { - "id": "id_client_cred", + "id": "id_sp_cred", "constraints": { "fields": [ { diff --git a/vcr/pe/policy.go b/vcr/pe/policy.go index 1d0811df1c..e3c18ef65f 100644 --- a/vcr/pe/policy.go +++ b/vcr/pe/policy.go @@ -29,7 +29,7 @@ const ( WalletOwnerOrganization = WalletOwnerType("organization") // WalletOwnerUser is used in a WalletOwnerMapping when the PresentationDefinition is intended for a user WalletOwnerUser = WalletOwnerType("user") - // WalletOwnerClient is used in a WalletOwnerMapping when the PresentationDefinition is intended for an OAuth client - // (e.g. a service provider acting on behalf of an organization in the RFC 7523 jwt-bearer flow). - WalletOwnerClient = WalletOwnerType("client") + // WalletOwnerServiceProvider is used in a WalletOwnerMapping when the PresentationDefinition is intended for a + // service provider acting on behalf of an organization (e.g. the OAuth client in the RFC 7523 jwt-bearer flow). + WalletOwnerServiceProvider = WalletOwnerType("service_provider") ) From 156ed6dcb0871e0c02a088bd89f4c26426803063 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:25:17 +0200 Subject: [PATCH 07/37] Reword service_provider doc to state when it applies, not when it doesn't Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/pages/deployment/policy.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index f3b8bb6b87..7285963463 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -77,7 +77,7 @@ The ``presentation_definition`` object contains the presentation definition that The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization``, ``service_provider`` and ``user``. The ``service_provider`` block describes the credentials that a service provider acting on behalf of a healthcare provider (the OAuth client in the RFC 7523 ``jwt-bearer`` flow) must present. -It is consulted only when the node initiates an outbound RFC 7523 token request and is ignored when the node acts as a verifier in an RFC021 ``vp_token-bearer`` flow. +It applies only to outbound RFC 7523 token requests initiated by the node. A profile may define any combination of ``organization``, ``service_provider`` and ``user`` blocks; at least one is required. OAuth2 Token Introspection field mapping From fe93b2ebf58123aa8aa33845bfc0c70dcf6d18d2 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:54:31 +0200 Subject: [PATCH 08/37] Initialize branch for PR From 7df21b3f3bb44618b75dc6fa0fcc309f2c90a467 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:34:54 +0200 Subject: [PATCH 09/37] Plumb serviceProviderSubjectID and the jwt-bearer feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends RequestRFC021AccessToken's signature with an optional serviceProviderSubjectID *string and registers the experimental auth.experimental.jwt_bearer_client config flag (default false). Mock and existing call sites updated to pass nil; behaviour is unchanged. The new parameter is currently unused — gate logic and the two-VP flow follow. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/api.go | 2 +- auth/api/iam/api_test.go | 18 +++++++++--------- auth/client/iam/interface.go | 4 +++- auth/client/iam/mock.go | 8 ++++---- auth/client/iam/openid4vp.go | 3 ++- auth/client/iam/openid4vp_test.go | 20 ++++++++++---------- auth/cmd/cmd.go | 5 +++++ auth/cmd/cmd_test.go | 1 + auth/config.go | 9 +++++++++ 9 files changed, 44 insertions(+), 26 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 6b8125bf02..32b64b7b9c 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -781,7 +781,7 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection) + tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, nil) 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 5424ab8e1d..89442584e8 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -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, nil).Return(response, nil).Times(2) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil).Return(&oauth.TokenResponse{}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, 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, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil).Return(nil, pe.ErrNoCredentials) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, 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) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil, nil).Return(response, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5ccf9caaf5..a9fc92417c 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -46,8 +46,10 @@ type Client interface { // 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. + // serviceProviderSubjectID, when non-nil, identifies a service-provider Nuts subject and triggers the RFC 7523 jwt-bearer two-VP flow + // (PSA 10.10). It is only honored when the experimental jwt-bearer client feature is enabled and the AS advertises jwt-bearer. RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, - credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) + credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *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 b6ad933a61..6481f2029c 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, credentialSelection map[string]string) (*oauth.TokenResponse, error) { +func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection) + ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) 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, credentialSelection any) *gomock.Call { +func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID 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, credentialSelection) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) } // VerifiableCredentials mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index c0b90ef73f..ad23fe7f52 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -243,7 +243,8 @@ 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, credentialSelection map[string]string) (*oauth.TokenResponse, error) { + useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { + _ = serviceProviderSubjectID // honored once two-VP logic is in place iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 68203d791d..f6ba41b693 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -253,7 +253,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.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(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -265,7 +265,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.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(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, 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, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrPreconditionFailed) @@ -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, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -326,7 +326,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.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(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -348,7 +348,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.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(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) var oauthErrResult oauth.OAuth2Error @@ -366,7 +366,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.True(t, errors.As(err, &oauth.OAuth2Error{})) @@ -376,7 +376,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.metadata = nil - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidClientCall) @@ -390,7 +390,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, _ = writer.Write([]byte("{")) } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrBadGateway) @@ -401,7 +401,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.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(), gomock.Any()).Return(nil, nil, assert.AnError) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.Error(t, err) }) diff --git a/auth/cmd/cmd.go b/auth/cmd/cmd.go index 5a2abf01e5..329adb0a7b 100644 --- a/auth/cmd/cmd.go +++ b/auth/cmd/cmd.go @@ -47,6 +47,9 @@ const ConfAccessTokenLifeSpan = "auth.accesstokenlifespan" // ConfAuthEndpointEnabled is the config key for enabling the Auth v2 API's Authorization Endpoint const ConfAuthEndpointEnabled = "auth.authorizationendpoint.enabled" +// ConfExperimentalJwtBearerClient toggles the RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). +const ConfExperimentalJwtBearerClient = "auth.experimental.jwt_bearer_client" + // FlagSet returns the configuration flags supported by this module. func FlagSet() *pflag.FlagSet { flags := pflag.NewFlagSet("auth", pflag.ContinueOnError) @@ -61,6 +64,8 @@ func FlagSet() *pflag.FlagSet { flags.StringSlice(ConfContractValidators, defs.ContractValidators, "sets the different contract validators to use") flags.Bool(ConfAuthEndpointEnabled, defs.AuthorizationEndpoint.Enabled, "enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. "+ "This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature.") + flags.Bool(ConfExperimentalJwtBearerClient, defs.Experimental.JwtBearerClient, "enables the experimental RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). "+ + "While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice.") _ = flags.MarkDeprecated("auth.http.timeout", "use httpclient.timeout instead") return flags diff --git a/auth/cmd/cmd_test.go b/auth/cmd/cmd_test.go index 5fe44f4c19..88514b3745 100644 --- a/auth/cmd/cmd_test.go +++ b/auth/cmd/cmd_test.go @@ -46,6 +46,7 @@ func TestFlagSet(t *testing.T) { ConfAuthEndpointEnabled, ConfClockSkew, ConfContractValidators, + ConfExperimentalJwtBearerClient, ConfHTTPTimeout, ConfAutoUpdateIrmaSchemas, ConfIrmaCorsOrigin, diff --git a/auth/config.go b/auth/config.go index 0f30bd3c95..ae787ebbbd 100644 --- a/auth/config.go +++ b/auth/config.go @@ -32,6 +32,15 @@ type Config struct { ContractValidators []string `koanf:"contractvalidators"` AccessTokenLifeSpan int `koanf:"accesstokenlifespan"` AuthorizationEndpoint AuthorizationEndpointConfig `koanf:"authorizationendpoint"` + Experimental ExperimentalConfig `koanf:"experimental"` +} + +// ExperimentalConfig groups feature flags for unstable functionality. +// Anything inside is subject to change without notice and may be removed in a future release. +type ExperimentalConfig struct { + // JwtBearerClient enables the RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). + // While disabled (the default), requests carrying a service-provider subject identifier are rejected. + JwtBearerClient bool `koanf:"jwt_bearer_client"` } type AuthorizationEndpointConfig struct { From cfc6d48893e177261df8ddd0e01b6343d3927396 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:36:35 +0200 Subject: [PATCH 10/37] Gate the two-VP flow behind the experimental flag When serviceProviderSubjectID is set but auth.experimental.jwt_bearer_client is false, the request is rejected immediately. The flag value is passed into OpenID4VPClient via NewClient so tests can exercise both states by setting the field directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/auth.go | 2 +- auth/client/iam/openid4vp.go | 41 ++++++++++++++++++------------- auth/client/iam/openid4vp_test.go | 18 ++++++++++++++ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index e869f35f87..57f7d72609 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -129,7 +129,7 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty { func (auth *Auth) IAMClient() iam.Client { keyResolver := resolver.DIDKeyResolver{Resolver: auth.vdrInstance.Resolver()} - return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout) + return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout, auth.config.Experimental.JwtBearerClient) } // Configure the Auth struct by creating a validator and create an Irma server diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index ad23fe7f52..c48a747f68 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -54,32 +54,37 @@ var ErrPreconditionFailed = errors.New("precondition failed") var _ Client = (*OpenID4VPClient)(nil) type OpenID4VPClient struct { - httpClient HTTPClient - jwtSigner nutsCrypto.JWTSigner - keyResolver resolver.KeyResolver - strictMode bool - wallet holder.Wallet - ldDocumentLoader ld.DocumentLoader - subjectManager didsubject.Manager - pdResolver PresentationDefinitionResolver + httpClient HTTPClient + jwtSigner nutsCrypto.JWTSigner + keyResolver resolver.KeyResolver + strictMode bool + wallet holder.Wallet + ldDocumentLoader ld.DocumentLoader + subjectManager didsubject.Manager + pdResolver PresentationDefinitionResolver + policyBackend policy.PDPBackend + experimentalJwtBearerClient bool } // NewClient returns an implementation of Holder func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectManager didsubject.Manager, jwtSigner nutsCrypto.JWTSigner, - ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { + ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration, + experimentalJwtBearerClient bool) *OpenID4VPClient { httpClient := HTTPClient{ strictMode: strictMode, httpClient: client.NewWithCache(httpClientTimeout), keyResolver: keyResolver, } client := &OpenID4VPClient{ - httpClient: httpClient, - keyResolver: keyResolver, - jwtSigner: jwtSigner, - ldDocumentLoader: ldDocumentLoader, - subjectManager: subjectManager, - strictMode: strictMode, - wallet: wallet, + httpClient: httpClient, + keyResolver: keyResolver, + jwtSigner: jwtSigner, + ldDocumentLoader: ldDocumentLoader, + subjectManager: subjectManager, + strictMode: strictMode, + wallet: wallet, + policyBackend: policyBackend, + experimentalJwtBearerClient: experimentalJwtBearerClient, } client.pdResolver = PresentationDefinitionResolver{ pdFetcher: client, @@ -244,7 +249,9 @@ 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, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { - _ = serviceProviderSubjectID // honored once two-VP logic is in place + if serviceProviderSubjectID != nil && !c.experimentalJwtBearerClient { + return nil, errors.New("jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true") + } iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index f6ba41b693..a3e61bc364 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -407,6 +407,24 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) } +func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { + const subjectID = "subby" + const subjectClientID = "https://example.com/oauth2/subby" + const spSubjectID = "service-provider-subby" + scopes := "first second" + + t.Run("rejects the request when the experimental jwt-bearer feature is disabled", func(t *testing.T) { + sp := spSubjectID + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = false + + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.Error(t, err) + assert.ErrorContains(t, err, "jwt-bearer") + }) +} + func TestIAMClient_RequestObjectByGet(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) From ef61c0f88173e3c6d779df6a8a916d22ee5c78fd Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:39:22 +0200 Subject: [PATCH 11/37] Reject two-VP requests when AS does not advertise jwt-bearer Adds the JwtBearerGrantType / JwtBearerClientAssertionType / client- assertion form-param constants and fails the request if the resolved authorization-server metadata does not list jwt-bearer in grant_types_supported. This is the no-silent-fallback rule from the PRD: when the caller has opted into the two-VP flow, an unsupported peer must produce a clear error rather than dropping back to RFC021. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 3 +++ auth/client/iam/openid4vp_test.go | 12 ++++++++++++ auth/oauth/types.go | 8 ++++++++ 3 files changed, 23 insertions(+) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index c48a747f68..1acacf2639 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -257,6 +257,9 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID if err != nil { return nil, err } + if serviceProviderSubjectID != nil && !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { + return nil, errors.New("authorization server does not advertise jwt-bearer support") + } // Resolve the presentation definition: from remote AS when available, local policy otherwise resolved, err := c.pdResolver.Resolve(ctx, scopes, *metadata) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index a3e61bc364..925c791d46 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -423,6 +423,18 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "jwt-bearer") }) + + t.Run("rejects the request when the AS does not advertise jwt-bearer", func(t *testing.T) { + sp := spSubjectID + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + // The default AS metadata in the test setup does not include JwtBearerGrantType in grant_types_supported. + + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.Error(t, err) + assert.ErrorContains(t, err, "authorization server does not advertise jwt-bearer") + }) } func TestIAMClient_RequestObjectByGet(t *testing.T) { diff --git a/auth/oauth/types.go b/auth/oauth/types.go index c0a6d769d2..72a8d6af7e 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -189,6 +189,10 @@ const ( ScopeParam = "scope" // StateParam is the parameter name for the state parameter. (RFC6749) StateParam = "state" + // ClientAssertionTypeParam is the parameter name for the client_assertion_type parameter. (RFC7521) + ClientAssertionTypeParam = "client_assertion_type" + // ClientAssertionParam is the parameter name for the client_assertion parameter. (RFC7521) + ClientAssertionParam = "client_assertion" // VpTokenParam is the parameter name for the vp_token parameter. (OpenID4VP) VpTokenParam = "vp_token" // WalletMetadataParam is used by the wallet to provide its metadata in an authorization request when RequestURIMethodParam is 'post' @@ -205,6 +209,10 @@ const ( PreAuthorizedCodeGrantType = "urn:ietf:params:oauth:grant-type:pre-authorized_code" // VpTokenGrantType is the grant_type for the vp_token-bearer grant type. (RFC021) VpTokenGrantType = "vp_token-bearer" + // JwtBearerGrantType is the grant_type for the RFC 7523 JWT bearer grant type, used by the LSPxNuts two-VP flow (PSA 10.10). + JwtBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" + // JwtBearerClientAssertionType is the client_assertion_type for the RFC 7523 JWT bearer client assertion. + JwtBearerClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" ) // response types From df974c97447fbc3f84cbf23498930f82b586521b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:41:37 +0200 Subject: [PATCH 12/37] Reject two-VP requests when no service_provider PD is configured Once the gate and AS-advertisement checks pass, the two-VP path looks up the credential profile from the local policy backend (the service_provider PD has no remote-endpoint equivalent) and fails loud when the resolved profile does not contain a service_provider PresentationDefinition. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 10 ++++++++++ auth/client/iam/openid4vp_test.go | 29 ++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 1acacf2639..3b66696404 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -260,6 +260,16 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID if serviceProviderSubjectID != nil && !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { return nil, errors.New("authorization server does not advertise jwt-bearer support") } + if serviceProviderSubjectID != nil { + match, err := c.policyBackend.FindCredentialProfile(ctx, scopes) + if err != nil { + return nil, fmt.Errorf("local PD resolution failed: %w", err) + } + if _, ok := match.WalletOwnerMapping[pe.WalletOwnerServiceProvider]; !ok { + return nil, fmt.Errorf("no service_provider presentation definition for scope %q", match.CredentialProfileScope) + } + _ = match // remaining two-VP construction follows in subsequent cycles + } // Resolve the presentation definition: from remote AS when available, local policy otherwise resolved, err := c.pdResolver.Resolve(ctx, scopes, *metadata) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 925c791d46..65fd27d55a 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -40,6 +40,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/policy" http2 "github.com/nuts-foundation/nuts-node/test/http" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -435,6 +436,24 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "authorization server does not advertise jwt-bearer") }) + + t.Run("rejects the request when no service_provider PD is configured for the scope", func(t *testing.T) { + sp := spSubjectID + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + }, + }, nil) + + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.Error(t, err) + assert.ErrorContains(t, err, "no service_provider presentation definition") + }) } func TestIAMClient_RequestObjectByGet(t *testing.T) { @@ -504,6 +523,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon keyResolver := resolver.NewMockKeyResolver(ctrl) subjectManager := didsubject.NewMockManager(ctrl) wallet := holder.NewMockWallet(ctrl) + policyBackend := policy.NewMockPDPBackend(ctrl) if tlsConfig == nil { tlsConfig = &tls.Config{} } @@ -516,10 +536,11 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon strictMode: false, httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), }, - jwtSigner: jwtSigner, - keyResolver: keyResolver, + jwtSigner: jwtSigner, + keyResolver: keyResolver, + policyBackend: policyBackend, } - testClient.pdResolver = PresentationDefinitionResolver{pdFetcher: testClient} + testClient.pdResolver = PresentationDefinitionResolver{pdFetcher: testClient, policyBackend: policyBackend} return &clientTestContext{ audit: audit.TestContext(), @@ -529,6 +550,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon keyResolver: keyResolver, wallet: wallet, subjectManager: subjectManager, + policyBackend: policyBackend, } } @@ -540,6 +562,7 @@ type clientTestContext struct { keyResolver *resolver.MockKeyResolver wallet *holder.MockWallet subjectManager *didsubject.MockManager + policyBackend *policy.MockPDPBackend } type clientServerTestContext struct { From ea4bbc3c652817e80359ae0480d685d3085d9c29 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:50:55 +0200 Subject: [PATCH 13/37] Build the jwt-bearer two-VP token request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When serviceProviderSubjectID is set, the request branches into a dedicated path that builds VP1 from the HCP wallet using the organization PD and VP2 from the SP wallet using the service_provider PD, and posts a form body with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, the two VPs as assertion and client_assertion, and no presentation_submission. Extracts filterDIDsByMethods so the new path and the existing single-VP path share the AS-method filtering instead of duplicating the loop. Cross-VP field-id binding is intentionally not yet wired — the next cycle adds the resolveInputDescriptorValues capture and the additive merge into credential_selection. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/interface.go | 4 +- auth/client/iam/openid4vp.go | 134 ++++++++++++++++++++++++------ auth/client/iam/openid4vp_test.go | 55 +++++++++++- auth/cmd/cmd.go | 4 +- auth/config.go | 2 +- auth/oauth/types.go | 2 +- 6 files changed, 164 insertions(+), 37 deletions(-) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index a9fc92417c..440d735863 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -46,8 +46,8 @@ type Client interface { // 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. - // serviceProviderSubjectID, when non-nil, identifies a service-provider Nuts subject and triggers the RFC 7523 jwt-bearer two-VP flow - // (PSA 10.10). It is only honored when the experimental jwt-bearer client feature is enabled and the AS advertises jwt-bearer. + // serviceProviderSubjectID, when non-nil, identifies a service-provider Nuts subject and triggers the RFC 7523 jwt-bearer two-VP flow. + // It is only honored when the experimental jwt-bearer client feature is enabled and the AS advertises jwt-bearer. RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 3b66696404..255723a8c6 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -261,14 +261,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return nil, errors.New("authorization server does not advertise jwt-bearer support") } if serviceProviderSubjectID != nil { - match, err := c.policyBackend.FindCredentialProfile(ctx, scopes) - if err != nil { - return nil, fmt.Errorf("local PD resolution failed: %w", err) - } - if _, ok := match.WalletOwnerMapping[pe.WalletOwnerServiceProvider]; !ok { - return nil, fmt.Errorf("no service_provider presentation definition for scope %q", match.CredentialProfileScope) - } - _ = match // remaining two-VP construction follows in subsequent cycles + return c.requestJwtBearerAccessToken(ctx, clientID, subjectID, *serviceProviderSubjectID, authServerURL, scopes, additionalCredentials, credentialSelection, metadata) } // Resolve the presentation definition: from remote AS when available, local policy otherwise @@ -291,25 +284,9 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return nil, err } - // in the s2s flow we use the metadata to determine the DID methods supported by the verifier - // filter walletDIDs on the DID methods supported by the verifier - j := 0 - allMethods := map[string]struct{}{} - for i, d := range subjectDIDs { - allMethods[d.Method] = struct{}{} - if slices.Contains(metadata.DIDMethodsSupported, d.Method) { - subjectDIDs[j] = subjectDIDs[i] - j++ - } - } - subjectDIDs = subjectDIDs[:j] - - if len(subjectDIDs) == 0 { - availableMethods := make([]string, 0, len(allMethods)) - for key := range maps.Keys(allMethods) { - availableMethods = append(availableMethods, key) - } - return nil, errors.Join(ErrPreconditionFailed, fmt.Errorf("did method mismatch, requested: %v, available: %v", metadata.DIDMethodsSupported, availableMethods)) + subjectDIDs, err = filterDIDsByMethods(subjectDIDs, metadata.DIDMethodsSupported) + if err != nil { + return nil, err } // each additional credential can be used by each DID @@ -369,6 +346,109 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return &tokenResponse, nil } +// requestJwtBearerAccessToken implements the RFC 7523 jwt-bearer two-VP token request flow. +// It builds VP1 from the HCP wallet (using the organization PD) and VP2 from the SP wallet (using the +// service_provider PD), assembles them as `assertion` and `client_assertion`, and POSTs the token request. +func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clientID string, subjectID string, serviceProviderSubjectID string, + authServerURL string, scopes string, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, + metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { + match, err := c.policyBackend.FindCredentialProfile(ctx, scopes) + if err != nil { + return nil, fmt.Errorf("local PD resolution failed: %w", err) + } + orgPD, hasOrg := match.WalletOwnerMapping[pe.WalletOwnerOrganization] + if !hasOrg { + return nil, fmt.Errorf("no organization presentation definition for scope %q", match.CredentialProfileScope) + } + spPD, hasSP := match.WalletOwnerMapping[pe.WalletOwnerServiceProvider] + if !hasSP { + return nil, fmt.Errorf("no service_provider presentation definition for scope %q", match.CredentialProfileScope) + } + params := holder.BuildParams{ + Audience: authServerURL, + DIDMethods: metadata.DIDMethodsSupported, + Expires: time.Now().Add(time.Second * 5), + Format: metadata.VPFormatsSupported, + Nonce: nutsCrypto.GenerateNonce(), + } + vp1, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + if err != nil { + return nil, err + } + vp2, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + if err != nil { + return nil, err + } + data := url.Values{} + data.Set(oauth.ClientIDParam, clientID) + data.Set(oauth.GrantTypeParam, oauth.JwtBearerGrantType) + data.Set(oauth.AssertionParam, vp1.Raw()) + data.Set(oauth.ClientAssertionTypeParam, oauth.JwtBearerClientAssertionType) + data.Set(oauth.ClientAssertionParam, vp2.Raw()) + data.Set(oauth.ScopeParam, scopes) + + log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n VP1: %s\n VP2: %s", metadata.TokenEndpoint, scopes, vp1.Raw(), vp2.Raw()) + token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, "") + if err != nil { + return nil, err + } + return &oauth.TokenResponse{ + AccessToken: token.AccessToken, + ExpiresIn: token.ExpiresIn, + TokenType: token.TokenType, + Scope: &scopes, + }, nil +} + +// buildSubmissionForSubject lists DIDs for the given subject, filters them to those whose method is supported +// by the AS, and asks the wallet to build a VP that fulfills the given PresentationDefinition. +func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subjectID string, presentationDefinition pe.PresentationDefinition, + additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, params holder.BuildParams, + supportedDIDMethods []string) (*vc.VerifiablePresentation, error) { + subjectDIDs, err := c.subjectManager.ListDIDs(ctx, subjectID) + if err != nil { + return nil, err + } + subjectDIDs, err = filterDIDsByMethods(subjectDIDs, supportedDIDMethods) + if err != nil { + return nil, err + } + additionalWalletCredentials := map[did.DID][]vc.VerifiableCredential{} + for _, subjectDID := range subjectDIDs { + for _, curr := range additionalCredentials { + additionalWalletCredentials[subjectDID] = append(additionalWalletCredentials[subjectDID], credential.AutoCorrectSelfAttestedCredential(curr, subjectDID)) + } + } + vp, _, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) + if err != nil { + return nil, err + } + return vp, nil +} + +// filterDIDsByMethods drops DIDs whose method is not in supportedMethods. Returns ErrPreconditionFailed when +// none of the subject's DIDs use a supported method. +func filterDIDsByMethods(subjectDIDs []did.DID, supportedMethods []string) ([]did.DID, error) { + j := 0 + allMethods := map[string]struct{}{} + for i, d := range subjectDIDs { + allMethods[d.Method] = struct{}{} + if slices.Contains(supportedMethods, d.Method) { + subjectDIDs[j] = subjectDIDs[i] + j++ + } + } + subjectDIDs = subjectDIDs[:j] + if len(subjectDIDs) == 0 { + availableMethods := make([]string, 0, len(allMethods)) + for key := range maps.Keys(allMethods) { + availableMethods = append(availableMethods, key) + } + return nil, errors.Join(ErrPreconditionFailed, fmt.Errorf("did method mismatch, requested: %v, available: %v", supportedMethods, availableMethods)) + } + return subjectDIDs, nil +} + func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) { iamClient := c.httpClient rsp, err := iamClient.OpenIdCredentialIssuerMetadata(ctx, oauthIssuerURI) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 65fd27d55a..3d5696661d 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -341,7 +341,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { } oauthErrorBytes, _ := json.Marshal(oauthError) ctx := createClientServerTestContext(t) - ctx.token = func(writer http.ResponseWriter) { + ctx.token = func(writer http.ResponseWriter, _ *http.Request) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusBadRequest) _, _ = writer.Write(oauthErrorBytes) @@ -454,6 +454,53 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "no service_provider presentation definition") }) + + t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) { + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + vp1, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + vp2, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // VP1 is built from the HCP wallet using the organization PD; VP2 from the SP wallet using the service_provider PD. + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(vp1, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), + pe.PresentationDefinition{Id: "sp_pd"}, gomock.Any(), gomock.Any()).Return(vp2, &pe.PresentationSubmission{}, nil) + + var capturedForm url.Values + ctx.token = func(writer http.ResponseWriter, request *http.Request) { + require.NoError(t, request.ParseForm()) + capturedForm = request.PostForm + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) + } + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, oauth.JwtBearerGrantType, capturedForm.Get(oauth.GrantTypeParam)) + assert.Equal(t, vp1.Raw(), capturedForm.Get(oauth.AssertionParam)) + assert.Equal(t, oauth.JwtBearerClientAssertionType, capturedForm.Get(oauth.ClientAssertionTypeParam)) + assert.Equal(t, vp2.Raw(), capturedForm.Get(oauth.ClientAssertionParam)) + assert.Empty(t, capturedForm.Get(oauth.PresentationSubmissionParam)) + }) } func TestIAMClient_RequestObjectByGet(t *testing.T) { @@ -579,7 +626,7 @@ type clientServerTestContext struct { credentialIssuerMetadata func(writer http.ResponseWriter) presentationDefinition func(writer http.ResponseWriter) response func(writer http.ResponseWriter) - token func(writer http.ResponseWriter) + token func(writer http.ResponseWriter, request *http.Request) credentials func(writer http.ResponseWriter) requestObjectJWT func(writer http.ResponseWriter) } @@ -628,7 +675,7 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { _, _ = writer.Write(bytes) return }, - token: func(writer http.ResponseWriter) { + token: func(writer http.ResponseWriter, _ *http.Request) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) @@ -681,7 +728,7 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { } case "/token": if ctx.token != nil { - ctx.token(writer) + ctx.token(writer, request) return } case "/credentials": diff --git a/auth/cmd/cmd.go b/auth/cmd/cmd.go index 329adb0a7b..137a019f9d 100644 --- a/auth/cmd/cmd.go +++ b/auth/cmd/cmd.go @@ -47,7 +47,7 @@ const ConfAccessTokenLifeSpan = "auth.accesstokenlifespan" // ConfAuthEndpointEnabled is the config key for enabling the Auth v2 API's Authorization Endpoint const ConfAuthEndpointEnabled = "auth.authorizationendpoint.enabled" -// ConfExperimentalJwtBearerClient toggles the RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). +// ConfExperimentalJwtBearerClient toggles the RFC 7523 jwt-bearer two-VP token request flow. const ConfExperimentalJwtBearerClient = "auth.experimental.jwt_bearer_client" // FlagSet returns the configuration flags supported by this module. @@ -64,7 +64,7 @@ func FlagSet() *pflag.FlagSet { flags.StringSlice(ConfContractValidators, defs.ContractValidators, "sets the different contract validators to use") flags.Bool(ConfAuthEndpointEnabled, defs.AuthorizationEndpoint.Enabled, "enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. "+ "This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature.") - flags.Bool(ConfExperimentalJwtBearerClient, defs.Experimental.JwtBearerClient, "enables the experimental RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). "+ + flags.Bool(ConfExperimentalJwtBearerClient, defs.Experimental.JwtBearerClient, "enables the experimental RFC 7523 jwt-bearer two-VP token request flow. "+ "While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice.") _ = flags.MarkDeprecated("auth.http.timeout", "use httpclient.timeout instead") diff --git a/auth/config.go b/auth/config.go index ae787ebbbd..0b8387837d 100644 --- a/auth/config.go +++ b/auth/config.go @@ -38,7 +38,7 @@ type Config struct { // ExperimentalConfig groups feature flags for unstable functionality. // Anything inside is subject to change without notice and may be removed in a future release. type ExperimentalConfig struct { - // JwtBearerClient enables the RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). + // JwtBearerClient enables the RFC 7523 jwt-bearer two-VP token request flow. // While disabled (the default), requests carrying a service-provider subject identifier are rejected. JwtBearerClient bool `koanf:"jwt_bearer_client"` } diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 72a8d6af7e..e08b62f251 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -209,7 +209,7 @@ const ( PreAuthorizedCodeGrantType = "urn:ietf:params:oauth:grant-type:pre-authorized_code" // VpTokenGrantType is the grant_type for the vp_token-bearer grant type. (RFC021) VpTokenGrantType = "vp_token-bearer" - // JwtBearerGrantType is the grant_type for the RFC 7523 JWT bearer grant type, used by the LSPxNuts two-VP flow (PSA 10.10). + // JwtBearerGrantType is the grant_type for the RFC 7523 JWT bearer grant type. JwtBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" // JwtBearerClientAssertionType is the client_assertion_type for the RFC 7523 JWT bearer client assertion. JwtBearerClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" From 6fe03d598538f5618d9e3e635432af1b00b4b5c5 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 18:00:50 +0200 Subject: [PATCH 14/37] Capture VP1 field-id values into VP2 credential_selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After VP1 is built, resolve the organization PD's id-bearing constraint fields against the submitted credentials and additively merge the resulting field-id → value pairs into the credential_selection map passed to VP2. Existing keys (EHR-supplied) are not overwritten; non-string field values are skipped. This realizes the cross-VP binding that lets a service_provider PD constrain VP2 by values matched in VP1 (for example, a delegating-HCP issuer DID flowing from VP1 into VP2's credential selection) without introducing any non-standard PE features. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 50 +++++++++++++--- auth/client/iam/openid4vp_test.go | 95 +++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 8 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 255723a8c6..a3b7ff446b 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -371,11 +371,17 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clien Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } - vp1, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + vp1, vp1Submission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) if err != nil { return nil, err } - vp2, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + // Capture id-bearing constraint field values resolved against VP1 and additively merge them into the + // credential_selection map for VP2. EHR-supplied keys take precedence over captured values. + credentialSelection, err = mergeCapturedFields(credentialSelection, vp1, vp1Submission, orgPD) + if err != nil { + return nil, err + } + vp2, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) if err != nil { return nil, err } @@ -404,14 +410,14 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clien // by the AS, and asks the wallet to build a VP that fulfills the given PresentationDefinition. func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subjectID string, presentationDefinition pe.PresentationDefinition, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, params holder.BuildParams, - supportedDIDMethods []string) (*vc.VerifiablePresentation, error) { + supportedDIDMethods []string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { subjectDIDs, err := c.subjectManager.ListDIDs(ctx, subjectID) if err != nil { - return nil, err + return nil, nil, err } subjectDIDs, err = filterDIDsByMethods(subjectDIDs, supportedDIDMethods) if err != nil { - return nil, err + return nil, nil, err } additionalWalletCredentials := map[did.DID][]vc.VerifiableCredential{} for _, subjectDID := range subjectDIDs { @@ -419,11 +425,39 @@ func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subject additionalWalletCredentials[subjectDID] = append(additionalWalletCredentials[subjectDID], credential.AutoCorrectSelfAttestedCredential(curr, subjectDID)) } } - vp, _, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) + return c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) +} + +// mergeCapturedFields resolves the id-bearing constraint fields of definition against vp's submitted credentials +// and adds the resulting field-id → value pairs to selection. Existing keys in selection are not overwritten. +// Non-string values are skipped (selection is map[string]string). +func mergeCapturedFields(selection map[string]string, vp *vc.VerifiablePresentation, submission *pe.PresentationSubmission, definition pe.PresentationDefinition) (map[string]string, error) { + envelope, err := pe.ParseEnvelope([]byte(vp.Raw())) if err != nil { - return nil, err + return nil, fmt.Errorf("parse VP envelope for cross-VP binding: %w", err) + } + credentialMap, err := submission.Resolve(*envelope) + if err != nil { + return nil, fmt.Errorf("resolve VP submission for cross-VP binding: %w", err) + } + captured, err := definition.ResolveConstraintsFields(credentialMap) + if err != nil { + return nil, fmt.Errorf("resolve constraint fields for cross-VP binding: %w", err) + } + if selection == nil { + selection = map[string]string{} + } + for k, v := range captured { + if _, exists := selection[k]; exists { + continue + } + s, ok := v.(string) + if !ok { + continue + } + selection[k] = s } - return vp, nil + return selection, nil } // filterDIDsByMethods drops DIDs whose method is not in supportedMethods. Returns ErrPreconditionFailed when diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 3d5696661d..2c45095fef 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -39,6 +39,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/core/to" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/policy" http2 "github.com/nuts-foundation/nuts-node/test/http" @@ -455,6 +456,100 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { assert.ErrorContains(t, err, "no service_provider presentation definition") }) + t.Run("captures shared field.id values from VP1 into VP2 credential_selection", func(t *testing.T) { + // Cross-VP binding scenario: when the organization PD and the service_provider PD share the same + // constraint-field `id` (here: "delegating_hcp"), the value matched in VP1 must flow into VP2's + // credential_selection so the wallet building VP2 can pick a credential constrained by that value. + // In this test we follow only the capture-and-merge step end-to-end: assert that VP2's BuildSubmission + // receives credential_selection["delegating_hcp"] = "did:test:hcp", the issuer of VP1's credential. + + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + + // VP1 is a minimal JSON-LD presentation containing one credential whose $.issuer is the HCP DID. + // The presentation needs enough structure for ParseEnvelope + submission.Resolve to walk the JSONPath + // "$.verifiableCredential[0]" and return the embedded credential. + vp1Raw := `{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiablePresentation"], + "verifiableCredential": [{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential"], + "issuer": "did:test:hcp", + "credentialSubject": {"id": "did:test:hcp"}, + "proof": {"type": "JsonWebSignature2020"} + }], + "proof": {"type": "JsonWebSignature2020"} + }` + vp1, err := vc.ParseVerifiablePresentation(vp1Raw) + require.NoError(t, err) + // VP2's body is irrelevant for this test — it never gets parsed because we're only asserting on the + // arguments passed to its BuildSubmission call. + vp2, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + + // VP1's submission tells the resolver that input descriptor "id_org_cred" was satisfied by the first + // credential in the verifiableCredential array of VP1. + vp1Submission := &pe.PresentationSubmission{ + DescriptorMap: []pe.InputDescriptorMappingObject{ + {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, + }, + } + + // orgPD declares one constraint field with id "delegating_hcp" pointing at $.issuer of the matched VC. + // That id is what makes the value capturable; without an id the field would be ignored. + orgPD := pe.PresentationDefinition{ + Id: "org_pd", + InputDescriptors: []*pe.InputDescriptor{{ + Id: "id_org_cred", + Constraints: &pe.Constraints{ + Fields: []pe.Field{{Id: to.Ptr("delegating_hcp"), Path: []string{"$.issuer"}}}, + }, + }}, + } + // spPD shares the field.id "delegating_hcp" by convention. The sharing is what binds VP2 to a value + // matched in VP1 — this PR's job is to plumb that value into credential_selection. The PD itself + // carries no constraints in this test because the wallet mock never inspects it. + spPD := pe.PresentationDefinition{Id: "sp_pd"} + + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + // Advertise jwt-bearer so the gates pass and the two-VP path runs. + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + // The two-VP path resolves both PDs from the local policy backend rather than the AS's PD endpoint. + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: orgPD, + pe.WalletOwnerServiceProvider: spPD, + }, + }, nil) + // VP1 is built from the HCP wallet, VP2 from the SP wallet — different subject IDs, different DIDs. + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // First call: build VP1 against the HCP DID using orgPD. Returns the JSON-LD VP and its submission so + // the production code can resolve constraint fields against them. + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + orgPD, gomock.Any(), gomock.Any()).Return(vp1, vp1Submission, nil) + // Second call: build VP2 against the SP DID using spPD. We capture the credential_selection argument + // so the test can assert the merged field-id value made it through. + var capturedSPSelection map[string]string + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), + spPD, gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, sel map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + capturedSPSelection = sel + return vp2, &pe.PresentationSubmission{}, nil + }) + + _, err = ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + // The HCP DID matched against $.issuer in VP1's credential should be captured under "delegating_hcp" + // and forwarded to VP2's wallet — this is the cross-VP binding payoff. + assert.Equal(t, "did:test:hcp", capturedSPSelection["delegating_hcp"]) + }) + t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") From 2ede2b6fe40fe5fa485bef6a115f292e060068c3 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 11:12:26 +0200 Subject: [PATCH 15/37] Add PresentationSubmission.ResolveVP for single-VP callers Resolve currently requires the caller to wrap a Verifiable Presentation in an Envelope, which forces callers that already hold a parsed VP in memory to round-trip through Raw() + ParseEnvelope just to drop the VP back into a struct so its private asInterface field can be used for JSONPath traversal. ResolveVP wraps the conversion: it computes asInterface from the VP directly and delegates to Resolve. parseJSONObjectOrStringEnvelope is refactored to share the same vpAsInterface helper so the JWT-vs-JSON-LD extraction logic stays in one place. Co-Authored-By: Claude Opus 4.7 (1M context) --- vcr/pe/presentation_submission.go | 14 +++++++++++ vcr/pe/presentation_submission_test.go | 33 ++++++++++++++++++++++++++ vcr/pe/util.go | 29 +++++++++++++++------- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go index fcd011152f..634004e5d5 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -163,6 +163,20 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis return presentationSubmission, signInstruction, nil } +// ResolveVP is a convenience wrapper around Resolve for callers that already hold a single parsed +// VerifiablePresentation in memory (e.g. a freshly built submission from the wallet) and would otherwise +// need to round-trip through Raw() + ParseEnvelope just to call Resolve. +func (s PresentationSubmission) ResolveVP(presentation vc.VerifiablePresentation) (map[string]vc.VerifiableCredential, error) { + asInterface, err := vpAsInterface(presentation) + if err != nil { + return nil, err + } + return s.Resolve(Envelope{ + Presentations: []vc.VerifiablePresentation{presentation}, + asInterface: asInterface, + }) +} + // Resolve returns a map where each of the input descriptors is mapped to the corresponding VerifiableCredential. // If an input descriptor can't be mapped to a VC, an error is returned. // This function is specified by https://identity.foundation/presentation-exchange/#processing-of-submission-entries diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go index 614777ff0c..962369fcf4 100644 --- a/vcr/pe/presentation_submission_test.go +++ b/vcr/pe/presentation_submission_test.go @@ -277,6 +277,39 @@ func TestPresentationSubmissionBuilder_SetCredentialSelector(t *testing.T) { }) } +func TestPresentationSubmission_ResolveVP(t *testing.T) { + t.Run("resolves descriptors against a single parsed VP without round-tripping through Envelope", func(t *testing.T) { + // Caller already has a *VerifiablePresentation in memory (e.g. just built by the wallet); ResolveVP + // must produce the same descriptor → credential mapping that Resolve(envelope) would. + vpRaw := `{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiablePresentation"], + "verifiableCredential": [{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential"], + "id": "urn:vc:42", + "issuer": "did:test:issuer", + "credentialSubject": {"id": "did:test:subject"}, + "proof": {"type": "JsonWebSignature2020"} + }], + "proof": {"type": "JsonWebSignature2020"} + }` + presentation, err := vc.ParseVerifiablePresentation(vpRaw) + require.NoError(t, err) + submission := PresentationSubmission{ + DescriptorMap: []InputDescriptorMappingObject{ + {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, + }, + } + + credentials, err := submission.ResolveVP(*presentation) + + require.NoError(t, err) + require.Contains(t, credentials, "id_org_cred") + assert.Equal(t, "urn:vc:42", credentials["id_org_cred"].ID.String()) + }) +} + func TestPresentationSubmission_Resolve(t *testing.T) { id1 := ssi.MustParseURI("1") id2 := ssi.MustParseURI("2") diff --git a/vcr/pe/util.go b/vcr/pe/util.go index 75ced72c07..024a1d57e3 100644 --- a/vcr/pe/util.go +++ b/vcr/pe/util.go @@ -129,13 +129,24 @@ func parseJSONObjectOrStringEnvelope(envelopeBytes []byte) (interface{}, *vc.Ver if err != nil { return nil, nil, fmt.Errorf("unable to parse PEX envelope as verifiable presentation: %w", err) } - // TODO: This should be part of go-did library; we need to decode a JWT VP (and maybe later VC) and get the properties - // (in this case as map) without losing original cardinality. - // Part of https://github.com/nuts-foundation/go-did/issues/85 + asInterface, err := vpAsInterface(*presentation) + if err != nil { + return nil, nil, err + } + return asInterface, presentation, nil +} + +// vpAsInterface returns the JSON-as-interface representation of vp suitable for jsonpath traversal, +// extracting the inner "vp" claim for JWT presentations so callers see the same shape regardless of format. +// TODO: This should be part of go-did library; we need to decode a JWT VP (and maybe later VC) and get the +// properties (in this case as map) without losing original cardinality. +// Part of https://github.com/nuts-foundation/go-did/issues/85 +func vpAsInterface(presentation vc.VerifiablePresentation) (interface{}, error) { + raw := []byte(presentation.Raw()) if presentation.Format() == vc.JWTPresentationProofFormat { - token, err := jwt.Parse(envelopeBytes, jwt.WithVerify(false), jwt.WithValidate(false)) + token, err := jwt.Parse(raw, jwt.WithVerify(false), jwt.WithValidate(false)) if err != nil { - return nil, nil, fmt.Errorf("unable to parse PEX envelope as JWT verifiable presentation: %w", err) + return nil, fmt.Errorf("unable to parse PEX envelope as JWT verifiable presentation: %w", err) } asMap := make(map[string]interface{}) // use the 'vp' claim as base Verifiable Presentation properties @@ -146,15 +157,15 @@ func parseJSONObjectOrStringEnvelope(envelopeBytes []byte) (interface{}, *vc.Ver if jti, ok := token.Get(jwt.JwtIDKey); ok { asMap["id"] = jti } - return asMap, presentation, nil + return asMap, nil } // For other formats, we can just parse the JSON to get the interface{} for JSON Path to work on var asMap interface{} - if err := json.Unmarshal(envelopeBytes, &asMap); err != nil { + if err := json.Unmarshal(raw, &asMap); err != nil { // Can't actually fail? - return nil, nil, err + return nil, err } - return asMap, presentation, nil + return asMap, nil } // tryParseJSONArray tries to parse the given bytes as a JSON array. From a0d28ea6128b786ccac7366090c503712af4de5a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 11:12:58 +0200 Subject: [PATCH 16/37] Tighten the two-VP path: ResolveVP, params.DIDMethods, no client_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the parse-envelope + submission.Resolve dance with PresentationSubmission.ResolveVP, since we already hold the parsed VP1 in memory after the wallet builds it. - Drop the duplicated supportedDIDMethods argument on buildSubmissionForSubject; the same value is already carried on holder.BuildParams.DIDMethods. - Rename the FindCredentialProfile result variable from match to profile — what we hold is the credential profile, not the act of matching it. - Stop setting the OAuth client_id form parameter in the jwt-bearer path. Per RFC 7521 §4.2 the client_assertion authenticates the client, so client_id is optional; including it would force the API layer to plumb a service-provider base URL through purely for the form field. - Drop the cross-VP orchestration tests; the helper they exercised (mergeCapturedFields) has been replaced by the smaller applyCapturedFieldsToSelection, unit-tested directly with plain maps. The happy-path orchestration test stays as the smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 65 ++++++++-------- auth/client/iam/openid4vp_test.go | 119 ++++++------------------------ 2 files changed, 53 insertions(+), 131 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index a3b7ff446b..6c2c454cd8 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -261,7 +261,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return nil, errors.New("authorization server does not advertise jwt-bearer support") } if serviceProviderSubjectID != nil { - return c.requestJwtBearerAccessToken(ctx, clientID, subjectID, *serviceProviderSubjectID, authServerURL, scopes, additionalCredentials, credentialSelection, metadata) + return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, additionalCredentials, credentialSelection, metadata) } // Resolve the presentation definition: from remote AS when available, local policy otherwise @@ -349,20 +349,22 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID // requestJwtBearerAccessToken implements the RFC 7523 jwt-bearer two-VP token request flow. // It builds VP1 from the HCP wallet (using the organization PD) and VP2 from the SP wallet (using the // service_provider PD), assembles them as `assertion` and `client_assertion`, and POSTs the token request. -func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clientID string, subjectID string, serviceProviderSubjectID string, +// Per RFC 7521 §4.2 the client is authenticated by the client_assertion, so no OAuth client_id form +// parameter is sent on this path. +func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subjectID string, serviceProviderSubjectID string, authServerURL string, scopes string, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { - match, err := c.policyBackend.FindCredentialProfile(ctx, scopes) + profile, err := c.policyBackend.FindCredentialProfile(ctx, scopes) if err != nil { return nil, fmt.Errorf("local PD resolution failed: %w", err) } - orgPD, hasOrg := match.WalletOwnerMapping[pe.WalletOwnerOrganization] + orgPD, hasOrg := profile.WalletOwnerMapping[pe.WalletOwnerOrganization] if !hasOrg { - return nil, fmt.Errorf("no organization presentation definition for scope %q", match.CredentialProfileScope) + return nil, fmt.Errorf("no organization presentation definition for scope %q", profile.CredentialProfileScope) } - spPD, hasSP := match.WalletOwnerMapping[pe.WalletOwnerServiceProvider] + spPD, hasSP := profile.WalletOwnerMapping[pe.WalletOwnerServiceProvider] if !hasSP { - return nil, fmt.Errorf("no service_provider presentation definition for scope %q", match.CredentialProfileScope) + return nil, fmt.Errorf("no service_provider presentation definition for scope %q", profile.CredentialProfileScope) } params := holder.BuildParams{ Audience: authServerURL, @@ -371,22 +373,27 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clien Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } - vp1, vp1Submission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + vp1, vp1Submission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } - // Capture id-bearing constraint field values resolved against VP1 and additively merge them into the - // credential_selection map for VP2. EHR-supplied keys take precedence over captured values. - credentialSelection, err = mergeCapturedFields(credentialSelection, vp1, vp1Submission, orgPD) + // Cross-VP binding: capture id-bearing constraint field values resolved against VP1 and additively merge + // them into the credential_selection map for VP2. The submission tells us which credential satisfied each + // input descriptor; we use that to walk the PD's id-bearing fields and extract their matched values. + credentialMap, err := vp1Submission.ResolveVP(*vp1) if err != nil { - return nil, err + return nil, fmt.Errorf("resolve VP1 submission for cross-VP binding: %w", err) + } + captured, err := orgPD.ResolveConstraintsFields(credentialMap) + if err != nil { + return nil, fmt.Errorf("resolve VP1 constraint fields for cross-VP binding: %w", err) } - vp2, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + credentialSelection = applyCapturedFieldsToSelection(credentialSelection, captured) + vp2, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } data := url.Values{} - data.Set(oauth.ClientIDParam, clientID) data.Set(oauth.GrantTypeParam, oauth.JwtBearerGrantType) data.Set(oauth.AssertionParam, vp1.Raw()) data.Set(oauth.ClientAssertionTypeParam, oauth.JwtBearerClientAssertionType) @@ -406,16 +413,15 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clien }, nil } -// buildSubmissionForSubject lists DIDs for the given subject, filters them to those whose method is supported -// by the AS, and asks the wallet to build a VP that fulfills the given PresentationDefinition. +// buildSubmissionForSubject lists DIDs for the given subject, filters them to those whose method is in +// params.DIDMethods, and asks the wallet to build a VP that fulfills the given PresentationDefinition. func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subjectID string, presentationDefinition pe.PresentationDefinition, - additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, params holder.BuildParams, - supportedDIDMethods []string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, params holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { subjectDIDs, err := c.subjectManager.ListDIDs(ctx, subjectID) if err != nil { return nil, nil, err } - subjectDIDs, err = filterDIDsByMethods(subjectDIDs, supportedDIDMethods) + subjectDIDs, err = filterDIDsByMethods(subjectDIDs, params.DIDMethods) if err != nil { return nil, nil, err } @@ -428,22 +434,9 @@ func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subject return c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) } -// mergeCapturedFields resolves the id-bearing constraint fields of definition against vp's submitted credentials -// and adds the resulting field-id → value pairs to selection. Existing keys in selection are not overwritten. -// Non-string values are skipped (selection is map[string]string). -func mergeCapturedFields(selection map[string]string, vp *vc.VerifiablePresentation, submission *pe.PresentationSubmission, definition pe.PresentationDefinition) (map[string]string, error) { - envelope, err := pe.ParseEnvelope([]byte(vp.Raw())) - if err != nil { - return nil, fmt.Errorf("parse VP envelope for cross-VP binding: %w", err) - } - credentialMap, err := submission.Resolve(*envelope) - if err != nil { - return nil, fmt.Errorf("resolve VP submission for cross-VP binding: %w", err) - } - captured, err := definition.ResolveConstraintsFields(credentialMap) - if err != nil { - return nil, fmt.Errorf("resolve constraint fields for cross-VP binding: %w", err) - } +// applyCapturedFieldsToSelection adds string-valued entries from captured to selection without overwriting +// existing keys. Non-string captured values are skipped (selection is map[string]string). +func applyCapturedFieldsToSelection(selection map[string]string, captured map[string]any) map[string]string { if selection == nil { selection = map[string]string{} } @@ -457,7 +450,7 @@ func mergeCapturedFields(selection map[string]string, vp *vc.VerifiablePresentat } selection[k] = s } - return selection, nil + return selection } // filterDIDsByMethods drops DIDs whose method is not in supportedMethods. Returns ErrPreconditionFailed when diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 2c45095fef..8da4e621d9 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -39,7 +39,6 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" - "github.com/nuts-foundation/nuts-node/core/to" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/policy" http2 "github.com/nuts-foundation/nuts-node/test/http" @@ -456,100 +455,6 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { assert.ErrorContains(t, err, "no service_provider presentation definition") }) - t.Run("captures shared field.id values from VP1 into VP2 credential_selection", func(t *testing.T) { - // Cross-VP binding scenario: when the organization PD and the service_provider PD share the same - // constraint-field `id` (here: "delegating_hcp"), the value matched in VP1 must flow into VP2's - // credential_selection so the wallet building VP2 can pick a credential constrained by that value. - // In this test we follow only the capture-and-merge step end-to-end: assert that VP2's BuildSubmission - // receives credential_selection["delegating_hcp"] = "did:test:hcp", the issuer of VP1's credential. - - sp := spSubjectID - hcpDID := did.MustParseDID("did:test:hcp") - spDID := did.MustParseDID("did:test:sp") - - // VP1 is a minimal JSON-LD presentation containing one credential whose $.issuer is the HCP DID. - // The presentation needs enough structure for ParseEnvelope + submission.Resolve to walk the JSONPath - // "$.verifiableCredential[0]" and return the embedded credential. - vp1Raw := `{ - "@context": ["https://www.w3.org/2018/credentials/v1"], - "type": ["VerifiablePresentation"], - "verifiableCredential": [{ - "@context": ["https://www.w3.org/2018/credentials/v1"], - "type": ["VerifiableCredential"], - "issuer": "did:test:hcp", - "credentialSubject": {"id": "did:test:hcp"}, - "proof": {"type": "JsonWebSignature2020"} - }], - "proof": {"type": "JsonWebSignature2020"} - }` - vp1, err := vc.ParseVerifiablePresentation(vp1Raw) - require.NoError(t, err) - // VP2's body is irrelevant for this test — it never gets parsed because we're only asserting on the - // arguments passed to its BuildSubmission call. - vp2, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:sp#1"}]}`) - require.NoError(t, err) - - // VP1's submission tells the resolver that input descriptor "id_org_cred" was satisfied by the first - // credential in the verifiableCredential array of VP1. - vp1Submission := &pe.PresentationSubmission{ - DescriptorMap: []pe.InputDescriptorMappingObject{ - {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, - }, - } - - // orgPD declares one constraint field with id "delegating_hcp" pointing at $.issuer of the matched VC. - // That id is what makes the value capturable; without an id the field would be ignored. - orgPD := pe.PresentationDefinition{ - Id: "org_pd", - InputDescriptors: []*pe.InputDescriptor{{ - Id: "id_org_cred", - Constraints: &pe.Constraints{ - Fields: []pe.Field{{Id: to.Ptr("delegating_hcp"), Path: []string{"$.issuer"}}}, - }, - }}, - } - // spPD shares the field.id "delegating_hcp" by convention. The sharing is what binds VP2 to a value - // matched in VP1 — this PR's job is to plumb that value into credential_selection. The PD itself - // carries no constraints in this test because the wallet mock never inspects it. - spPD := pe.PresentationDefinition{Id: "sp_pd"} - - ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true - // Advertise jwt-bearer so the gates pass and the two-VP path runs. - ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} - // The two-VP path resolves both PDs from the local policy backend rather than the AS's PD endpoint. - ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ - CredentialProfileScope: "first", - WalletOwnerMapping: pe.WalletOwnerMapping{ - pe.WalletOwnerOrganization: orgPD, - pe.WalletOwnerServiceProvider: spPD, - }, - }, nil) - // VP1 is built from the HCP wallet, VP2 from the SP wallet — different subject IDs, different DIDs. - ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) - ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) - // First call: build VP1 against the HCP DID using orgPD. Returns the JSON-LD VP and its submission so - // the production code can resolve constraint fields against them. - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), - orgPD, gomock.Any(), gomock.Any()).Return(vp1, vp1Submission, nil) - // Second call: build VP2 against the SP DID using spPD. We capture the credential_selection argument - // so the test can assert the merged field-id value made it through. - var capturedSPSelection map[string]string - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), - spPD, gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, sel map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { - capturedSPSelection = sel - return vp2, &pe.PresentationSubmission{}, nil - }) - - _, err = ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) - - require.NoError(t, err) - // The HCP DID matched against $.issuer in VP1's credential should be captured under "delegating_hcp" - // and forwarded to VP2's wallet — this is the cross-VP binding payoff. - assert.Equal(t, "did:test:hcp", capturedSPSelection["delegating_hcp"]) - }) - t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") @@ -595,6 +500,30 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { assert.Equal(t, oauth.JwtBearerClientAssertionType, capturedForm.Get(oauth.ClientAssertionTypeParam)) assert.Equal(t, vp2.Raw(), capturedForm.Get(oauth.ClientAssertionParam)) assert.Empty(t, capturedForm.Get(oauth.PresentationSubmissionParam)) + // Per RFC 7521 §4.2 client_id is optional when client_assertion is present and we omit it. + assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) + }) +} + +func TestApplyCapturedFieldsToSelection(t *testing.T) { + t.Run("adds string-valued captured entries to a nil selection", func(t *testing.T) { + merged := applyCapturedFieldsToSelection(nil, map[string]any{"delegating_hcp": "did:test:hcp"}) + + assert.Equal(t, map[string]string{"delegating_hcp": "did:test:hcp"}, merged) + }) + + t.Run("does not overwrite EHR-supplied selection keys", func(t *testing.T) { + ehrSelection := map[string]string{"delegating_hcp": "did:ehr:override"} + + merged := applyCapturedFieldsToSelection(ehrSelection, map[string]any{"delegating_hcp": "did:test:hcp"}) + + assert.Equal(t, "did:ehr:override", merged["delegating_hcp"]) + }) + + t.Run("skips non-string captured values", func(t *testing.T) { + merged := applyCapturedFieldsToSelection(nil, map[string]any{"int_field": 42, "string_field": "ok"}) + + assert.Equal(t, map[string]string{"string_field": "ok"}, merged) }) } From ad8cff6d536662ed18c380c8c053c66d717816b7 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 11:59:21 +0200 Subject: [PATCH 17/37] Extract requestVPTokenAccessToken so RequestRFC021AccessToken dispatches RequestRFC021AccessToken now reads as a thin dispatcher: gate the two-VP flag, fetch AS metadata once, then call requestJwtBearerAccessToken or requestVPTokenAccessToken. Both grant-specific paths live as private methods named after the grant type they assemble, so the structural parallel is obvious and neither path leaks implementation detail into the entry point. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 6c2c454cd8..fa144c4eb2 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -252,18 +252,25 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID if serviceProviderSubjectID != nil && !c.experimentalJwtBearerClient { return nil, errors.New("jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true") } - iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { return nil, err } - if serviceProviderSubjectID != nil && !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { - return nil, errors.New("authorization server does not advertise jwt-bearer support") - } if serviceProviderSubjectID != nil { + if !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { + return nil, errors.New("authorization server does not advertise jwt-bearer support") + } return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, additionalCredentials, credentialSelection, metadata) } + return c.requestVPTokenAccessToken(ctx, clientID, subjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) +} +// requestVPTokenAccessToken implements the single-VP RFC021 vp_token-bearer flow: resolve the +// presentation definition (remotely if the AS advertises one, locally otherwise), build a single VP from +// the caller's wallet, and POST it as `assertion` alongside the PE submission and DPoP header (when used). +func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, + scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, + metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { // Resolve the presentation definition: from remote AS when available, local policy otherwise resolved, err := c.pdResolver.Resolve(ctx, scopes, *metadata) if err != nil { @@ -284,7 +291,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return nil, err } - subjectDIDs, err = filterDIDsByMethods(subjectDIDs, metadata.DIDMethodsSupported) + subjectDIDs, err = filterDIDsByMethods(subjectDIDs, params.DIDMethods) if err != nil { return nil, err } @@ -329,7 +336,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID } log.Logger().Tracef("Requesting access token from '%s' for scope '%s'\n VP: %s\n Submission: %s", metadata.TokenEndpoint, scopes, assertion, string(presentationSubmission)) - token, err := iamClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) + token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) if err != nil { // the error could be a http error, we just relay it here to make use of any 400 status codes. return nil, err From 7d622df5422229dd98567acbac4ba33bad14d309 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 21:10:02 +0200 Subject: [PATCH 18/37] Rename RequestRFC021AccessToken to RequestServiceAccessToken The function used to assemble only the RFC021 vp_token-bearer flow, but it now also dispatches to the RFC 7523 jwt-bearer two-VP path. Renaming aligns with the public API endpoint POST /internal/auth/v2/{subjectID}/request-service-access-token and abstracts the protocol choice that the function makes internally. Mechanical rename of the interface, implementation, mock, callers and test references; no behavioural change. The interface docstring is also updated to drop the misleading "using Nuts RFC021" qualifier. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/api.go | 2 +- auth/api/iam/api_test.go | 18 ++++++++--------- auth/client/iam/interface.go | 10 ++++++---- auth/client/iam/mock.go | 12 ++++++------ auth/client/iam/openid4vp.go | 2 +- auth/client/iam/openid4vp_test.go | 32 +++++++++++++++---------------- 6 files changed, 39 insertions(+), 37 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 32b64b7b9c..3f833d26b0 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -781,7 +781,7 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, nil) + tokenResult, err := r.auth.IAMClient().RequestServiceAccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, nil) 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 89442584e8..bfac8c1308 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -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, nil, nil).Return(response, nil).Times(2) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil, nil).Return(&oauth.TokenResponse{}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, 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, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil, nil).Return(nil, pe.ErrNoCredentials) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, 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, nil, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil, nil).Return(response, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 440d735863..280f6fc454 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -43,12 +43,14 @@ type Client interface { PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (string, error) // 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. + // RequestServiceAccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server. + // When serviceProviderSubjectID is nil, the request uses the Nuts RFC021 vp_token-bearer single-VP flow. + // When serviceProviderSubjectID is non-nil it identifies a service-provider Nuts subject and triggers the RFC 7523 + // jwt-bearer two-VP flow; that flow is only honored when the experimental jwt-bearer client feature is enabled and + // the AS advertises jwt-bearer. // 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. - // serviceProviderSubjectID, when non-nil, identifies a service-provider Nuts subject and triggers the RFC 7523 jwt-bearer two-VP flow. - // It is only honored when the experimental jwt-bearer client feature is enabled and the AS advertises jwt-bearer. - RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, + RequestServiceAccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 6481f2029c..3a734e2fd2 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -193,19 +193,19 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestObjectByPost", reflect.TypeOf((*MockClient)(nil).RequestObjectByPost), ctx, requestURI, walletMetadata) } -// RequestRFC021AccessToken mocks base method. -func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { +// RequestServiceAccessToken mocks base method. +func (m *MockClient) RequestServiceAccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) + ret := m.ctrl.Call(m, "RequestServiceAccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) 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, credentialSelection, serviceProviderSubjectID any) *gomock.Call { +// RequestServiceAccessToken indicates an expected call of RequestServiceAccessToken. +func (mr *MockClientMockRecorder) RequestServiceAccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID 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, credentialSelection, serviceProviderSubjectID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestServiceAccessToken", reflect.TypeOf((*MockClient)(nil).RequestServiceAccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) } // VerifiableCredentials mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index fa144c4eb2..65a1c3fb10 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -247,7 +247,7 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd return &token, nil } -func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, +func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { if serviceProviderSubjectID != nil && !c.experimentalJwtBearerClient { return nil, errors.New("jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true") diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 8da4e621d9..a021224e0b 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -237,7 +237,7 @@ func TestIAMClient_AuthorizationServerMetadata(t *testing.T) { }) } -func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { +func TestRelyingParty_RequestServiceAccessToken(t *testing.T) { const subjectID = "subby" const subjectClientID = "https://example.com/oauth2/subby" primaryWalletDID := did.MustParseDID("did:test:primary") @@ -254,7 +254,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.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(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -266,7 +266,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.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(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.ErrorIs(t, err, pe.ErrNoCredentials) assert.Nil(t, response) @@ -276,7 +276,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.authzServerMetadata.DIDMethodsSupported = []string{"other"} ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrPreconditionFailed) @@ -313,7 +313,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return createdVP, &pe.PresentationSubmission{}, nil }) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -327,7 +327,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.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(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -349,7 +349,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.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(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) var oauthErrResult oauth.OAuth2Error @@ -367,7 +367,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.True(t, errors.As(err, &oauth.OAuth2Error{})) @@ -377,7 +377,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, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidClientCall) @@ -391,7 +391,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, _ = writer.Write([]byte("{")) } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrBadGateway) @@ -402,13 +402,13 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.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(), gomock.Any()).Return(nil, nil, assert.AnError) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.Error(t, err) }) } -func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { +func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { const subjectID = "subby" const subjectClientID = "https://example.com/oauth2/subby" const spSubjectID = "service-provider-subby" @@ -419,7 +419,7 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { ctx := createClientServerTestContext(t) ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = false - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.Error(t, err) assert.ErrorContains(t, err, "jwt-bearer") @@ -431,7 +431,7 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true // The default AS metadata in the test setup does not include JwtBearerGrantType in grant_types_supported. - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.Error(t, err) assert.ErrorContains(t, err, "authorization server does not advertise jwt-bearer") @@ -449,7 +449,7 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { }, }, nil) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.Error(t, err) assert.ErrorContains(t, err, "no service_provider presentation definition") @@ -491,7 +491,7 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) } - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.NoError(t, err) require.NotNil(t, response) From 5023f0c65184898aced09d153981a2d5cc2cf886 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 21:12:20 +0200 Subject: [PATCH 19/37] Extract loadAndValidateProfile and unit-test the rules directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both grant-specific paths used to repeat the same preamble: FindCredentialProfile, reject extras under profile-only, require an organization PD, collapse the resolved scope. The rules and error messages were identical; only the surrounding orchestration differed. loadAndValidateProfile now owns those rules; pdResolver.resolveLocal and requestJwtBearerAccessToken both call it and add only their flow-specific bits (single-VP picks the org PD; two-VP additionally requires the service_provider PD). Tests follow the same shape: TestLoadAndValidateProfile covers every scope-policy and missing-PD scenario with just a policyBackend mock — no HTTP server, no wallet, no subject manager. The two orchestration- level scope-policy tests in TestRelyingParty_RequestServiceAccessToken_TwoVP are removed; the helper test stands in for them and runs in a fraction of the setup. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 14 ++- auth/client/iam/openid4vp_test.go | 3 + auth/client/iam/pd_resolver.go | 29 +---- auth/client/iam/profile_validation.go | 59 +++++++++++ auth/client/iam/profile_validation_test.go | 117 +++++++++++++++++++++ 5 files changed, 190 insertions(+), 32 deletions(-) create mode 100644 auth/client/iam/profile_validation.go create mode 100644 auth/client/iam/profile_validation_test.go diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 65a1c3fb10..c016655ba8 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -361,14 +361,12 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subjectID string, serviceProviderSubjectID string, authServerURL string, scopes string, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { - profile, err := c.policyBackend.FindCredentialProfile(ctx, scopes) + profile, resolvedScope, err := loadAndValidateProfile(ctx, c.policyBackend, scopes) if err != nil { - return nil, fmt.Errorf("local PD resolution failed: %w", err) - } - orgPD, hasOrg := profile.WalletOwnerMapping[pe.WalletOwnerOrganization] - if !hasOrg { - return nil, fmt.Errorf("no organization presentation definition for scope %q", profile.CredentialProfileScope) + return nil, err } + // loadAndValidateProfile guarantees the organization PD; the service_provider PD is two-VP-specific. + orgPD := profile.WalletOwnerMapping[pe.WalletOwnerOrganization] spPD, hasSP := profile.WalletOwnerMapping[pe.WalletOwnerServiceProvider] if !hasSP { return nil, fmt.Errorf("no service_provider presentation definition for scope %q", profile.CredentialProfileScope) @@ -405,9 +403,9 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje data.Set(oauth.AssertionParam, vp1.Raw()) data.Set(oauth.ClientAssertionTypeParam, oauth.JwtBearerClientAssertionType) data.Set(oauth.ClientAssertionParam, vp2.Raw()) - data.Set(oauth.ScopeParam, scopes) + data.Set(oauth.ScopeParam, resolvedScope) - log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n VP1: %s\n VP2: %s", metadata.TokenEndpoint, scopes, vp1.Raw(), vp2.Raw()) + log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n VP1: %s\n VP2: %s", metadata.TokenEndpoint, resolvedScope, vp1.Raw(), vp2.Raw()) token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, "") if err != nil { return nil, err diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index a021224e0b..38b5408aa4 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -455,6 +455,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { assert.ErrorContains(t, err, "no service_provider presentation definition") }) + // Scope-policy behaviour (profile-only rejection, passthrough forwarding, missing organization PD) + // is covered by TestLoadAndValidateProfile, which exercises the same helper this path delegates to. + t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") diff --git a/auth/client/iam/pd_resolver.go b/auth/client/iam/pd_resolver.go index edce53eb21..a5973ac29c 100644 --- a/auth/client/iam/pd_resolver.go +++ b/auth/client/iam/pd_resolver.go @@ -78,33 +78,14 @@ func (r *PresentationDefinitionResolver) resolveRemote(ctx context.Context, scop } func (r *PresentationDefinitionResolver) resolveLocal(ctx context.Context, scope string) (*ResolvedPresentationDefinition, error) { - if r.policyBackend == nil { - return nil, fmt.Errorf("local PD resolution requires a policy backend, but none is configured") - } - match, err := r.policyBackend.FindCredentialProfile(ctx, scope) + profile, resolvedScope, err := loadAndValidateProfile(ctx, r.policyBackend, scope) if err != nil { - return nil, fmt.Errorf("local PD resolution failed: %w", err) - } - if match.ScopePolicy == policy.ScopePolicyProfileOnly && len(match.OtherScopes) > 0 { - return nil, oauth.OAuth2Error{ - Code: oauth.InvalidScope, - Description: "scope policy 'profile-only' does not allow additional scopes", - } - } - // Select the organization PD (default for current single-VP flow). - // TODO: When #4080 adds two-VP support, this resolver will need to return multiple PDs. - pd, ok := match.WalletOwnerMapping[pe.WalletOwnerOrganization] - if !ok { - return nil, fmt.Errorf("no organization presentation definition for scope %q", match.CredentialProfileScope) - } - // For passthrough and dynamic, forward all scopes to the remote AS. - // The client does not evaluate dynamic scopes — the server handles PDP evaluation at token-grant time (PR #4179). - resolvedScope := scope - if match.ScopePolicy == policy.ScopePolicyProfileOnly { - resolvedScope = match.CredentialProfileScope + return nil, err } + // Select the organization PD (default for the single-VP flow). loadAndValidateProfile already verified + // the org PD is present, so the lookup is safe. return &ResolvedPresentationDefinition{ - PresentationDefinition: pd, + PresentationDefinition: profile.WalletOwnerMapping[pe.WalletOwnerOrganization], Scope: resolvedScope, }, nil } diff --git a/auth/client/iam/profile_validation.go b/auth/client/iam/profile_validation.go new file mode 100644 index 0000000000..36ea87c4eb --- /dev/null +++ b/auth/client/iam/profile_validation.go @@ -0,0 +1,59 @@ +/* + * 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 iam + +import ( + "context" + "fmt" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) + +// loadAndValidateProfile fetches the credential profile for the requested scope, applies the scope policy, +// and verifies that the profile defines an organization PresentationDefinition (required by every local +// flow that fans out from VP1). +// +// Returns the credential profile and the resolved scope to forward to the AS. Returns an oauth.OAuth2Error +// (InvalidScope) when the profile is configured as profile-only but the request carries extra scopes; a +// plain error when the profile cannot be loaded or lacks the organization PD. +func loadAndValidateProfile(ctx context.Context, backend policy.PDPBackend, scope string) (*policy.CredentialProfileMatch, string, error) { + if backend == nil { + return nil, "", fmt.Errorf("local PD resolution requires a policy backend, but none is configured") + } + profile, err := backend.FindCredentialProfile(ctx, scope) + if err != nil { + return nil, "", fmt.Errorf("local PD resolution failed: %w", err) + } + if profile.ScopePolicy == policy.ScopePolicyProfileOnly && len(profile.OtherScopes) > 0 { + return nil, "", oauth.OAuth2Error{ + Code: oauth.InvalidScope, + Description: "scope policy 'profile-only' does not allow additional scopes", + } + } + if _, ok := profile.WalletOwnerMapping[pe.WalletOwnerOrganization]; !ok { + return nil, "", fmt.Errorf("no organization presentation definition for scope %q", profile.CredentialProfileScope) + } + resolvedScope := scope + if profile.ScopePolicy == policy.ScopePolicyProfileOnly { + resolvedScope = profile.CredentialProfileScope + } + return profile, resolvedScope, nil +} diff --git a/auth/client/iam/profile_validation_test.go b/auth/client/iam/profile_validation_test.go new file mode 100644 index 0000000000..ed53214138 --- /dev/null +++ b/auth/client/iam/profile_validation_test.go @@ -0,0 +1,117 @@ +/* + * 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 iam + +import ( + "context" + "errors" + "testing" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestLoadAndValidateProfile(t *testing.T) { + const requested = "first second" + orgPD := pe.PresentationDefinition{Id: "org_pd"} + + t.Run("returns error when no policy backend is configured", func(t *testing.T) { + _, _, err := loadAndValidateProfile(context.Background(), nil, requested) + + assert.ErrorContains(t, err, "local PD resolution requires a policy backend") + }) + + t.Run("wraps the policy backend error", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(nil, errors.New("boom")) + + _, _, err := loadAndValidateProfile(context.Background(), backend, requested) + + assert.ErrorContains(t, err, "local PD resolution failed: boom") + }) + + t.Run("rejects extra scopes when policy is profile-only", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + OtherScopes: []string{"second"}, + ScopePolicy: policy.ScopePolicyProfileOnly, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: orgPD}, + }, nil) + + _, _, err := loadAndValidateProfile(context.Background(), backend, requested) + + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.InvalidScope, oauthErr.Code) + assert.Contains(t, oauthErr.Description, "scope policy 'profile-only' does not allow additional scopes") + }) + + t.Run("rejects when the organization PD is missing", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + ScopePolicy: policy.ScopePolicyPassthrough, + WalletOwnerMapping: pe.WalletOwnerMapping{}, // no organization + }, nil) + + _, _, err := loadAndValidateProfile(context.Background(), backend, requested) + + assert.ErrorContains(t, err, `no organization presentation definition for scope "first"`) + }) + + t.Run("collapses to credential profile scope when policy is profile-only and no extras", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), "first").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + ScopePolicy: policy.ScopePolicyProfileOnly, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: orgPD}, + }, nil) + + profile, resolved, err := loadAndValidateProfile(context.Background(), backend, "first") + + require.NoError(t, err) + assert.Equal(t, "first", resolved) + assert.Equal(t, "first", profile.CredentialProfileScope) + }) + + t.Run("forwards the full input scope when policy is passthrough", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + OtherScopes: []string{"second"}, + ScopePolicy: policy.ScopePolicyPassthrough, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: orgPD}, + }, nil) + + _, resolved, err := loadAndValidateProfile(context.Background(), backend, requested) + + require.NoError(t, err) + assert.Equal(t, requested, resolved) + }) +} From b2ba04522b9229ec0243c78af63da8937bd3b087 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 21:23:08 +0200 Subject: [PATCH 20/37] Fold the single-VP build into buildSubmissionForSubject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestVPTokenAccessToken used to inline the ListDIDs → filterDIDsByMethods → expand additionalCredentials per DID → wallet.BuildSubmission sequence — the same logic that buildSubmissionForSubject already encapsulates and that requestJwtBearerAccessToken calls twice. Have single-VP call the same helper so both grant paths share the primitive and the inline copy goes away. The call shape into the wallet is byte-identical, so the existing single-VP test suite still passes without touch. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index c016655ba8..548d139d02 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -286,24 +286,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI Nonce: nutsCrypto.GenerateNonce(), } - subjectDIDs, err := c.subjectManager.ListDIDs(ctx, subjectID) - if err != nil { - return nil, err - } - - subjectDIDs, err = filterDIDsByMethods(subjectDIDs, params.DIDMethods) - if err != nil { - return nil, err - } - - // each additional credential can be used by each DID - additionalWalletCredentials := map[did.DID][]vc.VerifiableCredential{} - for _, subjectDID := range subjectDIDs { - for _, curr := range additionalCredentials { - additionalWalletCredentials[subjectDID] = append(additionalWalletCredentials[subjectDID], credential.AutoCorrectSelfAttestedCredential(curr, subjectDID)) - } - } - vp, submission, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, *presentationDefinition, credentialSelection, params) + vp, submission, err := c.buildSubmissionForSubject(ctx, subjectID, *presentationDefinition, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } From d7ed7b41504d842410f16811e50f41784f2933e6 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 11:39:31 +0200 Subject: [PATCH 21/37] Populate Envelope.raw in ResolveVP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveVP constructed an Envelope with an empty `raw []byte` field. Resolve never consults raw, so today nothing breaks — but any caller that inspected the wrapped envelope and called MarshalJSON would panic on `e.raw[0]`. Set raw from presentation.Raw() so the constructed envelope is a fully-formed value. Also tighten the doc comment to mention what the returned map is keyed by, so callers don't have to jump to Resolve to learn the contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- vcr/pe/presentation_submission.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go index 634004e5d5..1087bf41ba 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -165,7 +165,8 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis // ResolveVP is a convenience wrapper around Resolve for callers that already hold a single parsed // VerifiablePresentation in memory (e.g. a freshly built submission from the wallet) and would otherwise -// need to round-trip through Raw() + ParseEnvelope just to call Resolve. +// need to round-trip through Raw() + ParseEnvelope just to call Resolve. Returns a map keyed by +// InputDescriptor.Id; see Resolve for the underlying matching rules. func (s PresentationSubmission) ResolveVP(presentation vc.VerifiablePresentation) (map[string]vc.VerifiableCredential, error) { asInterface, err := vpAsInterface(presentation) if err != nil { @@ -174,6 +175,7 @@ func (s PresentationSubmission) ResolveVP(presentation vc.VerifiablePresentation return s.Resolve(Envelope{ Presentations: []vc.VerifiablePresentation{presentation}, asInterface: asInterface, + raw: []byte(presentation.Raw()), }) } From c68b7e932207280d5c85f603cb11d8e9a5e5d62b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 11:47:37 +0200 Subject: [PATCH 22/37] Wire DPoP through the jwt-bearer two-VP flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher accepted useDPoP and silently dropped it on the two-VP path. DPoP binds the issued access token to a key the holder controls — for the jwt-bearer flow that holder is the service provider (the OAuth client per RFC 7523). Sign the proof with the SP DID's key (taken from vp2.Holder) so callers that opt into DPoP get the same proof-of-possession on both grant types. Extracts signDPoPHeader as a small helper that returns ("","",nil) when useDPoP is false, so both grant paths can call it unconditionally and stop branching on the bool around the dpop() call. Also fixes the existing happy-path fixture: vp2.Holder must be set because the new DPoP-DID derivation reads it. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 56 +++++++++++++++++++++---------- auth/client/iam/openid4vp_test.go | 40 ++++++++++++++++++++-- 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 548d139d02..566842f0dd 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -260,7 +260,7 @@ func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientI if !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { return nil, errors.New("authorization server does not advertise jwt-bearer support") } - return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, additionalCredentials, credentialSelection, metadata) + return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) } return c.requestVPTokenAccessToken(ctx, clientID, subjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) } @@ -304,18 +304,9 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) data.Set(oauth.ScopeParam, resolved.Scope) - // create DPoP header - var dpopHeader string - var dpopKid string - if useDPoP { - request, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.TokenEndpoint, nil) - if err != nil { - return nil, err - } - dpopHeader, dpopKid, err = c.dpop(ctx, *subjectDID, *request) - if err != nil { - return nil, fmt.Errorf("failed to create DPoP header: %w", err) - } + dpopHeader, dpopKid, err := c.signDPoPHeader(ctx, useDPoP, *subjectDID, metadata.TokenEndpoint) + if err != nil { + return nil, err } log.Logger().Tracef("Requesting access token from '%s' for scope '%s'\n VP: %s\n Submission: %s", metadata.TokenEndpoint, scopes, assertion, string(presentationSubmission)) @@ -342,7 +333,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI // Per RFC 7521 §4.2 the client is authenticated by the client_assertion, so no OAuth client_id form // parameter is sent on this path. func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subjectID string, serviceProviderSubjectID string, - authServerURL string, scopes string, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, + authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { profile, resolvedScope, err := loadAndValidateProfile(ctx, c.policyBackend, scopes) if err != nil { @@ -381,6 +372,16 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje if err != nil { return nil, err } + // DPoP binds the issued access token to a key the service provider controls — the SP wallet will + // present and use the token, so the proof is signed with the SP DID's key. + spDID, err := did.ParseDID(vp2.Holder.String()) + if err != nil { + return nil, err + } + dpopHeader, dpopKid, err := c.signDPoPHeader(ctx, useDPoP, *spDID, metadata.TokenEndpoint) + if err != nil { + return nil, err + } data := url.Values{} data.Set(oauth.GrantTypeParam, oauth.JwtBearerGrantType) data.Set(oauth.AssertionParam, vp1.Raw()) @@ -389,16 +390,20 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje data.Set(oauth.ScopeParam, resolvedScope) log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n VP1: %s\n VP2: %s", metadata.TokenEndpoint, resolvedScope, vp1.Raw(), vp2.Raw()) - token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, "") + token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) if err != nil { return nil, err } - return &oauth.TokenResponse{ + tokenResponse := oauth.TokenResponse{ AccessToken: token.AccessToken, ExpiresIn: token.ExpiresIn, TokenType: token.TokenType, Scope: &scopes, - }, nil + } + if dpopKid != "" { + tokenResponse.DPoPKid = &dpopKid + } + return &tokenResponse, nil } // buildSubmissionForSubject lists DIDs for the given subject, filters them to those whose method is in @@ -482,6 +487,23 @@ func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialE return rsp, nil } +// signDPoPHeader signs a DPoP proof for a token-endpoint POST bound to signerDID's assertion key. +// Returns ("", "", nil) when useDPoP is false so callers can use the result unconditionally. +func (c *OpenID4VPClient) signDPoPHeader(ctx context.Context, useDPoP bool, signerDID did.DID, tokenEndpoint string) (string, string, error) { + if !useDPoP { + return "", "", nil + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, nil) + if err != nil { + return "", "", err + } + header, kid, err := c.dpop(ctx, signerDID, *request) + if err != nil { + return "", "", fmt.Errorf("failed to create DPoP header: %w", err) + } + return header, kid, nil +} + func (c *OpenID4VPClient) dpop(ctx context.Context, requester did.DID, request http.Request) (string, string, error) { // find the key to sign the DPoP token with keyID, _, err := c.keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 38b5408aa4..ecd86d4e99 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -462,9 +462,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") spDID := did.MustParseDID("did:test:sp") - vp1, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) require.NoError(t, err) - vp2, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:sp#1"}]}`) + vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) ctx := createClientServerTestContext(t) @@ -506,6 +506,42 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { // Per RFC 7521 §4.2 client_id is optional when client_assertion is present and we omit it. assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) }) + + t.Run("ok with DPoPHeader", func(t *testing.T) { + // DPoP binds the issued access token to a key the SP wallet controls — the proof must be signed with + // the SP DID's key (vp2.Holder), not the HCP DID's key. + sp := spSubjectID + spDID := did.MustParseDID("did:test:sp") + spKID := "did:test:sp#1" + vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{did.MustParseDID("did:test:hcp")}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(vp1, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(vp2, &pe.PresentationSubmission{}, nil) + // The DPoP signing key must be resolved against the SP DID, not the HCP DID — that's the assertion. + ctx.keyResolver.EXPECT().ResolveKey(spDID, nil, resolver.NutsSigningKeyType).Return(spKID, nil, nil) + ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), spKID).Return("dpop", nil) + + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil, &sp) + + require.NoError(t, err) + require.NotNil(t, response) + require.NotNil(t, response.DPoPKid) + assert.Equal(t, spKID, *response.DPoPKid) + }) } func TestApplyCapturedFieldsToSelection(t *testing.T) { From ae78489e3c2d82c6e63b6d56e3f36cd583bca615 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 11:55:38 +0200 Subject: [PATCH 23/37] End-to-end test for cross-VP field-id binding The happy-path test stubs both BuildSubmission calls with empty PresentationSubmission{}, so the cross-VP capture chain runs over an empty descriptor map and asserts nothing. Add a focused subtest that returns a real VP1 + submission shaped to capture a constraint field (`delegating_hcp` = `did:test:hcp`) and asserts that the second BuildSubmission receives the captured value in its credential_selection argument. Closes the e2e gap on the PRD's "cross-VP binding works without EHR involvement" acceptance criterion. The pure-function helper is still covered by TestApplyCapturedFieldsToSelection; this test guards the wiring around it. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp_test.go | 107 ++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index ecd86d4e99..2f1e86f569 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -507,6 +507,113 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) }) + t.Run("captured VP1 field-id values flow into VP2 credential_selection end-to-end", func(t *testing.T) { + // Cross-VP binding scenario, end to end: when the organization PD and the service_provider PD share + // the same constraint-field `id` (here: "delegating_hcp"), the value matched in VP1 must flow into + // VP2's credential_selection so the wallet building VP2 can pick a credential constrained by that + // value. This test follows the chain through requestJwtBearerAccessToken: + // + // VP1 + vp1Submission --ResolveVP--> credentialMap (which VC satisfied which input descriptor) + // credentialMap + orgPD --ResolveConstraintsFields--> {delegating_hcp: did:test:hcp} + // --applyCapturedFieldsToSelection--> credential_selection passed to VP2's BuildSubmission + // + // TestApplyCapturedFieldsToSelection covers the merge step in isolation; this test exists to guard + // the wiring from BuildSubmission's return values into VP2's BuildSubmission argument. + + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + + // VP1 is the healthcare provider's presentation. It must be a parseable JSON-LD VP that contains + // one credential whose $.issuer is the HCP DID — that value is what the binding will capture. + // `holder` is set so the DPoP code path (which derives a signing DID from vp.Holder) doesn't panic + // even though we don't enable DPoP in this test. + vp1, err := vc.ParseVerifiablePresentation(`{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiablePresentation"], + "holder": "did:test:hcp", + "verifiableCredential": [{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential"], + "issuer": "did:test:hcp", + "credentialSubject": {"id": "did:test:hcp"}, + "proof": {"type": "JsonWebSignature2020"} + }], + "proof": {"type": "JsonWebSignature2020"} + }`) + require.NoError(t, err) + // VP2's body is irrelevant for this test — we only assert what VP2's BuildSubmission was *called* + // with; we never inspect vp2 itself afterwards. The holder is set for the same DPoP-safety reason. + vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + + // vp1Submission tells the resolver "input descriptor id_org_cred was satisfied by the credential at + // $.verifiableCredential[0]". Without this, ResolveVP can't bridge from a descriptor id to a VC, and + // ResolveConstraintsFields has nothing to walk for $.issuer. + vp1Submission := &pe.PresentationSubmission{ + DescriptorMap: []pe.InputDescriptorMappingObject{ + {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, + }, + } + + // orgPD declares one constraint field with id "delegating_hcp" pointing at $.issuer of the matched + // VC. The id is what makes the value capturable; an unidentified field would be ignored. + fieldID := "delegating_hcp" + orgPD := pe.PresentationDefinition{ + Id: "org_pd", + InputDescriptors: []*pe.InputDescriptor{{ + Id: "id_org_cred", + Constraints: &pe.Constraints{ + Fields: []pe.Field{{Id: &fieldID, Path: []string{"$.issuer"}}}, + }, + }}, + } + // spPD shares the field.id "delegating_hcp" by convention. The sharing is what binds VP2 to a value + // matched in VP1. The PD itself carries no constraints in this test because the wallet mock never + // inspects it; we only care that the credential_selection it receives contains the captured value. + spPD := pe.PresentationDefinition{Id: "sp_pd"} + + ctx := createClientServerTestContext(t) + // Enable the experimental flag so the dispatcher routes us into the two-VP path. + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + // Advertise jwt-bearer so the dispatcher's "AS supports jwt-bearer" check passes. + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + // The two-VP path always resolves PDs from the local policy backend (no remote PD endpoint for the + // service_provider concept), so this is the single source of truth for both PDs in the request. + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: orgPD, + pe.WalletOwnerServiceProvider: spPD, + }, + }, nil) + // VP1 is built from the HCP wallet (subjectID), VP2 from the SP wallet (spSubjectID) — different + // subject IDs, different DID candidate slices. + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // First BuildSubmission call: build VP1 against the HCP DID using orgPD. We return the JSON-LD VP + // and its submission so the production code can resolve constraint fields against them. + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + orgPD, gomock.Any(), gomock.Any()).Return(vp1, vp1Submission, nil) + // Second BuildSubmission call: build VP2 against the SP DID using spPD. We capture the + // credential_selection argument so the assertion at the bottom can verify the merged field-id + // value made it through the orchestration. + var capturedSPSelection map[string]string + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), + spPD, gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, sel map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + capturedSPSelection = sel + return vp2, &pe.PresentationSubmission{}, nil + }) + + _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + // Payoff: the HCP DID matched against $.issuer in VP1's credential should be captured under + // "delegating_hcp" and forwarded to VP2's wallet — without the EHR caller having to set it. + assert.Equal(t, "did:test:hcp", capturedSPSelection["delegating_hcp"]) + }) + t.Run("ok with DPoPHeader", func(t *testing.T) { // DPoP binds the issued access token to a key the SP wallet controls — the proof must be signed with // the SP DID's key (vp2.Holder), not the HCP DID's key. From b9d3c98a08044bfbb0ca7479c245340519a53d74 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 11:56:50 +0200 Subject: [PATCH 24/37] Return the resolved scope on the TokenResponse Both grant paths sent `resolvedScope` (collapsed to the credential profile scope under profile-only, or the full input string under passthrough) on the wire to the AS, but echoed the raw `scopes` input back on the TokenResponse. A caller inspecting the response would see scopes the AS never granted. Return what was actually sent so the response matches the request. Pre-existing in the single-VP path; mirrored across to the new jwt-bearer path while we're here. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 566842f0dd..da20dee876 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -319,7 +319,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI AccessToken: token.AccessToken, ExpiresIn: token.ExpiresIn, TokenType: token.TokenType, - Scope: &scopes, + Scope: &resolved.Scope, } if dpopKid != "" { tokenResponse.DPoPKid = &dpopKid @@ -398,7 +398,7 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje AccessToken: token.AccessToken, ExpiresIn: token.ExpiresIn, TokenType: token.TokenType, - Scope: &scopes, + Scope: &resolvedScope, } if dpopKid != "" { tokenResponse.DPoPKid = &dpopKid From c4b817eea04e901724296b622fd01b15c6b1ed51 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 12:00:46 +0200 Subject: [PATCH 25/37] Doc polish for the jwt-bearer two-VP flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a release-notes entry for the experimental auth.experimental.jwt_bearer_client flag so operators discover the feature in the changelog rather than having to read the diff. - Spell out the HCP/SP abbreviations in requestJwtBearerAccessToken's function comment, and note that both PDs are resolved from the local policy backend (the AS's remote presentation_definition endpoint is not consulted in this flow — no standard exists yet for the AS to advertise a service_provider PD). - Document on the Client interface that additionalCredentials are offered to both wallets in the two-VP flow, that signed VCs flow through unchanged, and that unsigned self-attested credentials get per-holder-DID issuance via AutoCorrectSelfAttestedCredential. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/interface.go | 4 +++- auth/client/iam/openid4vp.go | 7 +++++-- docs/pages/release_notes.rst | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 280f6fc454..d26eb6004f 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -48,7 +48,9 @@ type Client interface { // When serviceProviderSubjectID is non-nil it identifies a service-provider Nuts subject and triggers the RFC 7523 // jwt-bearer two-VP flow; that flow is only honored when the experimental jwt-bearer client feature is enabled and // the AS advertises jwt-bearer. - // credentials are additional VCs to include alongside wallet-stored credentials. + // credentials are additional VCs to include alongside wallet-stored credentials. In the two-VP flow they are + // offered to both wallets; each PD selects what matches its input descriptors. Signed VCs flow through unchanged; + // unsigned self-attested credentials are auto-issued per holder DID by AutoCorrectSelfAttestedCredential. // credentialSelection maps PD field IDs to expected values to disambiguate when multiple credentials match an input descriptor. RequestServiceAccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index da20dee876..306fe9b16a 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -328,10 +328,13 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI } // requestJwtBearerAccessToken implements the RFC 7523 jwt-bearer two-VP token request flow. -// It builds VP1 from the HCP wallet (using the organization PD) and VP2 from the SP wallet (using the -// service_provider PD), assembles them as `assertion` and `client_assertion`, and POSTs the token request. +// It builds VP1 from the healthcare-provider (HCP) wallet using the organization PD, and VP2 from the +// service-provider (SP) wallet using the service_provider PD, assembles them as `assertion` and +// `client_assertion`, and POSTs the token request. // Per RFC 7521 §4.2 the client is authenticated by the client_assertion, so no OAuth client_id form // parameter is sent on this path. +// Both PDs are resolved from the local policy backend; the AS's remote presentation_definition endpoint +// is not consulted (no standardised mechanism exists today for the AS to advertise a service_provider PD). func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subjectID string, serviceProviderSubjectID string, authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index 7168d3f8e5..d5b3370645 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -9,6 +9,7 @@ Unreleased ## New features * #4063: Enable ``storage.debug`` flag to log go-leia performance issues (full table scans, suboptimal index usage) by @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4064 * #4078: Allow policy profiles to define a ``service_provider`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 +* #4078: Add the experimental RFC 7523 ``jwt-bearer`` two-VP token request flow, gated behind ``auth.experimental.jwt_bearer_client`` (default ``false``, subject to change) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4227 **************** Peanut (v6.2.1) From 3f8def8b4620a615477ffa19daefd61045220ef1 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 12:01:47 +0200 Subject: [PATCH 26/37] Move JwtBearerClientAssertionType into a dedicated const block JwtBearerClientAssertionType is a client_assertion_type value, not an OAuth grant_type. Living under the "// grant types" header was misleading for readers scanning the file by section. Promote it to its own "// client assertion types" block so it sits next to the ClientAssertionTypeParam param it parameterises. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/oauth/types.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/auth/oauth/types.go b/auth/oauth/types.go index e08b62f251..567b5459d1 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -211,7 +211,11 @@ const ( VpTokenGrantType = "vp_token-bearer" // JwtBearerGrantType is the grant_type for the RFC 7523 JWT bearer grant type. JwtBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" - // JwtBearerClientAssertionType is the client_assertion_type for the RFC 7523 JWT bearer client assertion. +) + +// client assertion types +const ( + // JwtBearerClientAssertionType is the canonical value of ClientAssertionTypeParam for the RFC 7523 JWT bearer client assertion. JwtBearerClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" ) From 603d4320dfa3c631a70db569e51e31f29b79ea8a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 12:03:13 +0200 Subject: [PATCH 27/37] Regenerate server_options.rst Catches up the deployment configuration table with two flags that were not regenerated when their commits landed: - auth.experimental.jwt_bearer_client (this PR) - policy.authzen.endpoint (#4144) Operators can now discover both from the docs without having to read the source. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/pages/deployment/server_options.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index a281ac14d6..5fa404be50 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -17,6 +17,7 @@ httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. **Auth** auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature. + auth.experimental.jwt_bearer_client false enables the experimental RFC 7523 jwt-bearer two-VP token request flow. While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice. **Crypto** crypto.storage Storage to use, 'fs' for file system (for development purposes), 'vaultkv' for HashiCorp Vault KV store, 'azure-keyvault' for Azure Key Vault, 'external' for an external backend (deprecated). crypto.azurekv.hsm false Whether to store the key in a hardware security module (HSM). If true, the Azure Key Vault must be configured for HSM usage. Default: false @@ -69,4 +70,5 @@ tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. + policy.authzen.endpoint Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ From c5bd0765944b3e47b1b0af5b86d3b5fbac2041e7 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 13:15:31 +0200 Subject: [PATCH 28/37] Wrap the jwt-bearer flag-flip in a tiny test helper Subtests previously did ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true which couples them to the concrete type behind the Client interface and bypasses the constructor. Wrap the field-poke in a small enableJwtBearerClient(t, ctx) helper so subtests can express intent without the type assertion noise. The "feature disabled" subtest now relies on the zero-value default explicitly (with a comment). Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp_test.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 2f1e86f569..f458df5c86 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -408,6 +408,14 @@ func TestRelyingParty_RequestServiceAccessToken(t *testing.T) { }) } +// enableJwtBearerClient flips the experimental jwt-bearer client feature flag on the test client. +// It exists so subtests don't have to type-assert to the concrete *OpenID4VPClient just to poke an +// unexported field; the intent ("opt this test into the two-VP flow") is also clearer at the call site. +func enableJwtBearerClient(t *testing.T, ctx *clientServerTestContext) { + t.Helper() + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true +} + func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { const subjectID = "subby" const subjectClientID = "https://example.com/oauth2/subby" @@ -415,9 +423,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { scopes := "first second" t.Run("rejects the request when the experimental jwt-bearer feature is disabled", func(t *testing.T) { + // The default zero value of experimentalJwtBearerClient is false; we leave it alone so the gate fires. sp := spSubjectID ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = false _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) @@ -428,7 +436,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { t.Run("rejects the request when the AS does not advertise jwt-bearer", func(t *testing.T) { sp := spSubjectID ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + enableJwtBearerClient(t, ctx) // The default AS metadata in the test setup does not include JwtBearerGrantType in grant_types_supported. _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) @@ -440,7 +448,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { t.Run("rejects the request when no service_provider PD is configured for the scope", func(t *testing.T) { sp := spSubjectID ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + enableJwtBearerClient(t, ctx) ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ CredentialProfileScope: "first", @@ -468,7 +476,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { require.NoError(t, err) ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + enableJwtBearerClient(t, ctx) ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ CredentialProfileScope: "first", @@ -575,7 +583,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { ctx := createClientServerTestContext(t) // Enable the experimental flag so the dispatcher routes us into the two-VP path. - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + enableJwtBearerClient(t, ctx) // Advertise jwt-bearer so the dispatcher's "AS supports jwt-bearer" check passes. ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} // The two-VP path always resolves PDs from the local policy backend (no remote PD endpoint for the @@ -625,7 +633,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + enableJwtBearerClient(t, ctx) ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ CredentialProfileScope: "first", From fd621f46b59bc80657fde885591b5b6eed053091 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 13:17:36 +0200 Subject: [PATCH 29/37] applyCapturedFieldsToSelection allocates a fresh selection map Mutating the caller-supplied selection map risked leaking captured field values into anything that retained the original reference (request DTO, logs, retries, caches). Always allocate a fresh map so the function is a pure value-in / value-out helper. The contract is now unambiguous: callers must use the return value, and the input map is never touched. A new subtest pins the no-mutation guarantee. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 17 ++++++++++------- auth/client/iam/openid4vp_test.go | 10 ++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 306fe9b16a..f34b561d64 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -430,23 +430,26 @@ func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subject return c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) } -// applyCapturedFieldsToSelection adds string-valued entries from captured to selection without overwriting -// existing keys. Non-string captured values are skipped (selection is map[string]string). +// applyCapturedFieldsToSelection returns a fresh selection map containing every entry from selection +// plus any string-valued entry from captured whose key is not already present. Non-string captured values +// are skipped (selection is map[string]string). The caller's selection map is never mutated, so the +// captured values cannot leak back through any retained reference. func applyCapturedFieldsToSelection(selection map[string]string, captured map[string]any) map[string]string { - if selection == nil { - selection = map[string]string{} + merged := make(map[string]string, len(selection)+len(captured)) + for k, v := range selection { + merged[k] = v } for k, v := range captured { - if _, exists := selection[k]; exists { + if _, exists := merged[k]; exists { continue } s, ok := v.(string) if !ok { continue } - selection[k] = s + merged[k] = s } - return selection + return merged } // filterDIDsByMethods drops DIDs whose method is not in supportedMethods. Returns ErrPreconditionFailed when diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index f458df5c86..9756e3f0dd 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -679,6 +679,16 @@ func TestApplyCapturedFieldsToSelection(t *testing.T) { assert.Equal(t, map[string]string{"string_field": "ok"}, merged) }) + + t.Run("does not mutate the caller's selection map", func(t *testing.T) { + // The caller (HTTP handler) typically passes a request-derived map. Mutating it would risk leaking + // captured values into anything that retains a reference to that map (logging, caches, retries). + ehrSelection := map[string]string{"caller_key": "caller_value"} + + _ = applyCapturedFieldsToSelection(ehrSelection, map[string]any{"captured_key": "captured_value"}) + + assert.Equal(t, map[string]string{"caller_key": "caller_value"}, ehrSelection) + }) } func TestIAMClient_RequestObjectByGet(t *testing.T) { From 584425eb75d7d689577e20c0eb665e3e904d6cd4 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 13:21:31 +0200 Subject: [PATCH 30/37] Use typed OAuth2Errors for jwt-bearer rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rejection sites in the two-VP path used plain errors.New / fmt.Errorf, which the API layer cannot map cleanly to OAuth status codes — they fall through to a generic 500 instead of the appropriate 4xx. Switch them to oauth.OAuth2Error matching the existing pattern already used inside loadAndValidateProfile: - "experimental flag off" / "AS does not advertise jwt-bearer" → UnsupportedGrantType (RFC 6749 §5.2) - "no service_provider PD configured for scope" → InvalidScope (matches the existing profile-only-extras error from loadAndValidateProfile) The "AS doesn't advertise" test is tightened to assert the error type and code so the contract is locked in. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 15 ++++++++++++--- auth/client/iam/openid4vp_test.go | 5 ++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index f34b561d64..e03800231b 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -250,7 +250,10 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { if serviceProviderSubjectID != nil && !c.experimentalJwtBearerClient { - return nil, errors.New("jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true") + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedGrantType, + Description: "jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true", + } } metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { @@ -258,7 +261,10 @@ func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientI } if serviceProviderSubjectID != nil { if !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { - return nil, errors.New("authorization server does not advertise jwt-bearer support") + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedGrantType, + Description: "authorization server does not advertise jwt-bearer support", + } } return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) } @@ -346,7 +352,10 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje orgPD := profile.WalletOwnerMapping[pe.WalletOwnerOrganization] spPD, hasSP := profile.WalletOwnerMapping[pe.WalletOwnerServiceProvider] if !hasSP { - return nil, fmt.Errorf("no service_provider presentation definition for scope %q", profile.CredentialProfileScope) + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidScope, + Description: fmt.Sprintf("no service_provider presentation definition for scope %q", profile.CredentialProfileScope), + } } params := holder.BuildParams{ Audience: authServerURL, diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 9756e3f0dd..be437c9072 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -442,7 +442,10 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.Error(t, err) - assert.ErrorContains(t, err, "authorization server does not advertise jwt-bearer") + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.UnsupportedGrantType, oauthErr.Code) + assert.Contains(t, oauthErr.Description, "authorization server does not advertise jwt-bearer") }) t.Run("rejects the request when no service_provider PD is configured for the scope", func(t *testing.T) { From c80a2ee15ec6bfe3d3cbe640add38bb0a33ecb3a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:09:25 +0200 Subject: [PATCH 31/37] Replace PresentationSubmission.ResolveVP with pe.NewEnvelopeFromVP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @reinkrul flagged that adding a one-call syntactic-sugar method to PresentationSubmission widens the otherwise-clean API for marginal benefit. Move the conversion into a dedicated constructor on the Envelope side: pe.NewEnvelopeFromVP(vp) returns an *Envelope, callers hand it to the existing PresentationSubmission.Resolve. Same correctness — Envelope.raw is populated from vp.Raw() so MarshalJSON stays panic-free — without growing PresentationSubmission's surface. Caller in auth/client/iam picks up two lines (constructor + error check) instead of one. Test renamed and adjusted to exercise the new constructor + Resolve combination. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 6 +++++- vcr/pe/presentation_submission.go | 16 ---------------- vcr/pe/presentation_submission_test.go | 13 ++++++++----- vcr/pe/util.go | 15 +++++++++++++++ 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index e03800231b..4cfb1aa38d 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -371,7 +371,11 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje // Cross-VP binding: capture id-bearing constraint field values resolved against VP1 and additively merge // them into the credential_selection map for VP2. The submission tells us which credential satisfied each // input descriptor; we use that to walk the PD's id-bearing fields and extract their matched values. - credentialMap, err := vp1Submission.ResolveVP(*vp1) + envelope, err := pe.NewEnvelopeFromVP(*vp1) + if err != nil { + return nil, fmt.Errorf("build VP1 envelope for cross-VP binding: %w", err) + } + credentialMap, err := vp1Submission.Resolve(*envelope) if err != nil { return nil, fmt.Errorf("resolve VP1 submission for cross-VP binding: %w", err) } diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go index 1087bf41ba..fcd011152f 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -163,22 +163,6 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis return presentationSubmission, signInstruction, nil } -// ResolveVP is a convenience wrapper around Resolve for callers that already hold a single parsed -// VerifiablePresentation in memory (e.g. a freshly built submission from the wallet) and would otherwise -// need to round-trip through Raw() + ParseEnvelope just to call Resolve. Returns a map keyed by -// InputDescriptor.Id; see Resolve for the underlying matching rules. -func (s PresentationSubmission) ResolveVP(presentation vc.VerifiablePresentation) (map[string]vc.VerifiableCredential, error) { - asInterface, err := vpAsInterface(presentation) - if err != nil { - return nil, err - } - return s.Resolve(Envelope{ - Presentations: []vc.VerifiablePresentation{presentation}, - asInterface: asInterface, - raw: []byte(presentation.Raw()), - }) -} - // Resolve returns a map where each of the input descriptors is mapped to the corresponding VerifiableCredential. // If an input descriptor can't be mapped to a VC, an error is returned. // This function is specified by https://identity.foundation/presentation-exchange/#processing-of-submission-entries diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go index 962369fcf4..787e3169cf 100644 --- a/vcr/pe/presentation_submission_test.go +++ b/vcr/pe/presentation_submission_test.go @@ -277,10 +277,11 @@ func TestPresentationSubmissionBuilder_SetCredentialSelector(t *testing.T) { }) } -func TestPresentationSubmission_ResolveVP(t *testing.T) { - t.Run("resolves descriptors against a single parsed VP without round-tripping through Envelope", func(t *testing.T) { - // Caller already has a *VerifiablePresentation in memory (e.g. just built by the wallet); ResolveVP - // must produce the same descriptor → credential mapping that Resolve(envelope) would. +func TestNewEnvelopeFromVP(t *testing.T) { + t.Run("wraps a parsed VP into an Envelope that PresentationSubmission.Resolve accepts", func(t *testing.T) { + // Caller already has a *VerifiablePresentation in memory (e.g. just built by the wallet); + // NewEnvelopeFromVP must produce an Envelope equivalent to ParseEnvelope([]byte(vp.Raw())) so that + // Resolve sees the same descriptor → credential mapping. vpRaw := `{ "@context": ["https://www.w3.org/2018/credentials/v1"], "type": ["VerifiablePresentation"], @@ -302,7 +303,9 @@ func TestPresentationSubmission_ResolveVP(t *testing.T) { }, } - credentials, err := submission.ResolveVP(*presentation) + envelope, err := NewEnvelopeFromVP(*presentation) + require.NoError(t, err) + credentials, err := submission.Resolve(*envelope) require.NoError(t, err) require.Contains(t, credentials, "id_org_cred") diff --git a/vcr/pe/util.go b/vcr/pe/util.go index 024a1d57e3..2c8fd21ab1 100644 --- a/vcr/pe/util.go +++ b/vcr/pe/util.go @@ -93,6 +93,21 @@ func ParseEnvelope(envelopeBytes []byte) (*Envelope, error) { }, nil } +// NewEnvelopeFromVP wraps a single Verifiable Presentation in an Envelope without re-parsing the source +// bytes. Convenient for callers that already hold a parsed VP in memory and would otherwise round-trip +// through Raw() + ParseEnvelope just to feed PresentationSubmission.Resolve. +func NewEnvelopeFromVP(presentation vc.VerifiablePresentation) (*Envelope, error) { + asInterface, err := vpAsInterface(presentation) + if err != nil { + return nil, err + } + return &Envelope{ + Presentations: []vc.VerifiablePresentation{presentation}, + asInterface: asInterface, + raw: []byte(presentation.Raw()), + }, nil +} + // parseEnvelopeEntry parses a single Verifiable Presentation in a Presentation Exchange envelope. // It takes into account custom unmarshalling required for JWT VPs. func parseJSONArrayEnvelope(arr []interface{}) (interface{}, []vc.VerifiablePresentation, error) { From 4f592ca6cf606c0be3ed9fab93746f54215814e3 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:10:49 +0200 Subject: [PATCH 32/37] Spell out the jwt-bearer URN in the AS-doesn't-advertise error Per @reinkrul: include the spec'd grant-type URN in the error description so EHR developers can grep for it directly in the AS's metadata response. The new wording reads: authorization server does not advertise "urn:ietf:params:oauth:grant-type:jwt-bearer" in grant_types_supported Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 2 +- auth/client/iam/openid4vp_test.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 4cfb1aa38d..4aa8f78c4b 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -263,7 +263,7 @@ func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientI if !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { return nil, oauth.OAuth2Error{ Code: oauth.UnsupportedGrantType, - Description: "authorization server does not advertise jwt-bearer support", + Description: fmt.Sprintf("authorization server does not advertise %q in grant_types_supported", oauth.JwtBearerGrantType), } } return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index be437c9072..60d0815574 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -445,7 +445,8 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { var oauthErr oauth.OAuth2Error require.ErrorAs(t, err, &oauthErr) assert.Equal(t, oauth.UnsupportedGrantType, oauthErr.Code) - assert.Contains(t, oauthErr.Description, "authorization server does not advertise jwt-bearer") + assert.Contains(t, oauthErr.Description, oauth.JwtBearerGrantType) + assert.Contains(t, oauthErr.Description, "grant_types_supported") }) t.Run("rejects the request when no service_provider PD is configured for the scope", func(t *testing.T) { From d88dc8f90336ef7c46659ea9bc752ac864be4677 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:17:08 +0200 Subject: [PATCH 33/37] Hoist the VP-assertion lifetime into a named constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @reinkrul: "don't we have a constant for this?" — there wasn't one. Both grant paths used `time.Now().Add(time.Second * 5)` inline. Pull out vpAssertionLifetime so the policy is documented once and a future change touches one line instead of two. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 4aa8f78c4b..10d6c96eca 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -51,6 +51,10 @@ import ( // ErrPreconditionFailed is returned when a precondition is not met. var ErrPreconditionFailed = errors.New("precondition failed") +// vpAssertionLifetime is the validity window of a Verifiable Presentation built for a token request. +// Short by design: the VP is signed and posted within the same call. +const vpAssertionLifetime = 5 * time.Second + var _ Client = (*OpenID4VPClient)(nil) type OpenID4VPClient struct { @@ -287,7 +291,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI params := holder.BuildParams{ Audience: authServerURL, DIDMethods: metadata.DIDMethodsSupported, - Expires: time.Now().Add(time.Second * 5), + Expires: time.Now().Add(vpAssertionLifetime), Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } @@ -360,7 +364,7 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje params := holder.BuildParams{ Audience: authServerURL, DIDMethods: metadata.DIDMethodsSupported, - Expires: time.Now().Add(time.Second * 5), + Expires: time.Now().Add(vpAssertionLifetime), Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } From 262aade3ff3ce3408945d4b8c4d014fff5636f94 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:24:21 +0200 Subject: [PATCH 34/37] Generate a fresh nonce for VP2 in the jwt-bearer flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both wallet.BuildSubmission calls used the same params struct, so VP2 was signed with VP1's nonce. Per spec each Verifiable Presentation must carry its own nonce; reuse would let a verifier confuse the two assertions or accept a replayed pairing. Regenerate params.Nonce between the VP1 and VP2 builds. Expires is intentionally left untouched — the two calls run within milliseconds and the lifetime is already short. A focused subtest captures both BuildSubmission params and asserts the nonces differ. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 2 ++ auth/client/iam/openid4vp_test.go | 44 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 10d6c96eca..2c2353d142 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -388,6 +388,8 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje return nil, fmt.Errorf("resolve VP1 constraint fields for cross-VP binding: %w", err) } credentialSelection = applyCapturedFieldsToSelection(credentialSelection, captured) + // Each VP must carry its own nonce; reuse would let a verifier confuse the two assertions. + params.Nonce = nutsCrypto.GenerateNonce() vp2, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, 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 60d0815574..c922e27245 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -519,6 +519,50 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) }) + t.Run("VP1 and VP2 carry distinct nonces", func(t *testing.T) { + // Each VP must be signed with a fresh nonce. Reusing the same nonce would let a verifier mistakenly + // treat the two assertions as a single signed payload, or accept a replayed pairing of VP1 with a + // stale VP2 (or vice versa). + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // Capture both BuildSubmission's params arguments so we can compare nonces directly. + var vp1Params, vp2Params holder.BuildParams + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, p holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + vp1Params = p + return vp1, &pe.PresentationSubmission{}, nil + }) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, p holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + vp2Params = p + return vp2, &pe.PresentationSubmission{}, nil + }) + + _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + require.NotEmpty(t, vp1Params.Nonce) + require.NotEmpty(t, vp2Params.Nonce) + assert.NotEqual(t, vp1Params.Nonce, vp2Params.Nonce, "VP2 must be signed with a fresh nonce") + }) + t.Run("captured VP1 field-id values flow into VP2 credential_selection end-to-end", func(t *testing.T) { // Cross-VP binding scenario, end to end: when the organization PD and the service_provider PD share // the same constraint-field `id` (here: "delegating_hcp"), the value matched in VP1 must flow into From 410e67aeeafc165112bd8d4a02cb7b052de0881f Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:33:23 +0200 Subject: [PATCH 35/37] Surface the JSONPath in cross-VP binding errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @reinkrul: "EHR developers will have no idea what they're doing wrong here." The cross-VP-binding error chain only carried the input descriptor ID and the credential ID; the failing JSONPath — the most useful piece for whoever is fixing the policy file — was inside the wrapped error, but unattributed. Two changes, one direction: - vcr/pe/matchField now wraps getValueAtPath / matchFilter errors with `path %q: %w`, so the path that failed is part of the chain for every caller (introspection, server-side, this PR's two-VP). - The three cross-VP error sites in requestJwtBearerAccessToken (envelope-build, submission-resolve, constraint-fields-resolve) return typed oauth.OAuth2Error{ServerError} with operator-facing descriptions naming the artefact ("organization presentation definition" for the constraint-fields case) so the API layer maps to a recognisable status code. Sample chain end to end: failed to extract cross-VP binding values from VP1 against the organization presentation definition: failed to match constraint for input descriptor 'id_org_cred' and credential 'urn:vc:42': path "$.issuer": Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 15 ++++++++++++--- vcr/pe/presentation_definition.go | 4 ++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 2c2353d142..eed07eb148 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -377,15 +377,24 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje // input descriptor; we use that to walk the PD's id-bearing fields and extract their matched values. envelope, err := pe.NewEnvelopeFromVP(*vp1) if err != nil { - return nil, fmt.Errorf("build VP1 envelope for cross-VP binding: %w", err) + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("failed to wrap VP1 in an envelope for cross-VP binding: %s", err), + } } credentialMap, err := vp1Submission.Resolve(*envelope) if err != nil { - return nil, fmt.Errorf("resolve VP1 submission for cross-VP binding: %w", err) + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("failed to resolve VP1 submission for cross-VP binding: %s", err), + } } captured, err := orgPD.ResolveConstraintsFields(credentialMap) if err != nil { - return nil, fmt.Errorf("resolve VP1 constraint fields for cross-VP binding: %w", err) + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("failed to extract cross-VP binding values from VP1 against the organization presentation definition: %s", err), + } } credentialSelection = applyCapturedFieldsToSelection(credentialSelection, captured) // Each VP must carry its own nonce; reuse would let a verifier confuse the two assertions. diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 1b982e4f43..f204463f13 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -446,7 +446,7 @@ func matchField(field Field, credential map[string]interface{}) (bool, interface // if path is not found continue value, err := getValueAtPath(path, credential) if err != nil { - return false, nil, err + return false, nil, fmt.Errorf("path %q: %w", path, err) } if value == nil { continue @@ -459,7 +459,7 @@ func matchField(field Field, credential map[string]interface{}) (bool, interface // if filter at path matches return true match, matchedValue, err := matchFilter(*field.Filter, value) if err != nil { - return false, nil, err + return false, nil, fmt.Errorf("path %q: %w", path, err) } if match { return true, matchedValue, nil From 7348a9c6f0f04ce54dc18327c09b273be92e2f19 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:46:13 +0200 Subject: [PATCH 36/37] Rename vp1/vp2 locals to organizationVP/serviceProviderVP @reinkrul: VP1/VP2 are non-descriptive. Rename the local variables in requestJwtBearerAccessToken and the TwoVP test fixtures to use the role of each VP at the call site: vp1 -> organizationVP vp1Submission -> organizationSubmission vp2 -> serviceProviderVP vp1Params -> organizationVPParams vp2Params -> serviceProviderVPParams The trace log labels and the cross-VP test's chain comment update to match. No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 29 ++++++------- auth/client/iam/openid4vp_test.go | 70 ++++++++++++++++--------------- 2 files changed, 51 insertions(+), 48 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index eed07eb148..fa91b28f8d 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -368,44 +368,45 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } - vp1, vp1Submission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params) + organizationVP, organizationSubmission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } - // Cross-VP binding: capture id-bearing constraint field values resolved against VP1 and additively merge - // them into the credential_selection map for VP2. The submission tells us which credential satisfied each - // input descriptor; we use that to walk the PD's id-bearing fields and extract their matched values. - envelope, err := pe.NewEnvelopeFromVP(*vp1) + // Cross-VP binding: capture id-bearing constraint field values resolved against the organization VP + // and additively merge them into the credential_selection map for the service-provider VP. The + // submission tells us which credential satisfied each input descriptor; we use that to walk the PD's + // id-bearing fields and extract their matched values. + envelope, err := pe.NewEnvelopeFromVP(*organizationVP) if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.ServerError, - Description: fmt.Sprintf("failed to wrap VP1 in an envelope for cross-VP binding: %s", err), + Description: fmt.Sprintf("failed to wrap the organization VP in an envelope for cross-VP binding: %s", err), } } - credentialMap, err := vp1Submission.Resolve(*envelope) + credentialMap, err := organizationSubmission.Resolve(*envelope) if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.ServerError, - Description: fmt.Sprintf("failed to resolve VP1 submission for cross-VP binding: %s", err), + Description: fmt.Sprintf("failed to resolve the organization VP submission for cross-VP binding: %s", err), } } captured, err := orgPD.ResolveConstraintsFields(credentialMap) if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.ServerError, - Description: fmt.Sprintf("failed to extract cross-VP binding values from VP1 against the organization presentation definition: %s", err), + Description: fmt.Sprintf("failed to extract cross-VP binding values from the organization VP against the organization presentation definition: %s", err), } } credentialSelection = applyCapturedFieldsToSelection(credentialSelection, captured) // Each VP must carry its own nonce; reuse would let a verifier confuse the two assertions. params.Nonce = nutsCrypto.GenerateNonce() - vp2, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params) + serviceProviderVP, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } // DPoP binds the issued access token to a key the service provider controls — the SP wallet will // present and use the token, so the proof is signed with the SP DID's key. - spDID, err := did.ParseDID(vp2.Holder.String()) + spDID, err := did.ParseDID(serviceProviderVP.Holder.String()) if err != nil { return nil, err } @@ -415,12 +416,12 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje } data := url.Values{} data.Set(oauth.GrantTypeParam, oauth.JwtBearerGrantType) - data.Set(oauth.AssertionParam, vp1.Raw()) + data.Set(oauth.AssertionParam, organizationVP.Raw()) data.Set(oauth.ClientAssertionTypeParam, oauth.JwtBearerClientAssertionType) - data.Set(oauth.ClientAssertionParam, vp2.Raw()) + data.Set(oauth.ClientAssertionParam, serviceProviderVP.Raw()) data.Set(oauth.ScopeParam, resolvedScope) - log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n VP1: %s\n VP2: %s", metadata.TokenEndpoint, resolvedScope, vp1.Raw(), vp2.Raw()) + log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n organization VP: %s\n service provider VP: %s", metadata.TokenEndpoint, resolvedScope, organizationVP.Raw(), serviceProviderVP.Raw()) token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) if err != nil { return nil, err diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index c922e27245..4c13395680 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -474,9 +474,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") spDID := did.MustParseDID("did:test:sp") - vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) require.NoError(t, err) - vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) ctx := createClientServerTestContext(t) @@ -493,9 +493,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) // VP1 is built from the HCP wallet using the organization PD; VP2 from the SP wallet using the service_provider PD. ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), - pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(vp1, &pe.PresentationSubmission{}, nil) + pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), - pe.PresentationDefinition{Id: "sp_pd"}, gomock.Any(), gomock.Any()).Return(vp2, &pe.PresentationSubmission{}, nil) + pe.PresentationDefinition{Id: "sp_pd"}, gomock.Any(), gomock.Any()).Return(serviceProviderVP, &pe.PresentationSubmission{}, nil) var capturedForm url.Values ctx.token = func(writer http.ResponseWriter, request *http.Request) { @@ -511,9 +511,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { require.NoError(t, err) require.NotNil(t, response) assert.Equal(t, oauth.JwtBearerGrantType, capturedForm.Get(oauth.GrantTypeParam)) - assert.Equal(t, vp1.Raw(), capturedForm.Get(oauth.AssertionParam)) + assert.Equal(t, organizationVP.Raw(), capturedForm.Get(oauth.AssertionParam)) assert.Equal(t, oauth.JwtBearerClientAssertionType, capturedForm.Get(oauth.ClientAssertionTypeParam)) - assert.Equal(t, vp2.Raw(), capturedForm.Get(oauth.ClientAssertionParam)) + assert.Equal(t, serviceProviderVP.Raw(), capturedForm.Get(oauth.ClientAssertionParam)) assert.Empty(t, capturedForm.Get(oauth.PresentationSubmissionParam)) // Per RFC 7521 §4.2 client_id is optional when client_assertion is present and we omit it. assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) @@ -526,9 +526,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") spDID := did.MustParseDID("did:test:sp") - vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) require.NoError(t, err) - vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) ctx := createClientServerTestContext(t) enableJwtBearerClient(t, ctx) @@ -543,38 +543,40 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) // Capture both BuildSubmission's params arguments so we can compare nonces directly. - var vp1Params, vp2Params holder.BuildParams + var organizationVPParams, serviceProviderVPParams holder.BuildParams ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, p holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { - vp1Params = p - return vp1, &pe.PresentationSubmission{}, nil + organizationVPParams = p + return organizationVP, &pe.PresentationSubmission{}, nil }) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, p holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { - vp2Params = p - return vp2, &pe.PresentationSubmission{}, nil + serviceProviderVPParams = p + return serviceProviderVP, &pe.PresentationSubmission{}, nil }) _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.NoError(t, err) - require.NotEmpty(t, vp1Params.Nonce) - require.NotEmpty(t, vp2Params.Nonce) - assert.NotEqual(t, vp1Params.Nonce, vp2Params.Nonce, "VP2 must be signed with a fresh nonce") + require.NotEmpty(t, organizationVPParams.Nonce) + require.NotEmpty(t, serviceProviderVPParams.Nonce) + assert.NotEqual(t, organizationVPParams.Nonce, serviceProviderVPParams.Nonce, "VP2 must be signed with a fresh nonce") }) t.Run("captured VP1 field-id values flow into VP2 credential_selection end-to-end", func(t *testing.T) { // Cross-VP binding scenario, end to end: when the organization PD and the service_provider PD share - // the same constraint-field `id` (here: "delegating_hcp"), the value matched in VP1 must flow into - // VP2's credential_selection so the wallet building VP2 can pick a credential constrained by that - // value. This test follows the chain through requestJwtBearerAccessToken: + // the same constraint-field `id` (here: "delegating_hcp"), the value matched in the organization VP + // must flow into the service-provider VP's credential_selection so the wallet building it can pick + // a credential constrained by that value. This test follows the chain through + // requestJwtBearerAccessToken: // - // VP1 + vp1Submission --ResolveVP--> credentialMap (which VC satisfied which input descriptor) + // organizationVP + organizationSubmission --NewEnvelopeFromVP+Resolve--> credentialMap // credentialMap + orgPD --ResolveConstraintsFields--> {delegating_hcp: did:test:hcp} - // --applyCapturedFieldsToSelection--> credential_selection passed to VP2's BuildSubmission + // --applyCapturedFieldsToSelection--> credential_selection passed to the service-provider + // wallet's BuildSubmission // // TestApplyCapturedFieldsToSelection covers the merge step in isolation; this test exists to guard - // the wiring from BuildSubmission's return values into VP2's BuildSubmission argument. + // the wiring from the first BuildSubmission's return values into the second BuildSubmission's args. sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") @@ -584,7 +586,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { // one credential whose $.issuer is the HCP DID — that value is what the binding will capture. // `holder` is set so the DPoP code path (which derives a signing DID from vp.Holder) doesn't panic // even though we don't enable DPoP in this test. - vp1, err := vc.ParseVerifiablePresentation(`{ + organizationVP, err := vc.ParseVerifiablePresentation(`{ "@context": ["https://www.w3.org/2018/credentials/v1"], "type": ["VerifiablePresentation"], "holder": "did:test:hcp", @@ -599,14 +601,14 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { }`) require.NoError(t, err) // VP2's body is irrelevant for this test — we only assert what VP2's BuildSubmission was *called* - // with; we never inspect vp2 itself afterwards. The holder is set for the same DPoP-safety reason. - vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + // with; we never inspect serviceProviderVP itself afterwards. The holder is set for the same DPoP-safety reason. + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) - // vp1Submission tells the resolver "input descriptor id_org_cred was satisfied by the credential at + // organizationSubmission tells the resolver "input descriptor id_org_cred was satisfied by the credential at // $.verifiableCredential[0]". Without this, ResolveVP can't bridge from a descriptor id to a VC, and // ResolveConstraintsFields has nothing to walk for $.issuer. - vp1Submission := &pe.PresentationSubmission{ + organizationSubmission := &pe.PresentationSubmission{ DescriptorMap: []pe.InputDescriptorMappingObject{ {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, }, @@ -650,7 +652,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { // First BuildSubmission call: build VP1 against the HCP DID using orgPD. We return the JSON-LD VP // and its submission so the production code can resolve constraint fields against them. ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), - orgPD, gomock.Any(), gomock.Any()).Return(vp1, vp1Submission, nil) + orgPD, gomock.Any(), gomock.Any()).Return(organizationVP, organizationSubmission, nil) // Second BuildSubmission call: build VP2 against the SP DID using spPD. We capture the // credential_selection argument so the assertion at the bottom can verify the merged field-id // value made it through the orchestration. @@ -659,7 +661,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { spPD, gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, sel map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { capturedSPSelection = sel - return vp2, &pe.PresentationSubmission{}, nil + return serviceProviderVP, &pe.PresentationSubmission{}, nil }) _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) @@ -672,13 +674,13 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { t.Run("ok with DPoPHeader", func(t *testing.T) { // DPoP binds the issued access token to a key the SP wallet controls — the proof must be signed with - // the SP DID's key (vp2.Holder), not the HCP DID's key. + // the SP DID's key (serviceProviderVP.Holder), not the HCP DID's key. sp := spSubjectID spDID := did.MustParseDID("did:test:sp") spKID := "did:test:sp#1" - vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) require.NoError(t, err) - vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) ctx := createClientServerTestContext(t) enableJwtBearerClient(t, ctx) @@ -692,8 +694,8 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { }, nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{did.MustParseDID("did:test:hcp")}, nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(vp1, &pe.PresentationSubmission{}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(vp2, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(serviceProviderVP, &pe.PresentationSubmission{}, nil) // The DPoP signing key must be resolved against the SP DID, not the HCP DID — that's the assertion. ctx.keyResolver.EXPECT().ResolveKey(spDID, nil, resolver.NutsSigningKeyType).Return(spKID, nil, nil) ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), spKID).Return("dpop", nil) From e898ff3cb042473bab27db5145a4a0cf2bb49ab5 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:56:27 +0200 Subject: [PATCH 37/37] Rename Client.RequestServiceAccessToken's subjectDID parameter to subjectID @reinkrul: the interface parameter was named subjectDID, but it actually receives a subject identifier (a string used to look up the subject's wallet DIDs), not a DID itself. The implementation has always called it subjectID. Match the names. Mock regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/interface.go | 2 +- auth/client/iam/mock.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index d26eb6004f..e91e1e1ddd 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -52,7 +52,7 @@ type Client interface { // offered to both wallets; each PD selects what matches its input descriptors. Signed VCs flow through unchanged; // unsigned self-attested credentials are auto-issued per holder DID by AutoCorrectSelfAttestedCredential. // credentialSelection maps PD field IDs to expected values to disambiguate when multiple credentials match an input descriptor. - RequestServiceAccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, + RequestServiceAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 3a734e2fd2..dc22ba0484 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -194,18 +194,18 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet } // RequestServiceAccessToken mocks base method. -func (m *MockClient) RequestServiceAccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { +func (m *MockClient) RequestServiceAccessToken(ctx context.Context, clientID, subjectID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestServiceAccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) + ret := m.ctrl.Call(m, "RequestServiceAccessToken", ctx, clientID, subjectID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // RequestServiceAccessToken indicates an expected call of RequestServiceAccessToken. -func (mr *MockClientMockRecorder) RequestServiceAccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID any) *gomock.Call { +func (mr *MockClientMockRecorder) RequestServiceAccessToken(ctx, clientID, subjectID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestServiceAccessToken", reflect.TypeOf((*MockClient)(nil).RequestServiceAccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestServiceAccessToken", reflect.TypeOf((*MockClient)(nil).RequestServiceAccessToken), ctx, clientID, subjectID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) } // VerifiableCredentials mocks base method.