diff --git a/auth/auth.go b/auth/auth.go index f135335c0..e869f35f8 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 968ea61ef..8ae69fbd8 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 bf7f8fef6..a5a2728d1 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 f4a725a09..118b2aea0 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/client/iam/pd_resolver.go b/auth/client/iam/pd_resolver.go new file mode 100644 index 000000000..4cdce7c92 --- /dev/null +++ b/auth/client/iam/pd_resolver.go @@ -0,0 +1,108 @@ +/* + * 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) { + 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) + } + 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 &ResolvedPresentationDefinition{ + PresentationDefinition: pd, + Scope: resolvedScope, + }, nil +} diff --git a/auth/client/iam/pd_resolver_test.go b/auth/client/iam/pd_resolver_test.go new file mode 100644 index 000000000..5f7c6255e --- /dev/null +++ b/auth/client/iam/pd_resolver_test.go @@ -0,0 +1,189 @@ +/* + * 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" + "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" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +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) + }) + 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) + }) + 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) + }) +} diff --git a/auth/test.go b/auth/test.go index 4142046cd..cd1940af3 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 b2737c9b3..4cce7e287 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()}