From cabd34a8e574682c21b71049d1281a32add339d4 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:24 +0200 Subject: [PATCH 1/6] Initialize branch for PR From b64927e5984000eee98ff7c9002250f1fcce4fe5 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Apr 2026 16:08:57 +0200 Subject: [PATCH 2/6] Add PresentationDefinitionResolver with remote PD support Introduces PresentationDefinitionResolver that abstracts PD resolution. When the remote AS metadata advertises a PD endpoint, the PD is fetched remotely and the full scope string is returned for the token request. Local fallback path is stubbed for the next cycle. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/client/iam/pd_resolver.go | 99 +++++++++++++++++++++++++++++ auth/client/iam/pd_resolver_test.go | 69 ++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 auth/client/iam/pd_resolver.go create mode 100644 auth/client/iam/pd_resolver_test.go diff --git a/auth/client/iam/pd_resolver.go b/auth/client/iam/pd_resolver.go new file mode 100644 index 0000000000..3dd78d76e4 --- /dev/null +++ b/auth/client/iam/pd_resolver.go @@ -0,0 +1,99 @@ +/* + * 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/core" + nutsHttp "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) + +// ResolvedPresentationDefinition contains a resolved PD and the scope to use in the token request. +type ResolvedPresentationDefinition struct { + PresentationDefinition pe.PresentationDefinition + // Scope is the scope string to include in the token request. + // When resolved remotely, this is the original scope string (remote AS handles scope policy). + // When resolved locally, this depends on the configured scope policy. + Scope string +} + +// PresentationDefinitionResolver resolves a PresentationDefinition for a given scope string. +// It uses the remote AS's PD endpoint when available, falling back to local policy resolution. +type PresentationDefinitionResolver struct { + httpClient HTTPClient + policyBackend policy.PDPBackend + strictMode bool +} + +// Resolve resolves a PresentationDefinition for the given scope string. +// If the remote AS metadata advertises a PD endpoint, the PD is fetched remotely +// and the full scope string is returned (remote AS handles scope policy). +// If no PD endpoint is available, the local policy backend is used and scope policy is enforced. +func (r *PresentationDefinitionResolver) Resolve(ctx context.Context, scope string, metadata oauth.AuthorizationServerMetadata) (*ResolvedPresentationDefinition, error) { + if metadata.PresentationDefinitionEndpoint != "" { + return r.resolveRemote(ctx, scope, metadata) + } + return r.resolveLocal(ctx, scope) +} + +func (r *PresentationDefinitionResolver) resolveRemote(ctx context.Context, scope string, metadata oauth.AuthorizationServerMetadata) (*ResolvedPresentationDefinition, error) { + parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, r.strictMode) + if err != nil { + return nil, err + } + pdURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{ + "scope": scope, + }) + pd, err := r.httpClient.PresentationDefinition(ctx, pdURL) + if err != nil { + return nil, err + } + return &ResolvedPresentationDefinition{ + PresentationDefinition: *pd, + Scope: scope, + }, nil +} + +func (r *PresentationDefinitionResolver) resolveLocal(ctx context.Context, scope string) (*ResolvedPresentationDefinition, error) { + match, err := r.policyBackend.FindCredentialProfile(ctx, 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) + } + return &ResolvedPresentationDefinition{ + PresentationDefinition: pd, + Scope: scope, + }, nil +} diff --git a/auth/client/iam/pd_resolver_test.go b/auth/client/iam/pd_resolver_test.go new file mode 100644 index 0000000000..1531ceef31 --- /dev/null +++ b/auth/client/iam/pd_resolver_test.go @@ -0,0 +1,69 @@ +/* + * 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" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/http/client" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "time" +) + +var testPD = pe.PresentationDefinition{ + Id: "test-pd", + InputDescriptors: []*pe.InputDescriptor{ + {Id: "id1"}, + }, +} + +func TestPresentationDefinitionResolver_Resolve(t *testing.T) { + t.Run("remote PD endpoint exists - fetches from remote and returns full scope", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/presentation_definition", r.URL.Path) + assert.Equal(t, "profile-scope extra-scope", r.URL.Query().Get("scope")) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(testPD) + })) + defer server.Close() + + resolver := &PresentationDefinitionResolver{ + httpClient: HTTPClient{ + strictMode: false, + httpClient: client.New(10 * time.Second), + }, + } + metadata := oauth.AuthorizationServerMetadata{ + PresentationDefinitionEndpoint: server.URL + "/presentation_definition", + } + + result, err := resolver.Resolve(context.Background(), "profile-scope extra-scope", metadata) + + require.NoError(t, err) + assert.Equal(t, "test-pd", result.PresentationDefinition.Id) + assert.Equal(t, "profile-scope extra-scope", result.Scope) + }) +} From 747fd27a85b71f5c79076a5adb8bb6f652c5f09a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Apr 2026 16:13:50 +0200 Subject: [PATCH 3/6] Add local PD fallback and scope policy enforcement to resolver When no remote PD endpoint exists, the resolver calls FindCredentialProfile locally. Profile-only rejects extra scopes, passthrough/dynamic forward all. Tests cover both remote and local paths with all scope policies. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/client/iam/pd_resolver_test.go | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/auth/client/iam/pd_resolver_test.go b/auth/client/iam/pd_resolver_test.go index 1531ceef31..464f6831d7 100644 --- a/auth/client/iam/pd_resolver_test.go +++ b/auth/client/iam/pd_resolver_test.go @@ -27,9 +27,11 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/http/client" + "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" "time" ) @@ -66,4 +68,82 @@ func TestPresentationDefinitionResolver_Resolve(t *testing.T) { assert.Equal(t, "test-pd", result.PresentationDefinition.Id) assert.Equal(t, "profile-scope extra-scope", result.Scope) }) + t.Run("no remote PD endpoint", func(t *testing.T) { + metadata := oauth.AuthorizationServerMetadata{} // no PD endpoint + + t.Run("single scope, profile-only", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockPolicy := policy.NewMockPDPBackend(ctrl) + mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "profile-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "profile-scope", + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: testPD}, + ScopePolicy: policy.ScopePolicyProfileOnly, + }, nil) + + resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy} + result, err := resolver.Resolve(context.Background(), "profile-scope", metadata) + + require.NoError(t, err) + assert.Equal(t, "test-pd", result.PresentationDefinition.Id) + assert.Equal(t, "profile-scope", result.Scope) + }) + t.Run("multi-scope, profile-only rejects", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockPolicy := policy.NewMockPDPBackend(ctrl) + mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "profile-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "profile-scope", + OtherScopes: []string{"extra-scope"}, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: testPD}, + ScopePolicy: policy.ScopePolicyProfileOnly, + }, nil) + + resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy} + _, err := resolver.Resolve(context.Background(), "profile-scope extra-scope", metadata) + + assert.ErrorContains(t, err, "does not allow additional scopes") + }) + t.Run("multi-scope, passthrough forwards all scopes", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockPolicy := policy.NewMockPDPBackend(ctrl) + mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "profile-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "profile-scope", + OtherScopes: []string{"extra-scope"}, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: testPD}, + ScopePolicy: policy.ScopePolicyPassthrough, + }, nil) + + resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy} + result, err := resolver.Resolve(context.Background(), "profile-scope extra-scope", metadata) + + require.NoError(t, err) + assert.Equal(t, "test-pd", result.PresentationDefinition.Id) + assert.Equal(t, "profile-scope extra-scope", result.Scope) + }) + t.Run("multi-scope, dynamic forwards all scopes", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockPolicy := policy.NewMockPDPBackend(ctrl) + mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "profile-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "profile-scope", + OtherScopes: []string{"extra-scope"}, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: testPD}, + ScopePolicy: policy.ScopePolicyDynamic, + }, nil) + + resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy} + result, err := resolver.Resolve(context.Background(), "profile-scope extra-scope", metadata) + + require.NoError(t, err) + assert.Equal(t, "profile-scope extra-scope", result.Scope) + }) + t.Run("unknown scope returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockPolicy := policy.NewMockPDPBackend(ctrl) + mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "unknown").Return(nil, policy.ErrNotFound) + + resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy} + _, err := resolver.Resolve(context.Background(), "unknown", metadata) + + assert.ErrorIs(t, err, policy.ErrNotFound) + }) + }) } From 08e0232e2299ced5f16f1732697eacea62578d20 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Apr 2026 16:31:46 +0200 Subject: [PATCH 4/6] Wire PresentationDefinitionResolver into client-side token flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct PD fetch in RequestRFC021AccessToken with the PresentationDefinitionResolver. The resolver is a dependency on OpenID4VPClient, wired through Auth → NewClient. The policy backend is passed through Auth to enable local PD fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/auth.go | 7 +++++-- auth/auth_test.go | 6 +++--- auth/client/iam/openid4vp.go | 34 ++++++++++++++++--------------- auth/client/iam/openid4vp_test.go | 6 ++++++ auth/test.go | 2 +- cmd/root.go | 4 ++-- 6 files changed, 35 insertions(+), 24 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index f135335c01..e869f35f87 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -22,6 +22,7 @@ import ( "crypto/tls" "errors" "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/didjwk" "github.com/nuts-foundation/nuts-node/vdr/didkey" @@ -68,6 +69,7 @@ type Auth struct { httpClientTimeout time.Duration tlsConfig *tls.Config subjectManager didsubject.Manager + policyBackend policy.PDPBackend // configuredDIDMethods contains the DID methods that are configured in the Nuts node, // of which VDR will create DIDs. configuredDIDMethods []string @@ -100,7 +102,7 @@ func (auth *Auth) ContractNotary() services.ContractNotary { // NewAuthInstance accepts a Config with several Nuts Engines and returns an instance of Auth func NewAuthInstance(config Config, vdrInstance vdr.VDR, subjectManager didsubject.Manager, vcr vcr.VCR, keyStore crypto.KeyStore, - serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider) *Auth { + serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider, policyBackend policy.PDPBackend) *Auth { return &Auth{ config: config, jsonldManager: jsonldManager, @@ -110,6 +112,7 @@ func NewAuthInstance(config Config, vdrInstance vdr.VDR, subjectManager didsubje vcr: vcr, pkiProvider: pkiProvider, serviceResolver: serviceResolver, + policyBackend: policyBackend, shutdownFunc: func() {}, } } @@ -126,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.strictMode, auth.httpClientTimeout) + return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout) } // Configure the Auth struct by creating a validator and create an Irma server diff --git a/auth/auth_test.go b/auth/auth_test.go index 968ea61ef8..8ae69fbd89 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -47,7 +47,7 @@ func TestAuth_Configure(t *testing.T) { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() - i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock) + i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock, nil) require.NoError(t, i.Configure(tlsServerConfig)) }) @@ -61,7 +61,7 @@ func TestAuth_Configure(t *testing.T) { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() - i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock) + i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock, nil) require.NoError(t, i.Configure(tlsServerConfig)) }) @@ -119,7 +119,7 @@ func TestAuth_IAMClient(t *testing.T) { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() - i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, jsonld.NewTestJSONLDManager(t), pkiMock) + i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, jsonld.NewTestJSONLDManager(t), pkiMock, nil) assert.NotNil(t, i.IAMClient()) }) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index bf7f8fef68..a5a2728d14 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -25,6 +25,7 @@ import ( "errors" "fmt" "github.com/nuts-foundation/nuts-node/http/client" + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vdr/didsubject" "github.com/piprate/json-gold/ld" @@ -60,23 +61,30 @@ type OpenID4VPClient struct { wallet holder.Wallet ldDocumentLoader ld.DocumentLoader subjectManager didsubject.Manager + pdResolver PresentationDefinitionResolver } // NewClient returns an implementation of Holder func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectManager didsubject.Manager, jwtSigner nutsCrypto.JWTSigner, - ldDocumentLoader ld.DocumentLoader, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { + ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { + httpClient := HTTPClient{ + strictMode: strictMode, + httpClient: client.NewWithCache(httpClientTimeout), + keyResolver: keyResolver, + } return &OpenID4VPClient{ - httpClient: HTTPClient{ - strictMode: strictMode, - httpClient: client.NewWithCache(httpClientTimeout), - keyResolver: keyResolver, - }, + httpClient: httpClient, keyResolver: keyResolver, jwtSigner: jwtSigner, ldDocumentLoader: ldDocumentLoader, subjectManager: subjectManager, strictMode: strictMode, wallet: wallet, + pdResolver: PresentationDefinitionResolver{ + httpClient: httpClient, + policyBackend: policyBackend, + strictMode: strictMode, + }, } } @@ -242,18 +250,12 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return nil, err } - // get the presentation definition from the verifier - parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, c.strictMode) - if err != nil { - return nil, err - } - presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{ - "scope": scopes, - }) - presentationDefinition, err := c.PresentationDefinition(ctx, presentationDefinitionURL.String()) + // Resolve the presentation definition: from remote AS when available, local policy otherwise + resolved, err := c.pdResolver.Resolve(ctx, scopes, *metadata) if err != nil { return nil, err } + presentationDefinition := &resolved.PresentationDefinition params := holder.BuildParams{ Audience: authServerURL, @@ -312,7 +314,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType) data.Set(oauth.AssertionParam, assertion) data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) - data.Set(oauth.ScopeParam, scopes) + data.Set(oauth.ScopeParam, resolved.Scope) // create DPoP header var dpopHeader string diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index f4a725a09c..118b2aea02 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -491,6 +491,12 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon }, jwtSigner: jwtSigner, keyResolver: keyResolver, + pdResolver: PresentationDefinitionResolver{ + httpClient: HTTPClient{ + strictMode: false, + httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), + }, + }, }, jwtSigner: jwtSigner, keyResolver: keyResolver, diff --git a/auth/test.go b/auth/test.go index 4142046cdf..cd1940af32 100644 --- a/auth/test.go +++ b/auth/test.go @@ -44,5 +44,5 @@ func testInstance(t *testing.T, cfg Config) *Auth { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() subjectManager := didsubject.NewMockManager(ctrl) - return NewAuthInstance(cfg, vdrInstance, subjectManager, vcrInstance, cryptoInstance, nil, nil, pkiMock) + return NewAuthInstance(cfg, vdrInstance, subjectManager, vcrInstance, cryptoInstance, nil, nil, pkiMock, nil) } diff --git a/cmd/root.go b/cmd/root.go index b2737c9b3a..4cce7e287a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -201,11 +201,11 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { credentialInstance := vcr.NewVCRInstance(cryptoInstance, vdrInstance, networkInstance, jsonld, eventManager, storageInstance, pkiInstance) didmanInstance := didman.NewDidmanInstance(vdrInstance, credentialInstance, jsonld) discoveryInstance := discovery.New(storageInstance, credentialInstance, vdrInstance, vdrInstance) - authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance) + policyInstance := policy.New() + authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance, policyInstance) statusEngine := status.NewStatusEngine(system) metricsEngine := core.NewMetricsEngine() goldenHammer := golden_hammer.New(vdrInstance, didmanInstance) - policyInstance := policy.New() // Register HTTP routes didKeyResolver := resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()} From 0b190e6b03e5da4d1dd37317c97766ccc675a7d8 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Apr 2026 17:55:16 +0200 Subject: [PATCH 5/6] Apply self-review fixes to PresentationDefinitionResolver - Add nil guard on policyBackend in resolveLocal - Return canonical credential profile scope for profile-only (not raw input) - Add comment explaining dynamic treated same as passthrough on client side - Add tests: nil policy backend, missing org PD, remote endpoint error - Fix import grouping in test file Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/client/iam/pd_resolver.go | 11 +++++++- auth/client/iam/pd_resolver_test.go | 42 ++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/auth/client/iam/pd_resolver.go b/auth/client/iam/pd_resolver.go index 3dd78d76e4..4cdce7c927 100644 --- a/auth/client/iam/pd_resolver.go +++ b/auth/client/iam/pd_resolver.go @@ -76,6 +76,9 @@ 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) if err != nil { return nil, fmt.Errorf("local PD resolution failed: %w", err) @@ -92,8 +95,14 @@ func (r *PresentationDefinitionResolver) resolveLocal(ctx context.Context, scope 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 &ResolvedPresentationDefinition{ PresentationDefinition: pd, - Scope: scope, + Scope: resolvedScope, }, nil } diff --git a/auth/client/iam/pd_resolver_test.go b/auth/client/iam/pd_resolver_test.go index 464f6831d7..5f7c6255e1 100644 --- a/auth/client/iam/pd_resolver_test.go +++ b/auth/client/iam/pd_resolver_test.go @@ -24,6 +24,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/http/client" @@ -32,7 +33,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "time" ) var testPD = pe.PresentationDefinition{ @@ -145,5 +145,45 @@ func TestPresentationDefinitionResolver_Resolve(t *testing.T) { assert.ErrorIs(t, err, policy.ErrNotFound) }) + t.Run("no organization PD in wallet owner mapping returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockPolicy := policy.NewMockPDPBackend(ctrl) + mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "user-only-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "user-only-scope", + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerUser: testPD}, + ScopePolicy: policy.ScopePolicyProfileOnly, + }, nil) + + resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy} + _, err := resolver.Resolve(context.Background(), "user-only-scope", metadata) + + assert.ErrorContains(t, err, "no organization presentation definition") + }) + t.Run("nil policy backend returns error", func(t *testing.T) { + resolver := &PresentationDefinitionResolver{policyBackend: nil} + _, err := resolver.Resolve(context.Background(), "any-scope", metadata) + + assert.ErrorContains(t, err, "policy backend") + }) + }) + t.Run("remote PD endpoint returns error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + resolver := &PresentationDefinitionResolver{ + httpClient: HTTPClient{ + strictMode: false, + httpClient: client.New(10 * time.Second), + }, + } + metadata := oauth.AuthorizationServerMetadata{ + PresentationDefinitionEndpoint: server.URL + "/presentation_definition", + } + + _, err := resolver.Resolve(context.Background(), "scope", metadata) + + assert.Error(t, err) }) } From 4a920e32a021d41343ca04101aa78a5e8291c347 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 21 Apr 2026 11:32:23 +0200 Subject: [PATCH 6/6] Refactor PD resolver to delegate remote fetch to OpenID4VPClient resolveRemote duplicated parse + strictmode check + HTTP call that already lives in OpenID4VPClient.PresentationDefinition. Narrow the resolver's dependency from HTTPClient+strictMode to a small pdFetcher interface satisfied by OpenID4VPClient. The resolver now composes the URL (with scope query param) and delegates the fetch. - Resolver no longer imports core; strictmode enforcement moves to the single method that owns it (OpenID4VPClient.PresentationDefinition). - NewClient wires the resolver with self as pdFetcher. - Tests swap httptest.NewServer for a fake pdFetcher that captures the endpoint URL, letting us assert the scope query param directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 12 +++---- auth/client/iam/openid4vp_test.go | 33 ++++++++---------- auth/client/iam/pd_resolver.go | 20 ++++++----- auth/client/iam/pd_resolver_test.go | 53 +++++++++++------------------ 4 files changed, 52 insertions(+), 66 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index a5a2728d14..c0b90ef73f 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -72,7 +72,7 @@ func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectMa httpClient: client.NewWithCache(httpClientTimeout), keyResolver: keyResolver, } - return &OpenID4VPClient{ + client := &OpenID4VPClient{ httpClient: httpClient, keyResolver: keyResolver, jwtSigner: jwtSigner, @@ -80,12 +80,12 @@ func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectMa subjectManager: subjectManager, strictMode: strictMode, wallet: wallet, - pdResolver: PresentationDefinitionResolver{ - httpClient: httpClient, - policyBackend: policyBackend, - strictMode: strictMode, - }, } + client.pdResolver = PresentationDefinitionResolver{ + pdFetcher: client, + policyBackend: policyBackend, + } + return client } func (c *OpenID4VPClient) ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) { diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 118b2aea02..68203d791d 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -479,25 +479,22 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon } tlsConfig.InsecureSkipVerify = true - return &clientTestContext{ - audit: audit.TestContext(), - ctrl: ctrl, - client: &OpenID4VPClient{ - wallet: wallet, - subjectManager: subjectManager, - httpClient: HTTPClient{ - strictMode: false, - httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), - }, - jwtSigner: jwtSigner, - keyResolver: keyResolver, - pdResolver: PresentationDefinitionResolver{ - httpClient: HTTPClient{ - strictMode: false, - httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), - }, - }, + testClient := &OpenID4VPClient{ + wallet: wallet, + subjectManager: subjectManager, + httpClient: HTTPClient{ + strictMode: false, + httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), }, + jwtSigner: jwtSigner, + keyResolver: keyResolver, + } + testClient.pdResolver = PresentationDefinitionResolver{pdFetcher: testClient} + + return &clientTestContext{ + audit: audit.TestContext(), + ctrl: ctrl, + client: testClient, jwtSigner: jwtSigner, keyResolver: keyResolver, wallet: wallet, diff --git a/auth/client/iam/pd_resolver.go b/auth/client/iam/pd_resolver.go index 4cdce7c927..edce53eb21 100644 --- a/auth/client/iam/pd_resolver.go +++ b/auth/client/iam/pd_resolver.go @@ -21,9 +21,9 @@ package iam import ( "context" "fmt" + "net/url" "github.com/nuts-foundation/nuts-node/auth/oauth" - "github.com/nuts-foundation/nuts-node/core" nutsHttp "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -38,12 +38,16 @@ type ResolvedPresentationDefinition struct { Scope string } +// pdFetcher retrieves a PresentationDefinition from a fully-formed endpoint URL. +type pdFetcher interface { + PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error) +} + // PresentationDefinitionResolver resolves a PresentationDefinition for a given scope string. // It uses the remote AS's PD endpoint when available, falling back to local policy resolution. type PresentationDefinitionResolver struct { - httpClient HTTPClient + pdFetcher pdFetcher policyBackend policy.PDPBackend - strictMode bool } // Resolve resolves a PresentationDefinition for the given scope string. @@ -58,14 +62,12 @@ func (r *PresentationDefinitionResolver) Resolve(ctx context.Context, scope stri } func (r *PresentationDefinitionResolver) resolveRemote(ctx context.Context, scope string, metadata oauth.AuthorizationServerMetadata) (*ResolvedPresentationDefinition, error) { - parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, r.strictMode) + baseURL, err := url.Parse(metadata.PresentationDefinitionEndpoint) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid presentation definition endpoint: %w", err) } - pdURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{ - "scope": scope, - }) - pd, err := r.httpClient.PresentationDefinition(ctx, pdURL) + pdURL := nutsHttp.AddQueryParams(*baseURL, map[string]string{"scope": scope}) + pd, err := r.pdFetcher.PresentationDefinition(ctx, pdURL.String()) if err != nil { return nil, err } diff --git a/auth/client/iam/pd_resolver_test.go b/auth/client/iam/pd_resolver_test.go index 5f7c6255e1..c714bcc843 100644 --- a/auth/client/iam/pd_resolver_test.go +++ b/auth/client/iam/pd_resolver_test.go @@ -20,14 +20,10 @@ package iam import ( "context" - "encoding/json" - "net/http" - "net/http/httptest" + "errors" "testing" - "time" "github.com/nuts-foundation/nuts-node/auth/oauth" - "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/stretchr/testify/assert" @@ -35,6 +31,17 @@ import ( "go.uber.org/mock/gomock" ) +type fakePDFetcher struct { + lastEndpoint string + pd *pe.PresentationDefinition + err error +} + +func (f *fakePDFetcher) PresentationDefinition(_ context.Context, endpoint string) (*pe.PresentationDefinition, error) { + f.lastEndpoint = endpoint + return f.pd, f.err +} + var testPD = pe.PresentationDefinition{ Id: "test-pd", InputDescriptors: []*pe.InputDescriptor{ @@ -44,22 +51,10 @@ var testPD = pe.PresentationDefinition{ func TestPresentationDefinitionResolver_Resolve(t *testing.T) { t.Run("remote PD endpoint exists - fetches from remote and returns full scope", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "/presentation_definition", r.URL.Path) - assert.Equal(t, "profile-scope extra-scope", r.URL.Query().Get("scope")) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(testPD) - })) - defer server.Close() - - resolver := &PresentationDefinitionResolver{ - httpClient: HTTPClient{ - strictMode: false, - httpClient: client.New(10 * time.Second), - }, - } + fetcher := &fakePDFetcher{pd: &testPD} + resolver := &PresentationDefinitionResolver{pdFetcher: fetcher} metadata := oauth.AuthorizationServerMetadata{ - PresentationDefinitionEndpoint: server.URL + "/presentation_definition", + PresentationDefinitionEndpoint: "https://as.example.com/presentation_definition", } result, err := resolver.Resolve(context.Background(), "profile-scope extra-scope", metadata) @@ -67,6 +62,7 @@ func TestPresentationDefinitionResolver_Resolve(t *testing.T) { require.NoError(t, err) assert.Equal(t, "test-pd", result.PresentationDefinition.Id) assert.Equal(t, "profile-scope extra-scope", result.Scope) + assert.Equal(t, "https://as.example.com/presentation_definition?scope=profile-scope+extra-scope", fetcher.lastEndpoint) }) t.Run("no remote PD endpoint", func(t *testing.T) { metadata := oauth.AuthorizationServerMetadata{} // no PD endpoint @@ -167,23 +163,14 @@ func TestPresentationDefinitionResolver_Resolve(t *testing.T) { }) }) t.Run("remote PD endpoint returns error", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - resolver := &PresentationDefinitionResolver{ - httpClient: HTTPClient{ - strictMode: false, - httpClient: client.New(10 * time.Second), - }, - } + fetcher := &fakePDFetcher{err: errors.New("PDP call failed")} + resolver := &PresentationDefinitionResolver{pdFetcher: fetcher} metadata := oauth.AuthorizationServerMetadata{ - PresentationDefinitionEndpoint: server.URL + "/presentation_definition", + PresentationDefinitionEndpoint: "https://as.example.com/presentation_definition", } _, err := resolver.Resolve(context.Background(), "scope", metadata) - assert.Error(t, err) + assert.ErrorContains(t, err, "PDP call failed") }) }