From 8d0fcacc03eae4cf2fba346954d0c3e756f1ad20 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:12 +0200 Subject: [PATCH 01/34] Initialize branch for PR From c7fd6ae6a62547a861cbeb093b46bbfb5486c696 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 16:54:09 +0200 Subject: [PATCH 02/34] Rename PresentationDefinitions to FindCredentialProfile Rename the PDPBackend interface method and introduce new types (CredentialProfileMatch, ScopePolicy, credentialProfileConfig) to support mixed OAuth2 scopes. The policy config struct now uses explicit fields for organization/user PDs and scope_policy, defaulting to profile-only. All callers and mocks updated. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api.go | 4 +- auth/api/iam/api_test.go | 10 +-- auth/api/iam/openid4vp_test.go | 6 +- auth/api/iam/s2s_vptoken_test.go | 24 +++---- auth/api/iam/validation.go | 4 +- policy/cmd.go | 1 + policy/config.go | 8 +++ policy/interface.go | 37 +++++++++-- policy/local.go | 110 +++++++++++++++++++++++-------- policy/local_test.go | 24 ++++--- policy/mock.go | 15 ++--- 11 files changed, 167 insertions(+), 76 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index bedbba113d..6b8125bf02 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -703,7 +703,7 @@ func (r Wrapper) PresentationDefinition(ctx context.Context, request Presentatio return PresentationDefinition200JSONResponse(PresentationDefinition{}), nil } - mapping, err := r.policyBackend.PresentationDefinitions(ctx, request.Params.Scope) + match, err := r.policyBackend.FindCredentialProfile(ctx, request.Params.Scope) if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.InvalidScope, @@ -715,7 +715,7 @@ func (r Wrapper) PresentationDefinition(ctx context.Context, request Presentatio if request.Params.WalletOwnerType != nil { walletOwnerType = *request.Params.WalletOwnerType } - result, exists := mapping[walletOwnerType] + result, exists := match.WalletOwnerMapping[walletOwnerType] if !exists { return nil, oauthError(oauth.InvalidRequest, fmt.Sprintf("no presentation definition found for '%s' wallet", walletOwnerType)) } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 5be4de20e1..ba9be2cd51 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -190,7 +190,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("ok", func(t *testing.T) { test := newTestClient(t) - test.policy.EXPECT().PresentationDefinitions(gomock.Any(), "example-scope").Return(walletOwnerMapping, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope"}}) @@ -215,7 +215,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { walletOwnerMapping := pe.WalletOwnerMapping{pe.WalletOwnerUser: pe.PresentationDefinition{Id: "test"}} test := newTestClient(t) - test.policy.EXPECT().PresentationDefinitions(gomock.Any(), "example-scope").Return(walletOwnerMapping, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope", WalletOwnerType: &userWalletType}}) @@ -227,7 +227,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("err - unknown wallet type", func(t *testing.T) { test := newTestClient(t) - test.policy.EXPECT().PresentationDefinitions(gomock.Any(), "example-scope").Return(walletOwnerMapping, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope", WalletOwnerType: &userWalletType}}) @@ -238,7 +238,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("error - unknown scope", func(t *testing.T) { test := newTestClient(t) - test.policy.EXPECT().PresentationDefinitions(gomock.Any(), "unknown").Return(nil, policy.ErrNotFound) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "unknown").Return(nil, policy.ErrNotFound) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "unknown"}}) @@ -289,7 +289,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { OpenIDProvider: serverMetadata, }, } - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), "test").Return(pe.WalletOwnerMapping{pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "test"}}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "test"}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) ctx.iamClient.EXPECT().OpenIDConfiguration(gomock.Any(), holderURL.String()).Return(&configuration, nil) ctx.jar.EXPECT().Create(verifierDID, verifierURL.String(), holderClientID, gomock.Any()).DoAndReturn(func(client did.DID, clientID string, audience string, modifier requestObjectModifier) jarRequest { req := createJarRequest(client, clientID, audience, modifier) diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 1f0135363c..52e73713ff 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -107,7 +107,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("unknown scope", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(pe.WalletOwnerMapping{}, policy.ErrNotFound) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) params := defaultParams() params[oauth.ScopeParam] = "unknown" @@ -126,7 +126,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("failed to generate authorization request", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), "test").Return(pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) params := defaultParams() ctx.iamClient.EXPECT().OpenIDConfiguration(context.Background(), holderClientID).Return(&oauth.OpenIDConfiguration{ Metadata: oauth.EntityStatementMetadata{ @@ -142,7 +142,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("failed to resolve OpenID configuration", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), "test").Return(pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) params := defaultParams() ctx.iamClient.EXPECT().OpenIDConfiguration(context.Background(), holderClientID).Return(nil, assert.AnError) diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index 7cc4504b7c..a8f9e0ed22 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -118,7 +118,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("JSON-LD VP", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -165,7 +165,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { require.NoError(t, token.Set(jwt.AudienceKey, issuerClientID)) }, verifiableCredential) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -200,7 +200,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("replay attack (nonce is reused)", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil).Times(2) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil).Times(2) _, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) @@ -211,7 +211,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JSON-LD VP is missing nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { proof.Domain = &issuerClientID proof.Nonce = nil @@ -224,7 +224,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JSON-LD VP has empty nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { proof.Domain = &issuerClientID proof.Nonce = new(string) @@ -237,7 +237,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP is missing nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Remove("nonce") @@ -249,7 +249,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP has empty nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Set("nonce", "") @@ -261,7 +261,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP nonce is not a string", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Set("nonce", true) @@ -297,7 +297,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("VP verification fails", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid")) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -342,7 +342,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("unsupported scope", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), "everything").Return(nil, policy.ErrNotFound) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "everything").Return(nil, policy.ErrNotFound) resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, "everything", submissionJSON, presentation.Raw()) @@ -364,7 +364,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, otherVerifiableCredential) ctx := newTestClient(t) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - presentation submission does not conform to presentation definition (id=)") @@ -375,7 +375,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { httpRequest := &http.Request{Header: http.Header{"Dpop": []string{"invalid"}}} httpRequest.Header.Set("DPoP", "invalid") contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) - ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) diff --git a/auth/api/iam/validation.go b/auth/api/iam/validation.go index 7809a3ab4f..c36f29ad91 100644 --- a/auth/api/iam/validation.go +++ b/auth/api/iam/validation.go @@ -79,7 +79,7 @@ func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresenta } func (r Wrapper) presentationDefinitionForScope(ctx context.Context, scope string) (pe.WalletOwnerMapping, error) { - mapping, err := r.policyBackend.PresentationDefinitions(ctx, scope) + match, err := r.policyBackend.FindCredentialProfile(ctx, scope) if err != nil { if errors.Is(err, policy.ErrNotFound) { return nil, oauth.OAuth2Error{ @@ -94,5 +94,5 @@ func (r Wrapper) presentationDefinitionForScope(ctx context.Context, scope strin Description: fmt.Sprintf("failed to retrieve presentation definition for scope (%s): %s", scope, err.Error()), } } - return mapping, err + return match.WalletOwnerMapping, nil } diff --git a/policy/cmd.go b/policy/cmd.go index 9110d48ac0..b339d06e00 100644 --- a/policy/cmd.go +++ b/policy/cmd.go @@ -27,5 +27,6 @@ func FlagSet() *pflag.FlagSet { defCfg := defaultConfig() flagSet := pflag.NewFlagSet("policy", pflag.ContinueOnError) flagSet.String("policy.directory", defCfg.Directory, "Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping.") + flagSet.String("policy.authzen.endpoint", defCfg.AuthZen.Endpoint, "Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'.") return flagSet } diff --git a/policy/config.go b/policy/config.go index 6cf98eb173..f44ff740a2 100644 --- a/policy/config.go +++ b/policy/config.go @@ -22,6 +22,14 @@ type Config struct { // Directory is the directory where the policy files are stored // policy files include a scope to presentation definition mapping Directory string `koanf:"directory"` + // AuthZen contains configuration for the AuthZen PDP integration + AuthZen AuthZenConfig `koanf:"authzen"` +} + +// AuthZenConfig contains configuration for an AuthZen-compatible PDP endpoint. +type AuthZenConfig struct { + // Endpoint is the base URL of the AuthZen PDP + Endpoint string `koanf:"endpoint"` } func defaultConfig() Config { diff --git a/policy/interface.go b/policy/interface.go index 993815c269..95c83cbec7 100644 --- a/policy/interface.go +++ b/policy/interface.go @@ -29,10 +29,37 @@ const ModuleName = "policy" var ErrNotFound = errors.New("not found") -// PDPBackend is the interface for the policy backend -// Both the remote and local policy backend implement this interface +// ScopePolicy defines how extra scopes (beyond the credential profile scope) are handled. +type ScopePolicy string + +const ( + // ScopePolicyProfileOnly only accepts the credential profile scope. Extra scopes cause an error. + ScopePolicyProfileOnly ScopePolicy = "profile-only" + // ScopePolicyPassthrough grants all requested scopes without evaluation. + ScopePolicyPassthrough ScopePolicy = "passthrough" + // ScopePolicyDynamic evaluates extra scopes via an external AuthZen PDP. + ScopePolicyDynamic ScopePolicy = "dynamic" +) + +// CredentialProfileMatch is the result of matching a scope string against the policy configuration. +// It contains the matched credential profile (WalletOwnerMapping + ScopePolicy) and the +// remaining scopes that did not match any credential profile. +type CredentialProfileMatch struct { + // CredentialProfileScope is the scope that matched a credential profile. + CredentialProfileScope string + // WalletOwnerMapping contains the PresentationDefinitions per wallet owner type for the matched credential profile. + WalletOwnerMapping pe.WalletOwnerMapping + // ScopePolicy is the configured scope policy for the matched credential profile. + ScopePolicy ScopePolicy + // OtherScopes contains the scopes from the request that did not match any credential profile. + OtherScopes []string +} + +// PDPBackend is the interface for the policy backend. +// Both the remote and local policy backend implement this interface. type PDPBackend interface { - // PresentationDefinitions returns the PresentationDefinitions (mapped to a WalletOwnerType) for the given scope - // scopes are space delimited. It's up to the backend to decide how to handle this - PresentationDefinitions(ctx context.Context, scope string) (pe.WalletOwnerMapping, error) + // FindCredentialProfile resolves a scope string against the policy configuration. + // It parses the space-delimited scope string, identifies exactly one credential profile scope, + // and returns the matched profile along with any remaining scopes. + FindCredentialProfile(ctx context.Context, scope string) (*CredentialProfileMatch, error) } diff --git a/policy/local.go b/policy/local.go index 81719eb7d8..26082ea807 100644 --- a/policy/local.go +++ b/policy/local.go @@ -30,6 +30,15 @@ import ( "strings" ) +func (sp ScopePolicy) valid() bool { + switch sp { + case ScopePolicyProfileOnly, ScopePolicyPassthrough, ScopePolicyDynamic: + return true + default: + return false + } +} + var _ PDPBackend = (*LocalPDP)(nil) // New creates a new local policy backend @@ -37,13 +46,12 @@ func New() *LocalPDP { return &LocalPDP{} } -// LocalPDP is a backend for presentation definitions -// It loads a file with the mapping from oauth scope to PEX Policy. -// It allows access when the requester can present a submission according to the Presentation Definition. +// LocalPDP is a backend for presentation definitions. +// It loads policy files that map OAuth scopes to credential profiles (PresentationDefinitions + scope policy). type LocalPDP struct { config Config - // mapping holds the oauth scope to PEX Policy mapping - mapping map[string]validatingWalletOwnerMapping + // mapping holds the credential profile configuration per scope + mapping map[string]credentialProfileConfig } func (b *LocalPDP) Name() string { @@ -67,7 +75,13 @@ func (b *LocalPDP) Configure(_ core.ServerConfig) error { return fmt.Errorf("failed to load policy from directory: %w", err) } } - + if b.config.AuthZen.Endpoint == "" { + for scope, profile := range b.mapping { + if profile.ScopePolicy == ScopePolicyDynamic { + return fmt.Errorf("credential profile %q has scope_policy %q but no AuthZen endpoint is configured (policy.authzen.endpoint)", scope, ScopePolicyDynamic) + } + } + } return nil } @@ -75,16 +89,33 @@ func (b *LocalPDP) Config() interface{} { return &b.config } -func (b *LocalPDP) PresentationDefinitions(_ context.Context, scope string) (pe.WalletOwnerMapping, error) { - result := pe.WalletOwnerMapping{} - mapping, exists := b.mapping[scope] - if !exists { - return nil, ErrNotFound +func (b *LocalPDP) FindCredentialProfile(_ context.Context, scope string) (*CredentialProfileMatch, error) { + var profileScope string + var profile credentialProfileConfig + var otherScopes []string + for _, s := range strings.Split(scope, " ") { + if s == "" { + continue + } + if p, exists := b.mapping[s]; exists { + if profileScope != "" { + return nil, fmt.Errorf("%w: multiple credential profile scopes found", ErrNotFound) + } + profileScope = s + profile = p + } else { + otherScopes = append(otherScopes, s) + } } - for walletOwnerType, policy := range mapping { - result[walletOwnerType] = policy + if profileScope == "" { + return nil, ErrNotFound } - return result, nil + return &CredentialProfileMatch{ + CredentialProfileScope: profileScope, + WalletOwnerMapping: profile.toWalletOwnerMapping(), + ScopePolicy: profile.ScopePolicy, + OtherScopes: otherScopes, + }, nil } // loadFromDirectory traverses all .json files in the given directory and loads them @@ -118,43 +149,64 @@ func (b *LocalPDP) loadFromDirectory(directory string) error { return nil } -// LoadFromFile loads the mapping from the given file func (b *LocalPDP) loadFromFile(filename string) error { - // read the bytes from the file reader, err := os.Open(filename) if err != nil { return err } defer reader.Close() - bytes, err := io.ReadAll(reader) + data, err := io.ReadAll(reader) if err != nil { return err } - // unmarshal the bytes into the mapping - result := make(map[string]validatingWalletOwnerMapping) - err = json.Unmarshal(bytes, &result) - if err != nil { + result := make(map[string]credentialProfileConfig) + if err = json.Unmarshal(data, &result); err != nil { return fmt.Errorf("failed to unmarshal PEX Policy mapping file %s: %w", filename, err) } if b.mapping == nil { - b.mapping = make(map[string]validatingWalletOwnerMapping) + b.mapping = make(map[string]credentialProfileConfig) } - for scope, defs := range result { + for scope, profile := range result { if _, exists := b.mapping[scope]; exists { return fmt.Errorf("mapping for scope '%s' already exists (file=%s)", scope, filename) } - b.mapping[scope] = defs + // Default to profile-only when scope_policy is not specified + if profile.ScopePolicy == "" { + profile.ScopePolicy = ScopePolicyProfileOnly + } + if !profile.ScopePolicy.valid() { + return fmt.Errorf("invalid scope_policy %q for scope %q (file=%s)", profile.ScopePolicy, scope, filename) + } + b.mapping[scope] = profile } return nil } -// validatingPresentationDefinition is an alias for PresentationDefinition that validates the JSON on unmarshal. -type validatingWalletOwnerMapping pe.WalletOwnerMapping +// credentialProfileConfig holds the configuration for a single credential profile. +type credentialProfileConfig struct { + Organization *validatingPresentationDefinition `json:"organization,omitempty"` + User *validatingPresentationDefinition `json:"user,omitempty"` + ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` +} + +func (c *credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { + m := pe.WalletOwnerMapping{} + if c.Organization != nil { + m[pe.WalletOwnerOrganization] = pe.PresentationDefinition(*c.Organization) + } + if c.User != nil { + m[pe.WalletOwnerUser] = pe.PresentationDefinition(*c.User) + } + return m +} + +// validatingPresentationDefinition validates the PresentationDefinition against the v2 JSON schema on unmarshal. +type validatingPresentationDefinition pe.PresentationDefinition -func (v *validatingWalletOwnerMapping) UnmarshalJSON(data []byte) error { - if err := v2.Validate(data, v2.WalletOwnerMapping); err != nil { +func (v *validatingPresentationDefinition) UnmarshalJSON(data []byte) error { + if err := v2.Validate(data, v2.PresentationDefinition); err != nil { return err } - return json.Unmarshal(data, (*pe.WalletOwnerMapping)(v)) + return json.Unmarshal(data, (*pe.PresentationDefinition)(v)) } diff --git a/policy/local_test.go b/policy/local_test.go index ca0a357a11..bba316d8f2 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -54,24 +54,27 @@ func TestStore_LoadFromFile(t *testing.T) { }) } -func TestStore_PresentationDefinitions(t *testing.T) { +func TestLocalPDP_FindCredentialProfile(t *testing.T) { t.Run("err - not found", func(t *testing.T) { store := LocalPDP{} - _, err := store.PresentationDefinitions(context.Background(), "example-scope2") + _, err := store.FindCredentialProfile(context.Background(), "unknown-scope") - assert.Equal(t, ErrNotFound, err) + assert.ErrorIs(t, err, ErrNotFound) }) - t.Run("returns the presentation definition if the scope exists", func(t *testing.T) { + t.Run("returns match for existing scope", func(t *testing.T) { store := LocalPDP{} err := store.loadFromFile("test/definition_mapping.json") require.NoError(t, err) - result, err := store.PresentationDefinitions(context.Background(), "example-scope") + match, err := store.FindCredentialProfile(context.Background(), "example-scope") require.NoError(t, err) - assert.NotNil(t, result) + assert.Equal(t, "example-scope", match.CredentialProfileScope) + assert.NotNil(t, match.WalletOwnerMapping) + assert.Equal(t, ScopePolicyProfileOnly, match.ScopePolicy) + assert.Empty(t, match.OtherScopes) }) } @@ -88,8 +91,9 @@ func Test_LocalPDP_loadFromDirectory(t *testing.T) { err := store.loadFromDirectory("test") require.NoError(t, err) - _, err = store.PresentationDefinitions(context.Background(), "example-scope") + match, err := store.FindCredentialProfile(context.Background(), "example-scope") require.NoError(t, err) + assert.Equal(t, "example-scope", match.CredentialProfileScope) }) t.Run("2 files, 3 scopes", func(t *testing.T) { store := LocalPDP{} @@ -97,11 +101,11 @@ func Test_LocalPDP_loadFromDirectory(t *testing.T) { err := store.loadFromDirectory("test/2_files") require.NoError(t, err) - _, err = store.PresentationDefinitions(context.Background(), "1") + _, err = store.FindCredentialProfile(context.Background(), "1") require.NoError(t, err) - _, err = store.PresentationDefinitions(context.Background(), "2") + _, err = store.FindCredentialProfile(context.Background(), "2") require.NoError(t, err) - _, err = store.PresentationDefinitions(context.Background(), "3") + _, err = store.FindCredentialProfile(context.Background(), "3") require.NoError(t, err) }) t.Run("2 files, duplicate scope", func(t *testing.T) { diff --git a/policy/mock.go b/policy/mock.go index a406fb20cf..8e05e7600b 100644 --- a/policy/mock.go +++ b/policy/mock.go @@ -13,7 +13,6 @@ import ( context "context" reflect "reflect" - pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -41,17 +40,17 @@ func (m *MockPDPBackend) EXPECT() *MockPDPBackendMockRecorder { return m.recorder } -// PresentationDefinitions mocks base method. -func (m *MockPDPBackend) PresentationDefinitions(ctx context.Context, scope string) (pe.WalletOwnerMapping, error) { +// FindCredentialProfile mocks base method. +func (m *MockPDPBackend) FindCredentialProfile(ctx context.Context, scope string) (*CredentialProfileMatch, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PresentationDefinitions", ctx, scope) - ret0, _ := ret[0].(pe.WalletOwnerMapping) + ret := m.ctrl.Call(m, "FindCredentialProfile", ctx, scope) + ret0, _ := ret[0].(*CredentialProfileMatch) ret1, _ := ret[1].(error) return ret0, ret1 } -// PresentationDefinitions indicates an expected call of PresentationDefinitions. -func (mr *MockPDPBackendMockRecorder) PresentationDefinitions(ctx, scope any) *gomock.Call { +// FindCredentialProfile indicates an expected call of FindCredentialProfile. +func (mr *MockPDPBackendMockRecorder) FindCredentialProfile(ctx, scope any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinitions", reflect.TypeOf((*MockPDPBackend)(nil).PresentationDefinitions), ctx, scope) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindCredentialProfile", reflect.TypeOf((*MockPDPBackend)(nil).FindCredentialProfile), ctx, scope) } From 2d16cf7bcfcf453f735ff4a0ab1c772bf1748135 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 16:56:41 +0200 Subject: [PATCH 03/34] Add tests for multi-scope parsing in FindCredentialProfile Tests cover: multi-scope with one profile scope + other scopes, multiple profile scopes (error), no profile scope (error), and empty scope string (error). Co-Authored-By: Claude Opus 4.6 (1M context) --- policy/local_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/policy/local_test.go b/policy/local_test.go index bba316d8f2..f1ca3dc109 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -76,6 +76,44 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { assert.Equal(t, ScopePolicyProfileOnly, match.ScopePolicy) assert.Empty(t, match.OtherScopes) }) + t.Run("multi-scope with one profile scope returns match and other scopes", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/definition_mapping.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), "example-scope patient/Observation.read launch/patient") + + require.NoError(t, err) + assert.Equal(t, "example-scope", match.CredentialProfileScope) + assert.NotNil(t, match.WalletOwnerMapping) + assert.Equal(t, []string{"patient/Observation.read", "launch/patient"}, match.OtherScopes) + }) + t.Run("err - multiple credential profile scopes", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromDirectory("test/2_files") + require.NoError(t, err) + + _, err = store.FindCredentialProfile(context.Background(), "1 2") + + assert.ErrorIs(t, err, ErrNotFound) + assert.ErrorContains(t, err, "multiple credential profile scopes") + }) + t.Run("err - no credential profile scope", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/definition_mapping.json") + require.NoError(t, err) + + _, err = store.FindCredentialProfile(context.Background(), "unknown-a unknown-b") + + assert.ErrorIs(t, err, ErrNotFound) + }) + t.Run("err - empty scope string", func(t *testing.T) { + store := LocalPDP{} + + _, err := store.FindCredentialProfile(context.Background(), "") + + assert.ErrorIs(t, err, ErrNotFound) + }) } func Test_LocalPDP_loadFromDirectory(t *testing.T) { From a0edb159bf84126e24b45e7faf0d0ac092bc1fbc Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 17:00:01 +0200 Subject: [PATCH 04/34] Add scope_policy config parsing and startup validation tests Tests cover: scope_policy parsed from JSON config (dynamic, passthrough), invalid scope_policy rejected at load time, dynamic without AuthZen endpoint fails at startup, passthrough without endpoint succeeds. Co-Authored-By: Claude Opus 4.6 (1M context) --- policy/local_test.go | 62 +++++++++++++++++++ policy/test/scope_policy/dynamic.json | 21 +++++++ policy/test/scope_policy/passthrough.json | 21 +++++++ policy/test/scope_policy_invalid/invalid.json | 21 +++++++ 4 files changed, 125 insertions(+) create mode 100644 policy/test/scope_policy/dynamic.json create mode 100644 policy/test/scope_policy/passthrough.json create mode 100644 policy/test/scope_policy_invalid/invalid.json diff --git a/policy/local_test.go b/policy/local_test.go index f1ca3dc109..e70e13e88a 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -20,6 +20,7 @@ package policy import ( "context" + "github.com/nuts-foundation/nuts-node/core" "testing" "github.com/stretchr/testify/assert" @@ -116,6 +117,67 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { }) } +func TestLocalPDP_ScopePolicyConfig(t *testing.T) { + t.Run("scope_policy parsed from config", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/scope_policy/dynamic.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), "dynamic-scope") + + require.NoError(t, err) + assert.Equal(t, ScopePolicyDynamic, match.ScopePolicy) + }) + t.Run("passthrough scope_policy parsed from config", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/scope_policy/passthrough.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), "passthrough-scope") + + require.NoError(t, err) + assert.Equal(t, ScopePolicyPassthrough, match.ScopePolicy) + }) + t.Run("invalid scope_policy rejected at load time", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/scope_policy_invalid/invalid.json") + + assert.ErrorContains(t, err, `invalid scope_policy "bogus"`) + }) +} + +func TestLocalPDP_Configure(t *testing.T) { + t.Run("dynamic scope_policy without AuthZen endpoint fails", func(t *testing.T) { + store := LocalPDP{config: Config{Directory: "test/scope_policy"}} + + err := store.Configure(core.ServerConfig{}) + + assert.ErrorContains(t, err, "no AuthZen endpoint is configured") + }) + t.Run("dynamic scope_policy with AuthZen endpoint succeeds", func(t *testing.T) { + store := LocalPDP{config: Config{ + Directory: "test/scope_policy", + AuthZen: AuthZenConfig{Endpoint: "http://localhost:8080"}, + }} + + err := store.Configure(core.ServerConfig{}) + + assert.NoError(t, err) + }) + t.Run("passthrough scope_policy without AuthZen endpoint succeeds", func(t *testing.T) { + store := LocalPDP{config: Config{Directory: "test/scope_policy"}} + // Load only the passthrough config, not the dynamic one + store.config.Directory = "" + err := store.loadFromFile("test/scope_policy/passthrough.json") + require.NoError(t, err) + + err = store.Configure(core.ServerConfig{}) + + assert.NoError(t, err) + }) +} + func Test_LocalPDP_loadFromDirectory(t *testing.T) { t.Run("no files", func(t *testing.T) { store := LocalPDP{} diff --git a/policy/test/scope_policy/dynamic.json b/policy/test/scope_policy/dynamic.json new file mode 100644 index 0000000000..c3efea205b --- /dev/null +++ b/policy/test/scope_policy/dynamic.json @@ -0,0 +1,21 @@ +{ + "dynamic-scope": { + "organization": { + "id": "pd_dynamic", + "input_descriptors": [ + { + "id": "id_org", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": {"type": "string", "const": "TestCredential"} + } + ] + } + } + ] + }, + "scope_policy": "dynamic" + } +} diff --git a/policy/test/scope_policy/passthrough.json b/policy/test/scope_policy/passthrough.json new file mode 100644 index 0000000000..95ca51d68a --- /dev/null +++ b/policy/test/scope_policy/passthrough.json @@ -0,0 +1,21 @@ +{ + "passthrough-scope": { + "organization": { + "id": "pd_passthrough", + "input_descriptors": [ + { + "id": "id_org", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": {"type": "string", "const": "TestCredential"} + } + ] + } + } + ] + }, + "scope_policy": "passthrough" + } +} diff --git a/policy/test/scope_policy_invalid/invalid.json b/policy/test/scope_policy_invalid/invalid.json new file mode 100644 index 0000000000..5db3bb3d8d --- /dev/null +++ b/policy/test/scope_policy_invalid/invalid.json @@ -0,0 +1,21 @@ +{ + "invalid-scope": { + "organization": { + "id": "pd_invalid", + "input_descriptors": [ + { + "id": "id_org", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": {"type": "string", "const": "TestCredential"} + } + ] + } + } + ] + }, + "scope_policy": "bogus" + } +} From ba9c58b9272ed70a37a1840aeeff61e39c3c00fa Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 17:21:35 +0200 Subject: [PATCH 05/34] Apply self-review fixes - Introduce ErrAmbiguousScope for multiple credential profile scopes (instead of wrapping ErrNotFound which was semantically wrong) - Use strings.Fields instead of strings.Split for robust whitespace handling - Add nil-check: credential profile must define at least one of organization/user - Add doc comments on Config, ErrNotFound, FindCredentialProfile implementation - Use value receiver on toWalletOwnerMapping (small non-mutating struct) - Add test for consecutive spaces in scope string - Assert ScopePolicy in multi-scope test - Make Configure tests load single files instead of whole directory Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/validation.go | 2 +- policy/config.go | 1 + policy/interface.go | 6 +++++- policy/local.go | 13 +++++++------ policy/local_test.go | 28 +++++++++++++++++++++------- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/auth/api/iam/validation.go b/auth/api/iam/validation.go index c36f29ad91..2d19128a59 100644 --- a/auth/api/iam/validation.go +++ b/auth/api/iam/validation.go @@ -81,7 +81,7 @@ func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresenta func (r Wrapper) presentationDefinitionForScope(ctx context.Context, scope string) (pe.WalletOwnerMapping, error) { match, err := r.policyBackend.FindCredentialProfile(ctx, scope) if err != nil { - if errors.Is(err, policy.ErrNotFound) { + if errors.Is(err, policy.ErrNotFound) || errors.Is(err, policy.ErrAmbiguousScope) { return nil, oauth.OAuth2Error{ Code: oauth.InvalidScope, InternalError: err, diff --git a/policy/config.go b/policy/config.go index f44ff740a2..21baa12b68 100644 --- a/policy/config.go +++ b/policy/config.go @@ -18,6 +18,7 @@ package policy +// Config holds the configuration for the policy module. type Config struct { // Directory is the directory where the policy files are stored // policy files include a scope to presentation definition mapping diff --git a/policy/interface.go b/policy/interface.go index 95c83cbec7..00ab47661d 100644 --- a/policy/interface.go +++ b/policy/interface.go @@ -24,11 +24,15 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/pe" ) -// ModuleName is the name of the policy module +// ModuleName is the name of the policy module. const ModuleName = "policy" +// ErrNotFound is returned when no credential profile matches the requested scope. var ErrNotFound = errors.New("not found") +// ErrAmbiguousScope is returned when multiple credential profile scopes are found in a single request. +var ErrAmbiguousScope = errors.New("multiple credential profile scopes found") + // ScopePolicy defines how extra scopes (beyond the credential profile scope) are handled. type ScopePolicy string diff --git a/policy/local.go b/policy/local.go index 26082ea807..e5efbddea6 100644 --- a/policy/local.go +++ b/policy/local.go @@ -89,17 +89,15 @@ func (b *LocalPDP) Config() interface{} { return &b.config } +// FindCredentialProfile implements PDPBackend. func (b *LocalPDP) FindCredentialProfile(_ context.Context, scope string) (*CredentialProfileMatch, error) { var profileScope string var profile credentialProfileConfig var otherScopes []string - for _, s := range strings.Split(scope, " ") { - if s == "" { - continue - } + for _, s := range strings.Fields(scope) { if p, exists := b.mapping[s]; exists { if profileScope != "" { - return nil, fmt.Errorf("%w: multiple credential profile scopes found", ErrNotFound) + return nil, ErrAmbiguousScope } profileScope = s profile = p @@ -175,6 +173,9 @@ 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.ScopePolicy.valid() { return fmt.Errorf("invalid scope_policy %q for scope %q (file=%s)", profile.ScopePolicy, scope, filename) } @@ -190,7 +191,7 @@ type credentialProfileConfig struct { ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` } -func (c *credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { +func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { m := pe.WalletOwnerMapping{} if c.Organization != nil { m[pe.WalletOwnerOrganization] = pe.PresentationDefinition(*c.Organization) diff --git a/policy/local_test.go b/policy/local_test.go index e70e13e88a..b25e931436 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -87,8 +87,20 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { require.NoError(t, err) assert.Equal(t, "example-scope", match.CredentialProfileScope) assert.NotNil(t, match.WalletOwnerMapping) + assert.Equal(t, ScopePolicyProfileOnly, match.ScopePolicy) assert.Equal(t, []string{"patient/Observation.read", "launch/patient"}, match.OtherScopes) }) + t.Run("handles consecutive spaces and whitespace in scope string", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/definition_mapping.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), " example-scope extra ") + + require.NoError(t, err) + assert.Equal(t, "example-scope", match.CredentialProfileScope) + assert.Equal(t, []string{"extra"}, match.OtherScopes) + }) t.Run("err - multiple credential profile scopes", func(t *testing.T) { store := LocalPDP{} err := store.loadFromDirectory("test/2_files") @@ -96,8 +108,7 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { _, err = store.FindCredentialProfile(context.Background(), "1 2") - assert.ErrorIs(t, err, ErrNotFound) - assert.ErrorContains(t, err, "multiple credential profile scopes") + assert.ErrorIs(t, err, ErrAmbiguousScope) }) t.Run("err - no credential profile scope", func(t *testing.T) { store := LocalPDP{} @@ -149,19 +160,22 @@ func TestLocalPDP_ScopePolicyConfig(t *testing.T) { func TestLocalPDP_Configure(t *testing.T) { t.Run("dynamic scope_policy without AuthZen endpoint fails", func(t *testing.T) { - store := LocalPDP{config: Config{Directory: "test/scope_policy"}} + store := LocalPDP{} + err := store.loadFromFile("test/scope_policy/dynamic.json") + require.NoError(t, err) - err := store.Configure(core.ServerConfig{}) + err = store.Configure(core.ServerConfig{}) assert.ErrorContains(t, err, "no AuthZen endpoint is configured") }) t.Run("dynamic scope_policy with AuthZen endpoint succeeds", func(t *testing.T) { store := LocalPDP{config: Config{ - Directory: "test/scope_policy", - AuthZen: AuthZenConfig{Endpoint: "http://localhost:8080"}, + AuthZen: AuthZenConfig{Endpoint: "http://localhost:8080"}, }} + err := store.loadFromFile("test/scope_policy/dynamic.json") + require.NoError(t, err) - err := store.Configure(core.ServerConfig{}) + err = store.Configure(core.ServerConfig{}) assert.NoError(t, err) }) From c05eb535025a849a37ea0f066ea22627701970cd Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:21 +0200 Subject: [PATCH 06/34] Initialize branch for PR From 0c0557f1c5d836f15bd71889e33fda2680ad3a6b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Apr 2026 09:17:07 +0200 Subject: [PATCH 07/34] Add AuthZen client with batch evaluation support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the HTTP client for the AuthZen Access Evaluations API (POST /access/v1/evaluations). Request uses AuthZen batch format: shared subject/action/context with per-scope evaluations array. Returns scope→decision map. Co-Authored-By: Claude Opus 4.6 (1M context) --- policy/authzen/client.go | 85 +++++++++++++++++++++++++++++++++++ policy/authzen/client_test.go | 73 ++++++++++++++++++++++++++++++ policy/authzen/types.go | 79 ++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 policy/authzen/client.go create mode 100644 policy/authzen/client_test.go create mode 100644 policy/authzen/types.go diff --git a/policy/authzen/client.go b/policy/authzen/client.go new file mode 100644 index 0000000000..1624cad4f9 --- /dev/null +++ b/policy/authzen/client.go @@ -0,0 +1,85 @@ +/* + * 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 authzen + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/nuts-foundation/nuts-node/core" +) + +const evaluationsPath = "/access/v1/evaluations" + +// Client is an HTTP client for the AuthZen Access Evaluations API. +type Client struct { + endpoint string + httpClient core.HTTPRequestDoer +} + +// NewClient creates a new AuthZen client. The endpoint is the base URL of the PDP. +// The httpClient handles timeouts and TLS configuration. +func NewClient(endpoint string, httpClient core.HTTPRequestDoer) *Client { + return &Client{ + endpoint: endpoint, + httpClient: httpClient, + } +} + +// Evaluate sends a batch evaluation request and returns a map of scope → decision. +func (c *Client) Evaluate(ctx context.Context, req EvaluationsRequest) (map[string]bool, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("authzen: marshal request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+evaluationsPath, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("authzen: create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + httpResp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("authzen: execute request: %w", err) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(httpResp.Body) + return nil, fmt.Errorf("authzen: PDP returned HTTP %d: %s", httpResp.StatusCode, string(respBody)) + } + + var resp EvaluationsResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("authzen: decode response: %w", err) + } + if len(resp.Evaluations) != len(req.Evaluations) { + return nil, fmt.Errorf("authzen: expected %d evaluations, got %d", len(req.Evaluations), len(resp.Evaluations)) + } + + decisions := make(map[string]bool, len(req.Evaluations)) + for i, eval := range resp.Evaluations { + decisions[req.Evaluations[i].Resource.ID] = eval.Decision + } + return decisions, nil +} diff --git a/policy/authzen/client_test.go b/policy/authzen/client_test.go new file mode 100644 index 0000000000..ba53fd106c --- /dev/null +++ b/policy/authzen/client_test.go @@ -0,0 +1,73 @@ +/* + * 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 authzen + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_Evaluate(t *testing.T) { + t.Run("successful evaluation returns scope decisions", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/access/v1/evaluations", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var req EvaluationsRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.Equal(t, "organization", req.Subject.Type) + assert.Len(t, req.Evaluations, 2) + + resp := EvaluationsResponse{ + Evaluations: []EvaluationResult{ + {Decision: true}, + {Decision: false}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, server.Client()) + decisions, err := client.Evaluate(context.Background(), EvaluationsRequest{ + Subject: Subject{Type: "organization", ID: "did:web:example.com"}, + Action: Action{Name: "request_scope"}, + Context: EvaluationContext{Policy: "test-profile"}, + Evaluations: []Evaluation{ + {Resource: Resource{Type: "scope", ID: "scope-a"}}, + {Resource: Resource{Type: "scope", ID: "scope-b"}}, + }, + }) + + require.NoError(t, err) + assert.Equal(t, map[string]bool{ + "scope-a": true, + "scope-b": false, + }, decisions) + }) +} diff --git a/policy/authzen/types.go b/policy/authzen/types.go new file mode 100644 index 0000000000..30da41b3b3 --- /dev/null +++ b/policy/authzen/types.go @@ -0,0 +1,79 @@ +/* + * 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 authzen + +// EvaluationsRequest is the batch request for the AuthZen Access Evaluations API (POST /access/v1/evaluations). +// Top-level fields are shared defaults; individual evaluations override only the resource. +type EvaluationsRequest struct { + Subject Subject `json:"subject"` + Action Action `json:"action"` + Context EvaluationContext `json:"context"` + Evaluations []Evaluation `json:"evaluations"` +} + +// Evaluation is a single evaluation within a batch request, overriding the resource. +type Evaluation struct { + Resource Resource `json:"resource"` +} + +// Subject identifies the entity requesting access. +type Subject struct { + Type string `json:"type"` + ID string `json:"id"` + Properties SubjectProperties `json:"properties"` +} + +// SubjectProperties contains claims extracted from validated VCs, grouped by role. +type SubjectProperties struct { + Client map[string]any `json:"client,omitempty"` + Organization map[string]any `json:"organization,omitempty"` + User map[string]any `json:"user,omitempty"` +} + +// Action describes the operation being requested. +type Action struct { + Name string `json:"name"` +} + +// Resource identifies the resource being accessed. +type Resource struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// EvaluationContext provides additional context for policy routing. +type EvaluationContext struct { + Policy string `json:"policy"` +} + +// EvaluationsResponse is the batch response from the AuthZen Access Evaluations API. +type EvaluationsResponse struct { + Evaluations []EvaluationResult `json:"evaluations"` +} + +// EvaluationResult contains the decision for a single evaluation. +type EvaluationResult struct { + Decision bool `json:"decision"` + Context *EvaluationResultContext `json:"context,omitempty"` +} + +// EvaluationResultContext contains supplementary information about an evaluation decision. +type EvaluationResultContext struct { + Reason string `json:"reason,omitempty"` +} From cb22056a3dcdb707dabd89b45a91479e0d7fd0ec Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Apr 2026 09:20:24 +0200 Subject: [PATCH 08/34] Add error handling and edge case tests for AuthZen client Tests cover: partial denial, HTTP 500, PDP unreachable, context cancellation/timeout, evaluation count mismatch, malformed response. Co-Authored-By: Claude Opus 4.6 (1M context) --- policy/authzen/client_test.go | 111 ++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/policy/authzen/client_test.go b/policy/authzen/client_test.go index ba53fd106c..2cdbcc1358 100644 --- a/policy/authzen/client_test.go +++ b/policy/authzen/client_test.go @@ -70,4 +70,115 @@ func TestClient_Evaluate(t *testing.T) { "scope-b": false, }, decisions) }) + t.Run("partial denial - some scopes approved, some denied", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := EvaluationsResponse{ + Evaluations: []EvaluationResult{ + {Decision: true}, + {Decision: false, Context: &EvaluationResultContext{Reason: "not permitted"}}, + {Decision: true}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, server.Client()) + decisions, err := client.Evaluate(context.Background(), EvaluationsRequest{ + Evaluations: []Evaluation{ + {Resource: Resource{Type: "scope", ID: "read"}}, + {Resource: Resource{Type: "scope", ID: "write"}}, + {Resource: Resource{Type: "scope", ID: "notify"}}, + }, + }) + + require.NoError(t, err) + assert.True(t, decisions["read"]) + assert.False(t, decisions["write"]) + assert.True(t, decisions["notify"]) + }) + t.Run("HTTP error from PDP", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer server.Close() + + client := NewClient(server.URL, server.Client()) + _, err := client.Evaluate(context.Background(), EvaluationsRequest{ + Evaluations: []Evaluation{ + {Resource: Resource{Type: "scope", ID: "test"}}, + }, + }) + + assert.ErrorContains(t, err, "PDP returned HTTP 500") + }) + t.Run("PDP unreachable", func(t *testing.T) { + client := NewClient("http://localhost:1", http.DefaultClient) + _, err := client.Evaluate(context.Background(), EvaluationsRequest{ + Evaluations: []Evaluation{ + {Resource: Resource{Type: "scope", ID: "test"}}, + }, + }) + + assert.ErrorContains(t, err, "authzen: execute request") + }) + t.Run("evaluation count mismatch", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := EvaluationsResponse{ + Evaluations: []EvaluationResult{ + {Decision: true}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL, server.Client()) + _, err := client.Evaluate(context.Background(), EvaluationsRequest{ + Evaluations: []Evaluation{ + {Resource: Resource{Type: "scope", ID: "a"}}, + {Resource: Resource{Type: "scope", ID: "b"}}, + }, + }) + + assert.ErrorContains(t, err, "expected 2 evaluations, got 1") + }) + t.Run("malformed response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("not json")) + })) + defer server.Close() + + client := NewClient(server.URL, server.Client()) + _, err := client.Evaluate(context.Background(), EvaluationsRequest{ + Evaluations: []Evaluation{ + {Resource: Resource{Type: "scope", ID: "test"}}, + }, + }) + + assert.ErrorContains(t, err, "authzen: decode response") + }) + t.Run("request with context deadline", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Block until context is cancelled + <-r.Context().Done() + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + client := NewClient(server.URL, server.Client()) + _, err := client.Evaluate(ctx, EvaluationsRequest{ + Evaluations: []Evaluation{ + {Resource: Resource{Type: "scope", ID: "test"}}, + }, + }) + + assert.ErrorContains(t, err, "authzen: execute request") + }) } From 4f08bcf95baae75a3debe4bdcb6dafbaf5b02ae0 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 14 Apr 2026 09:46:26 +0200 Subject: [PATCH 09/34] Apply self-review fixes to AuthZen client - Truncate PDP error body in error messages (prevent log injection) - Validate duplicate resource IDs before sending request - Add Accept: application/json header - Add package doc comment - Fix require.NoError inside httptest handler (capture request, assert outside) - Rename context cancellation test for accuracy - Add duplicate resource ID test - Response body limiting delegated to StrictHTTPClient (caller responsibility) Co-Authored-By: Claude Opus 4.6 (1M context) --- policy/authzen/client.go | 19 ++++++++++++++++++- policy/authzen/client_test.go | 27 ++++++++++++++++++--------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/policy/authzen/client.go b/policy/authzen/client.go index 1624cad4f9..d523bb3012 100644 --- a/policy/authzen/client.go +++ b/policy/authzen/client.go @@ -16,6 +16,7 @@ * */ +// Package authzen implements an HTTP client for the AuthZen Access Evaluations API. package authzen import ( @@ -57,6 +58,16 @@ func (c *Client) Evaluate(ctx context.Context, req EvaluationsRequest) (map[stri return nil, fmt.Errorf("authzen: create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + // Check for duplicate resource IDs before sending the request + seen := make(map[string]bool, len(req.Evaluations)) + for _, eval := range req.Evaluations { + if seen[eval.Resource.ID] { + return nil, fmt.Errorf("authzen: duplicate resource ID in request: %s", eval.Resource.ID) + } + seen[eval.Resource.ID] = true + } httpResp, err := c.httpClient.Do(httpReq) if err != nil { @@ -66,7 +77,13 @@ func (c *Client) Evaluate(ctx context.Context, req EvaluationsRequest) (map[stri if httpResp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(httpResp.Body) - return nil, fmt.Errorf("authzen: PDP returned HTTP %d: %s", httpResp.StatusCode, string(respBody)) + // Truncate to keep error messages reasonable and prevent log injection + const maxErrorLen = 200 + bodyStr := string(respBody) + if len(bodyStr) > maxErrorLen { + bodyStr = bodyStr[:maxErrorLen] + "..." + } + return nil, fmt.Errorf("authzen: PDP returned HTTP %d: %s", httpResp.StatusCode, bodyStr) } var resp EvaluationsResponse diff --git a/policy/authzen/client_test.go b/policy/authzen/client_test.go index 2cdbcc1358..4ad9513f03 100644 --- a/policy/authzen/client_test.go +++ b/policy/authzen/client_test.go @@ -31,16 +31,13 @@ import ( func TestClient_Evaluate(t *testing.T) { t.Run("successful evaluation returns scope decisions", func(t *testing.T) { + var receivedReq EvaluationsRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/access/v1/evaluations", r.URL.Path) assert.Equal(t, http.MethodPost, r.Method) assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - var req EvaluationsRequest - err := json.NewDecoder(r.Body).Decode(&req) - require.NoError(t, err) - assert.Equal(t, "organization", req.Subject.Type) - assert.Len(t, req.Evaluations, 2) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + json.NewDecoder(r.Body).Decode(&receivedReq) resp := EvaluationsResponse{ Evaluations: []EvaluationResult{ @@ -65,6 +62,8 @@ func TestClient_Evaluate(t *testing.T) { }) require.NoError(t, err) + assert.Equal(t, "organization", receivedReq.Subject.Type) + assert.Len(t, receivedReq.Evaluations, 2) assert.Equal(t, map[string]bool{ "scope-a": true, "scope-b": false, @@ -162,15 +161,14 @@ func TestClient_Evaluate(t *testing.T) { assert.ErrorContains(t, err, "authzen: decode response") }) - t.Run("request with context deadline", func(t *testing.T) { + t.Run("cancelled context returns error", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Block until context is cancelled <-r.Context().Done() })) defer server.Close() ctx, cancel := context.WithCancel(context.Background()) - cancel() // Cancel immediately + cancel() client := NewClient(server.URL, server.Client()) _, err := client.Evaluate(ctx, EvaluationsRequest{ @@ -181,4 +179,15 @@ func TestClient_Evaluate(t *testing.T) { assert.ErrorContains(t, err, "authzen: execute request") }) + t.Run("duplicate resource ID in request returns error", func(t *testing.T) { + client := NewClient("http://unused", http.DefaultClient) + _, err := client.Evaluate(context.Background(), EvaluationsRequest{ + Evaluations: []Evaluation{ + {Resource: Resource{Type: "scope", ID: "same"}}, + {Resource: Resource{Type: "scope", ID: "same"}}, + }, + }) + + assert.ErrorContains(t, err, "duplicate resource ID in request: same") + }) } 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 10/34] 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 11/34] 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 12/34] 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 13/34] 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 14/34] 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 f75823aa479fb14b62f0e238a10fe389320d0a4f Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:21 +0200 Subject: [PATCH 15/34] Initialize branch for PR From 3facbb0acc25fa8c88c00539cb59a1ee3c73ee69 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:43 +0200 Subject: [PATCH 16/34] Initialize branch for PR From 9af2ab5bebf2e709a6080af754b097c8e56c7c8a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 13:41:08 +0200 Subject: [PATCH 17/34] Rename presentationDefinitionForScope to findCredentialProfile Returns the full CredentialProfileMatch instead of only WalletOwnerMapping. Callers that only need WalletOwnerMapping access match.WalletOwnerMapping. Prepares for scope policy enforcement on the server side. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/openid4vp.go | 4 ++-- auth/api/iam/s2s_vptoken.go | 4 ++-- auth/api/iam/validation.go | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index fe6bb045ec..231149fc9b 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -111,7 +111,7 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, subject s // Determine which PEX Presentation Definitions we want to see fulfilled during authorization through OpenID4VP. // Each Presentation Definition triggers 1 OpenID4VP flow. // TODO: Support multiple scopes? - presentationDefinitions, err := r.presentationDefinitionForScope(ctx, params.get(oauth.ScopeParam)) + match, err := r.findCredentialProfile(ctx, params.get(oauth.ScopeParam)) if err != nil { return nil, withCallbackURI(err, redirectURL) } @@ -122,7 +122,7 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, subject s OwnSubject: &subject, ClientState: params.get(oauth.StateParam), RedirectURI: redirectURL.String(), - OpenID4VPVerifier: newPEXConsumer(presentationDefinitions), + OpenID4VPVerifier: newPEXConsumer(match.WalletOwnerMapping), PKCEParams: PKCEParams{ // store params, when generating authorization code we take the params from the nonceStore and encrypt them in the authorization code Challenge: params.get(oauth.CodeChallengeParam), ChallengeMethod: params.get(oauth.CodeChallengeMethodParam), diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index c215ea4269..2a52b04f4c 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -73,11 +73,11 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin return nil, err } } - walletOwnerMapping, err := r.presentationDefinitionForScope(ctx, scope) + match, err := r.findCredentialProfile(ctx, scope) if err != nil { return nil, err } - pexConsumer := newPEXConsumer(walletOwnerMapping) + pexConsumer := newPEXConsumer(match.WalletOwnerMapping) if err := pexConsumer.fulfill(*submission, *pexEnvelope); err != nil { return nil, oauthError(oauth.InvalidRequest, err.Error()) } diff --git a/auth/api/iam/validation.go b/auth/api/iam/validation.go index 2d19128a59..81c3c66596 100644 --- a/auth/api/iam/validation.go +++ b/auth/api/iam/validation.go @@ -27,7 +27,6 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vcr/pe" ) // validatePresentationSigner checks if the presenter of the VP is the same as the subject of the VCs being presented. @@ -78,7 +77,7 @@ func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresenta } } -func (r Wrapper) presentationDefinitionForScope(ctx context.Context, scope string) (pe.WalletOwnerMapping, error) { +func (r Wrapper) findCredentialProfile(ctx context.Context, scope string) (*policy.CredentialProfileMatch, error) { match, err := r.policyBackend.FindCredentialProfile(ctx, scope) if err != nil { if errors.Is(err, policy.ErrNotFound) || errors.Is(err, policy.ErrAmbiguousScope) { @@ -94,5 +93,5 @@ func (r Wrapper) presentationDefinitionForScope(ctx context.Context, scope strin Description: fmt.Sprintf("failed to retrieve presentation definition for scope (%s): %s", scope, err.Error()), } } - return match.WalletOwnerMapping, nil + return match, nil } From 39377f38205ee92761982d14b52d5d55d6beaa01 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 13:45:58 +0200 Subject: [PATCH 18/34] Enforce profile-only scope policy in server-side token handler Profile-only scope policy rejects token requests with extra scopes beyond the credential profile scope. Check happens early, before expensive VP signature verification. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/s2s_vptoken.go | 7 +++++++ auth/api/iam/s2s_vptoken_test.go | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 2a52b04f4c..78723c6fd6 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -28,6 +28,7 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -77,6 +78,12 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin if err != nil { return nil, 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", + } + } pexConsumer := newPEXConsumer(match.WalletOwnerMapping) if err := pexConsumer.fulfill(*submission, *pexEnvelope); err != nil { return nil, oauthError(oauth.InvalidRequest, err.Error()) diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index a8f9e0ed22..bc2d89e74f 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -382,6 +382,20 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { _ = assertOAuthErrorWithCode(t, err, oauth.InvalidDPopProof, "DPoP header is invalid") assert.Nil(t, resp) }) + t.Run("profile-only scope policy rejects extra scopes", func(t *testing.T) { + ctx := newTestClient(t) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyProfileOnly, + OtherScopes: []string{"extra-scope"}, + }, nil) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + _ = assertOAuthErrorWithCode(t, err, oauth.InvalidScope, "scope policy 'profile-only' does not allow additional scopes") + assert.Nil(t, resp) + }) } func TestWrapper_createAccessToken(t *testing.T) { From da8a027129c19ab2d0c5d23c97fba01b46d6252d Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 13:50:49 +0200 Subject: [PATCH 19/34] Add test for passthrough scope policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies that passthrough scope policy grants all requested scopes. No implementation change needed — existing code already passes the full scope string through when not rejected by profile-only. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/s2s_vptoken_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index bc2d89e74f..b52298662b 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -396,6 +396,23 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { _ = assertOAuthErrorWithCode(t, err, oauth.InvalidScope, "scope policy 'profile-only' does not allow additional scopes") assert.Nil(t, resp) }) + t.Run("passthrough scope policy grants all requested scopes", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyPassthrough, + OtherScopes: []string{"extra-scope"}, + }, nil) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + require.NoError(t, err) + require.IsType(t, HandleTokenRequest200JSONResponse{}, resp) + tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) + assert.Equal(t, "example-scope extra-scope", *tokenResponse.Scope) + }) } func TestWrapper_createAccessToken(t *testing.T) { From a7de2c6db93bfd74a269ce8d1be392802c99b653 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 14:03:29 +0200 Subject: [PATCH 20/34] Explicitly derive granted scopes from scope policy Replace the implicit pass-through of the raw input scope with an explicit grantedScopesForPolicy switch. Profile-only grants only the credential profile scope. Passthrough grants the profile scope plus other scopes. Dynamic returns an error (not yet implemented). Prevents accidental scope pass-through when a new policy is added. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/s2s_vptoken.go | 33 +++++++++++++++++++++++++++++++- auth/api/iam/s2s_vptoken_test.go | 24 +++++++++++------------ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 78723c6fd6..79cc0d8d09 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/nuts-foundation/go-did/did" @@ -114,15 +115,45 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin } } + // Compute granted scopes based on scope policy. Never pass through the raw input scope + // directly — always derive granted scopes from the policy decision. + grantedScope, err := grantedScopesForPolicy(match) + if err != nil { + return nil, err + } + // All OK, allow access issuerURL := r.subjectToBaseURL(subject) - response, err := r.createAccessToken(issuerURL.String(), clientID, time.Now(), scope, *pexConsumer, dpopProof) + response, err := r.createAccessToken(issuerURL.String(), clientID, time.Now(), grantedScope, *pexConsumer, dpopProof) if err != nil { return nil, err } return HandleTokenRequest200JSONResponse(*response), nil } +// grantedScopesForPolicy returns the scopes to include in the access token based on the scope policy. +// Profile-only grants only the credential profile scope. Passthrough grants the credential profile +// scope plus all other requested scopes. Dynamic requires PDP evaluation and is not yet handled here. +func grantedScopesForPolicy(match *policy.CredentialProfileMatch) (string, error) { + switch match.ScopePolicy { + case policy.ScopePolicyProfileOnly: + return match.CredentialProfileScope, nil + case policy.ScopePolicyPassthrough: + scopes := append([]string{match.CredentialProfileScope}, match.OtherScopes...) + return strings.Join(scopes, " "), nil + case policy.ScopePolicyDynamic: + return "", oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "dynamic scope policy evaluation not yet implemented", + } + default: + return "", oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("unsupported scope policy: %s", match.ScopePolicy), + } + } +} + func resolveInputDescriptorValues(presentationDefinitions pe.WalletOwnerMapping, credentialMap map[string]vc.VerifiableCredential) (map[string]any, error) { fieldsMap := make(map[string]any) for _, definition := range presentationDefinitions { diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index b52298662b..b5a3f0fe3e 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -118,7 +118,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("JSON-LD VP", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -165,7 +165,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { require.NoError(t, token.Set(jwt.AudienceKey, issuerClientID)) }, verifiableCredential) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -200,7 +200,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("replay attack (nonce is reused)", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil).Times(2) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil).Times(2) _, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) @@ -211,7 +211,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JSON-LD VP is missing nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { proof.Domain = &issuerClientID proof.Nonce = nil @@ -224,7 +224,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JSON-LD VP has empty nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { proof.Domain = &issuerClientID proof.Nonce = new(string) @@ -237,7 +237,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP is missing nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Remove("nonce") @@ -249,7 +249,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP has empty nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Set("nonce", "") @@ -261,7 +261,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP nonce is not a string", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Set("nonce", true) @@ -297,7 +297,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("VP verification fails", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid")) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -364,7 +364,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, otherVerifiableCredential) ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - presentation submission does not conform to presentation definition (id=)") @@ -375,7 +375,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { httpRequest := &http.Request{Header: http.Header{"Dpop": []string{"invalid"}}} httpRequest.Header.Set("DPoP", "invalid") contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -398,7 +398,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("passthrough scope policy grants all requested scopes", func(t *testing.T) { ctx := newTestClient(t) - ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ CredentialProfileScope: "example-scope", WalletOwnerMapping: walletOwnerMapping, From e2da404af72b3afb43348646976fe3a5b20de40d Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 14:16:05 +0200 Subject: [PATCH 21/34] Expose AuthZen evaluator through PDPBackend interface LocalPDP creates an authzen.Client during Configure when an AuthZen endpoint is configured. PDPBackend exposes it via AuthZenEvaluator(), returning nil when no endpoint is set. This keeps AuthZen client ownership in the policy module (which owns the config) and avoids wiring through cmd/root.go before config is loaded. Co-Authored-By: Claude Opus 4.6 (1M context) --- policy/interface.go | 10 +++++++++ policy/local.go | 12 ++++++++++ policy/mock.go | 54 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/policy/interface.go b/policy/interface.go index 00ab47661d..e577bd4a20 100644 --- a/policy/interface.go +++ b/policy/interface.go @@ -21,6 +21,7 @@ package policy import ( "context" "errors" + "github.com/nuts-foundation/nuts-node/policy/authzen" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -59,6 +60,12 @@ type CredentialProfileMatch struct { OtherScopes []string } +// AuthZenEvaluator evaluates OAuth2 scopes against an external AuthZen-compatible PDP. +// Defined here so PDPBackend can expose it without callers importing the authzen package directly. +type AuthZenEvaluator interface { + Evaluate(ctx context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) +} + // PDPBackend is the interface for the policy backend. // Both the remote and local policy backend implement this interface. type PDPBackend interface { @@ -66,4 +73,7 @@ type PDPBackend interface { // It parses the space-delimited scope string, identifies exactly one credential profile scope, // and returns the matched profile along with any remaining scopes. FindCredentialProfile(ctx context.Context, scope string) (*CredentialProfileMatch, error) + // AuthZenEvaluator returns the configured AuthZen evaluator for dynamic scope policy evaluation. + // Returns nil when no AuthZen endpoint is configured. + AuthZenEvaluator() AuthZenEvaluator } diff --git a/policy/local.go b/policy/local.go index e5efbddea6..872f03f8fa 100644 --- a/policy/local.go +++ b/policy/local.go @@ -23,9 +23,11 @@ import ( "encoding/json" "fmt" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/policy/authzen" "github.com/nuts-foundation/nuts-node/vcr/pe" v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" "io" + "net/http" "os" "strings" ) @@ -52,6 +54,9 @@ type LocalPDP struct { config Config // mapping holds the credential profile configuration per scope mapping map[string]credentialProfileConfig + // authzenClient is created during Configure when an AuthZen endpoint is configured. + // It is nil when no endpoint is configured. + authzenClient AuthZenEvaluator } func (b *LocalPDP) Name() string { @@ -81,10 +86,17 @@ func (b *LocalPDP) Configure(_ core.ServerConfig) error { return fmt.Errorf("credential profile %q has scope_policy %q but no AuthZen endpoint is configured (policy.authzen.endpoint)", scope, ScopePolicyDynamic) } } + } else { + b.authzenClient = authzen.NewClient(b.config.AuthZen.Endpoint, http.DefaultClient) } return nil } +// AuthZenEvaluator returns the AuthZen evaluator, or nil when no endpoint is configured. +func (b *LocalPDP) AuthZenEvaluator() AuthZenEvaluator { + return b.authzenClient +} + func (b *LocalPDP) Config() interface{} { return &b.config } diff --git a/policy/mock.go b/policy/mock.go index 8e05e7600b..fd51dbfe26 100644 --- a/policy/mock.go +++ b/policy/mock.go @@ -13,9 +13,49 @@ import ( context "context" reflect "reflect" + authzen "github.com/nuts-foundation/nuts-node/policy/authzen" gomock "go.uber.org/mock/gomock" ) +// MockAuthZenEvaluator is a mock of AuthZenEvaluator interface. +type MockAuthZenEvaluator struct { + ctrl *gomock.Controller + recorder *MockAuthZenEvaluatorMockRecorder + isgomock struct{} +} + +// MockAuthZenEvaluatorMockRecorder is the mock recorder for MockAuthZenEvaluator. +type MockAuthZenEvaluatorMockRecorder struct { + mock *MockAuthZenEvaluator +} + +// NewMockAuthZenEvaluator creates a new mock instance. +func NewMockAuthZenEvaluator(ctrl *gomock.Controller) *MockAuthZenEvaluator { + mock := &MockAuthZenEvaluator{ctrl: ctrl} + mock.recorder = &MockAuthZenEvaluatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAuthZenEvaluator) EXPECT() *MockAuthZenEvaluatorMockRecorder { + return m.recorder +} + +// Evaluate mocks base method. +func (m *MockAuthZenEvaluator) Evaluate(ctx context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Evaluate", ctx, req) + ret0, _ := ret[0].(map[string]bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Evaluate indicates an expected call of Evaluate. +func (mr *MockAuthZenEvaluatorMockRecorder) Evaluate(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Evaluate", reflect.TypeOf((*MockAuthZenEvaluator)(nil).Evaluate), ctx, req) +} + // MockPDPBackend is a mock of PDPBackend interface. type MockPDPBackend struct { ctrl *gomock.Controller @@ -40,6 +80,20 @@ func (m *MockPDPBackend) EXPECT() *MockPDPBackendMockRecorder { return m.recorder } +// AuthZenEvaluator mocks base method. +func (m *MockPDPBackend) AuthZenEvaluator() AuthZenEvaluator { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AuthZenEvaluator") + ret0, _ := ret[0].(AuthZenEvaluator) + return ret0 +} + +// AuthZenEvaluator indicates an expected call of AuthZenEvaluator. +func (mr *MockPDPBackendMockRecorder) AuthZenEvaluator() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthZenEvaluator", reflect.TypeOf((*MockPDPBackend)(nil).AuthZenEvaluator)) +} + // FindCredentialProfile mocks base method. func (m *MockPDPBackend) FindCredentialProfile(ctx context.Context, scope string) (*CredentialProfileMatch, error) { m.ctrl.T.Helper() From 623984746560a63b773f55cdf1ec12e78a907c1f Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 14:20:55 +0200 Subject: [PATCH 22/34] Implement dynamic scope policy via AuthZen PDP When scope_policy is 'dynamic', the server builds an AuthZen batch evaluation request from the validated credentials (claims extracted via resolveInputDescriptorValues, matching introspection behavior) and calls the PDP. The credential profile scope must be approved by the PDP or the request is denied. Other scopes are granted only when the PDP approves them. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api_test.go | 21 ++++++---- auth/api/iam/s2s_vptoken.go | 70 +++++++++++++++++++++++++++++--- auth/api/iam/s2s_vptoken_test.go | 22 ++++++++++ 3 files changed, 98 insertions(+), 15 deletions(-) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index ba9be2cd51..8091dde4ea 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -190,7 +190,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("ok", func(t *testing.T) { test := newTestClient(t) - test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "example-scope", WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope"}}) @@ -215,7 +215,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { walletOwnerMapping := pe.WalletOwnerMapping{pe.WalletOwnerUser: pe.PresentationDefinition{Id: "test"}} test := newTestClient(t) - test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "example-scope", WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope", WalletOwnerType: &userWalletType}}) @@ -227,7 +227,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("err - unknown wallet type", func(t *testing.T) { test := newTestClient(t) - test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "example-scope", WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope", WalletOwnerType: &userWalletType}}) @@ -289,7 +289,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { OpenIDProvider: serverMetadata, }, } - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "test"}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "test", WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "test"}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) ctx.iamClient.EXPECT().OpenIDConfiguration(gomock.Any(), holderURL.String()).Return(&configuration, nil) ctx.jar.EXPECT().Create(verifierDID, verifierURL.String(), holderClientID, gomock.Any()).DoAndReturn(func(client did.DID, clientID string, audience string, modifier requestObjectModifier) jarRequest { req := createJarRequest(client, clientID, audience, modifier) @@ -1578,7 +1578,8 @@ type testCtx struct { iamClient *iam.MockClient jwtSigner *cryptoNuts.MockJWTSigner keyResolver *resolver.MockKeyResolver - policy *policy.MockPDPBackend + policy *policy.MockPDPBackend + authzenEvaluator *policy.MockAuthZenEvaluator resolver *resolver.MockDIDResolver relyingParty *oauthServices.MockRelyingParty vcr *vcr.MockVCR @@ -1600,6 +1601,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b storageEngine := storage.NewTestStorageEngine(t) authnServices := auth.NewMockAuthenticationServices(ctrl) policyInstance := policy.NewMockPDPBackend(ctrl) + authzenEvaluator := policy.NewMockAuthZenEvaluator(ctrl) mockResolver := resolver.NewMockDIDResolver(ctrl) relyingPary := oauthServices.NewMockRelyingParty(ctrl) vcIssuer := issuer.NewMockIssuer(ctrl) @@ -1641,10 +1643,11 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b jar: mockJAR, } return &testCtx{ - ctrl: ctrl, - authnServices: authnServices, - policy: policyInstance, - relyingParty: relyingPary, + ctrl: ctrl, + authnServices: authnServices, + policy: policyInstance, + authzenEvaluator: authzenEvaluator, + relyingParty: relyingPary, vcIssuer: vcIssuer, vcVerifier: vcVerifier, resolver: mockResolver, diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 79cc0d8d09..64a08f22c1 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -30,6 +30,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/policy/authzen" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -117,7 +118,7 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin // Compute granted scopes based on scope policy. Never pass through the raw input scope // directly — always derive granted scopes from the policy decision. - grantedScope, err := grantedScopesForPolicy(match) + grantedScope, err := r.grantedScopesForPolicy(ctx, match, credentialSubjectID, *pexConsumer) if err != nil { return nil, err } @@ -133,8 +134,8 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin // grantedScopesForPolicy returns the scopes to include in the access token based on the scope policy. // Profile-only grants only the credential profile scope. Passthrough grants the credential profile -// scope plus all other requested scopes. Dynamic requires PDP evaluation and is not yet handled here. -func grantedScopesForPolicy(match *policy.CredentialProfileMatch) (string, error) { +// scope plus all other requested scopes. Dynamic calls the configured AuthZen PDP for per-scope evaluation. +func (r Wrapper) grantedScopesForPolicy(ctx context.Context, match *policy.CredentialProfileMatch, subjectDID did.DID, pexState PEXConsumer) (string, error) { switch match.ScopePolicy { case policy.ScopePolicyProfileOnly: return match.CredentialProfileScope, nil @@ -142,16 +143,73 @@ func grantedScopesForPolicy(match *policy.CredentialProfileMatch) (string, error scopes := append([]string{match.CredentialProfileScope}, match.OtherScopes...) return strings.Join(scopes, " "), nil case policy.ScopePolicyDynamic: + return r.evaluateDynamicScopes(ctx, match, subjectDID, pexState) + default: return "", oauth.OAuth2Error{ Code: oauth.ServerError, - Description: "dynamic scope policy evaluation not yet implemented", + Description: fmt.Sprintf("unsupported scope policy: %s", match.ScopePolicy), } - default: + } +} + +// evaluateDynamicScopes calls the AuthZen PDP to evaluate each requested scope. +// Returns the space-joined granted scopes. If the PDP denies the credential profile scope, +// the request is rejected. Other denied scopes are simply excluded from the granted set. +func (r Wrapper) evaluateDynamicScopes(ctx context.Context, match *policy.CredentialProfileMatch, subjectDID did.DID, pexState PEXConsumer) (string, error) { + evaluator := r.policyBackend.AuthZenEvaluator() + if evaluator == nil { + // Should be caught at startup by policy.LocalPDP.Configure, but guard here defensively. return "", oauth.OAuth2Error{ Code: oauth.ServerError, - Description: fmt.Sprintf("unsupported scope policy: %s", match.ScopePolicy), + Description: "dynamic scope policy configured but no AuthZen evaluator available", + } + } + credentialMap, err := pexState.credentialMap() + if err != nil { + return "", err + } + claims, err := resolveInputDescriptorValues(pexState.RequiredPresentationDefinitions, credentialMap) + if err != nil { + return "", err + } + allScopes := append([]string{match.CredentialProfileScope}, match.OtherScopes...) + request := authzen.EvaluationsRequest{ + Subject: authzen.Subject{ + Type: "organization", + ID: subjectDID.String(), + Properties: authzen.SubjectProperties{ + Organization: claims, + }, + }, + Action: authzen.Action{Name: "request_scope"}, + Context: authzen.EvaluationContext{Policy: match.CredentialProfileScope}, + Evaluations: make([]authzen.Evaluation, len(allScopes)), + } + for i, s := range allScopes { + request.Evaluations[i] = authzen.Evaluation{Resource: authzen.Resource{Type: "scope", ID: s}} + } + + decisions, err := evaluator.Evaluate(ctx, request) + if err != nil { + return "", oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "AuthZen PDP evaluation failed: " + err.Error(), + InternalError: err, + } + } + if !decisions[match.CredentialProfileScope] { + return "", oauth.OAuth2Error{ + Code: oauth.AccessDenied, + Description: fmt.Sprintf("PDP denied credential profile scope %q", match.CredentialProfileScope), + } + } + granted := []string{match.CredentialProfileScope} + for _, s := range match.OtherScopes { + if decisions[s] { + granted = append(granted, s) } } + return strings.Join(granted, " "), nil } func resolveInputDescriptorValues(presentationDefinitions pe.WalletOwnerMapping, credentialMap map[string]vc.VerifiableCredential) (map[string]any, error) { diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index b5a3f0fe3e..b596f6b41e 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -408,6 +408,28 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + require.NoError(t, err) + require.IsType(t, HandleTokenRequest200JSONResponse{}, resp) + tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) + assert.Equal(t, "example-scope extra-scope", *tokenResponse.Scope) + }) + t.Run("dynamic scope policy - PDP approves all scopes", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) + ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(map[string]bool{ + "example-scope": true, + "extra-scope": true, + }, nil) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + require.NoError(t, err) require.IsType(t, HandleTokenRequest200JSONResponse{}, resp) tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) From 3c23f415f31d8e7793a22a5a083053edceb9db74 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 14:23:40 +0200 Subject: [PATCH 23/34] Add dynamic scope policy tests: partial denial, profile denial, PDP error - Partial denial: denied other scopes excluded, approved ones granted - PDP denies credential profile scope: request rejected (access_denied) - PDP call fails: server_error returned with details Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/s2s_vptoken_test.go | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index b596f6b41e..032f502392 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -435,6 +435,65 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) assert.Equal(t, "example-scope extra-scope", *tokenResponse.Scope) }) + t.Run("dynamic scope policy - PDP partial denial excludes denied scopes", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope other-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope", "other-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) + ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(map[string]bool{ + "example-scope": true, + "extra-scope": true, + "other-scope": false, + }, nil) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope other-scope", submissionJSON, presentation.Raw()) + + require.NoError(t, err) + tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) + assert.Equal(t, "example-scope extra-scope", *tokenResponse.Scope) + }) + t.Run("dynamic scope policy - PDP denies credential profile scope - access_denied", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) + ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(map[string]bool{ + "example-scope": false, + "extra-scope": true, + }, nil) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + _ = assertOAuthErrorWithCode(t, err, oauth.AccessDenied, `PDP denied credential profile scope "example-scope"`) + assert.Nil(t, resp) + }) + t.Run("dynamic scope policy - PDP error returns server_error", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) + ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(nil, errors.New("PDP unreachable")) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + _ = assertOAuthErrorWithCode(t, err, oauth.ServerError, "AuthZen PDP evaluation failed: PDP unreachable") + assert.Nil(t, resp) + }) } func TestWrapper_createAccessToken(t *testing.T) { From de5213bc29c4acedfc8f23aba10f4e4be652c822 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 14:38:08 +0200 Subject: [PATCH 24/34] Apply self-review fixes - Use StrictHTTPClient (timeout + response body limit) for AuthZen client instead of http.DefaultClient (memory #4185) - Wrap credentialMap() / resolveInputDescriptorValues errors as OAuth2Error to preserve the spec-compliant error response contract - Use generic Description for PDP errors, keep details in InternalError to avoid leaking PDP internals to the OAuth2 client - Tighten dynamic-approves-all test to verify AuthZen request shape (subject.type, action.name, context.policy, evaluations layout) - Fix AuthZenEvaluator interface doc comment - Apply gofmt Follow-up issues: - #4202: apply scope policy to OpenID4VP / auth-code flow - Claim role-bucket mismatch deferred to #4080 (two-VP flow) Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api_test.go | 60 ++++++++++++++++---------------- auth/api/iam/openid4vp_test.go | 4 +-- auth/api/iam/s2s_vptoken.go | 10 ++++-- auth/api/iam/s2s_vptoken_test.go | 22 +++++++++--- policy/interface.go | 3 +- policy/local.go | 9 +++-- 6 files changed, 66 insertions(+), 42 deletions(-) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 8091dde4ea..5424ab8e1d 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1571,24 +1571,24 @@ func statusCodeFrom(err error) int { } type testCtx struct { - authnServices *auth.MockAuthenticationServices - ctrl *gomock.Controller - client *Wrapper - documentOwner *didsubject.MockDocumentOwner - iamClient *iam.MockClient - jwtSigner *cryptoNuts.MockJWTSigner - keyResolver *resolver.MockKeyResolver - policy *policy.MockPDPBackend - authzenEvaluator *policy.MockAuthZenEvaluator - resolver *resolver.MockDIDResolver - relyingParty *oauthServices.MockRelyingParty - vcr *vcr.MockVCR - vdr *vdr.MockVDR - vcIssuer *issuer.MockIssuer - vcVerifier *verifier.MockVerifier - wallet *holder.MockWallet - subjectManager *didsubject.MockManager - jar *MockJAR + authnServices *auth.MockAuthenticationServices + ctrl *gomock.Controller + client *Wrapper + documentOwner *didsubject.MockDocumentOwner + iamClient *iam.MockClient + jwtSigner *cryptoNuts.MockJWTSigner + keyResolver *resolver.MockKeyResolver + policy *policy.MockPDPBackend + authzenEvaluator *policy.MockAuthZenEvaluator + resolver *resolver.MockDIDResolver + relyingParty *oauthServices.MockRelyingParty + vcr *vcr.MockVCR + vdr *vdr.MockVDR + vcIssuer *issuer.MockIssuer + vcVerifier *verifier.MockVerifier + wallet *holder.MockWallet + subjectManager *didsubject.MockManager + jar *MockJAR } func newTestClient(t testing.TB) *testCtx { @@ -1648,17 +1648,17 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b policy: policyInstance, authzenEvaluator: authzenEvaluator, relyingParty: relyingPary, - vcIssuer: vcIssuer, - vcVerifier: vcVerifier, - resolver: mockResolver, - documentOwner: mockDocumentOwner, - subjectManager: subjectManager, - iamClient: iamClient, - vcr: mockVCR, - wallet: mockWallet, - keyResolver: keyResolver, - jwtSigner: jwtSigner, - jar: mockJAR, - client: client, + vcIssuer: vcIssuer, + vcVerifier: vcVerifier, + resolver: mockResolver, + documentOwner: mockDocumentOwner, + subjectManager: subjectManager, + iamClient: iamClient, + vcr: mockVCR, + wallet: mockWallet, + keyResolver: keyResolver, + jwtSigner: jwtSigner, + jar: mockJAR, + client: client, } } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 52e73713ff..86fa35e4c4 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -126,7 +126,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("failed to generate authorization request", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "test", WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) params := defaultParams() ctx.iamClient.EXPECT().OpenIDConfiguration(context.Background(), holderClientID).Return(&oauth.OpenIDConfiguration{ Metadata: oauth.EntityStatementMetadata{ @@ -142,7 +142,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("failed to resolve OpenID configuration", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "test", WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) params := defaultParams() ctx.iamClient.EXPECT().OpenIDConfiguration(context.Background(), holderClientID).Return(nil, assert.AnError) diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 64a08f22c1..8c5cbaf230 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -166,7 +166,11 @@ func (r Wrapper) evaluateDynamicScopes(ctx context.Context, match *policy.Creden } credentialMap, err := pexState.credentialMap() if err != nil { - return "", err + return "", oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "failed to extract credentials for scope evaluation", + InternalError: err, + } } claims, err := resolveInputDescriptorValues(pexState.RequiredPresentationDefinitions, credentialMap) if err != nil { @@ -191,9 +195,11 @@ func (r Wrapper) evaluateDynamicScopes(ctx context.Context, match *policy.Creden decisions, err := evaluator.Evaluate(ctx, request) if err != nil { + // Keep Description generic to avoid leaking PDP internals to the OAuth2 client. + // Details remain available in InternalError for server-side logging. return "", oauth.OAuth2Error{ Code: oauth.ServerError, - Description: "AuthZen PDP evaluation failed: " + err.Error(), + Description: "policy decision point unavailable", InternalError: err, } } diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index 032f502392..eb95d1961b 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -27,6 +27,7 @@ import ( "errors" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/policy/authzen" "go.uber.org/mock/gomock" "net/http" "testing" @@ -423,10 +424,21 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { OtherScopes: []string{"extra-scope"}, }, nil) ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) - ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(map[string]bool{ - "example-scope": true, - "extra-scope": true, - }, nil) + // Verify the AuthZen request shape matches the PRD contract. + ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) { + assert.Equal(t, "organization", req.Subject.Type) + assert.Equal(t, "request_scope", req.Action.Name) + assert.Equal(t, "example-scope", req.Context.Policy) + require.Len(t, req.Evaluations, 2) + assert.Equal(t, "scope", req.Evaluations[0].Resource.Type) + assert.Equal(t, "example-scope", req.Evaluations[0].Resource.ID) + assert.Equal(t, "extra-scope", req.Evaluations[1].Resource.ID) + return map[string]bool{ + "example-scope": true, + "extra-scope": true, + }, nil + }) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) @@ -491,7 +503,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) - _ = assertOAuthErrorWithCode(t, err, oauth.ServerError, "AuthZen PDP evaluation failed: PDP unreachable") + _ = assertOAuthErrorWithCode(t, err, oauth.ServerError, "policy decision point unavailable") assert.Nil(t, resp) }) } diff --git a/policy/interface.go b/policy/interface.go index e577bd4a20..67ed291f27 100644 --- a/policy/interface.go +++ b/policy/interface.go @@ -61,7 +61,8 @@ type CredentialProfileMatch struct { } // AuthZenEvaluator evaluates OAuth2 scopes against an external AuthZen-compatible PDP. -// Defined here so PDPBackend can expose it without callers importing the authzen package directly. +// The interface allows PDPBackend implementations to provide the evaluator (or nil +// when no endpoint is configured) without exposing concrete client types. type AuthZenEvaluator interface { Evaluate(ctx context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) } diff --git a/policy/local.go b/policy/local.go index 872f03f8fa..b8f6d02a04 100644 --- a/policy/local.go +++ b/policy/local.go @@ -23,15 +23,19 @@ import ( "encoding/json" "fmt" "github.com/nuts-foundation/nuts-node/core" + httpClient "github.com/nuts-foundation/nuts-node/http/client" "github.com/nuts-foundation/nuts-node/policy/authzen" "github.com/nuts-foundation/nuts-node/vcr/pe" v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" "io" - "net/http" "os" "strings" + "time" ) +// authzenTimeout is the default timeout for AuthZen PDP requests. +const authzenTimeout = 10 * time.Second + func (sp ScopePolicy) valid() bool { switch sp { case ScopePolicyProfileOnly, ScopePolicyPassthrough, ScopePolicyDynamic: @@ -87,7 +91,8 @@ func (b *LocalPDP) Configure(_ core.ServerConfig) error { } } } else { - b.authzenClient = authzen.NewClient(b.config.AuthZen.Endpoint, http.DefaultClient) + // Use StrictHTTPClient: enforces TLS, bounds response body size, applies timeout. + b.authzenClient = authzen.NewClient(b.config.AuthZen.Endpoint, httpClient.New(authzenTimeout)) } return nil } From 0a236af09de8500d2e522e53286f46398e2666fa Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:21 +0200 Subject: [PATCH 25/34] Initialize branch for PR From 358de0729870ddf30f50e43337052706b26d2524 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:43 +0200 Subject: [PATCH 26/34] Initialize branch for PR From 50d47ff063329f981f5a664171dc8a07b232a08a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:54 +0200 Subject: [PATCH 27/34] Initialize branch for PR From 85a3e06aa239dacfa8e4c90523d41f0a6f47295b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 14:54:00 +0200 Subject: [PATCH 28/34] Add end-to-end integration tests for dynamic scope policy Exercises the server-side token handler with a real AuthZen HTTP client talking to an httptest server. Unlike unit tests that mock the evaluator, this validates the full HTTP roundtrip: request serialization, response parsing, and error propagation. Tests cover: PDP approves all, partial denial, HTTP 500 error. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/integration_test.go | 180 +++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 auth/api/iam/integration_test.go diff --git a/auth/api/iam/integration_test.go b/auth/api/iam/integration_test.go new file mode 100644 index 0000000000..fdaea0b9c0 --- /dev/null +++ b/auth/api/iam/integration_test.go @@ -0,0 +1,180 @@ +/* + * 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/policy" + "github.com/nuts-foundation/nuts-node/policy/authzen" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" + "github.com/nuts-foundation/nuts-node/vcr/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// TestIntegration_DynamicScopePolicy_AuthZenEndToEnd exercises the server-side token handler +// with a real AuthZen HTTP client talking to an httptest server. Unlike the unit tests in +// s2s_vptoken_test.go which mock the AuthZen evaluator directly, this test validates the +// full HTTP roundtrip: request serialization, response parsing, and error propagation. +func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { + // Shared fixtures + var presentationDefinition pe.PresentationDefinition + require.NoError(t, json.Unmarshal([]byte(`{ + "format": { + "ldp_vc": {"proof_type": ["JsonWebSignature2020"]} + }, + "input_descriptors": [{ + "id": "1", + "constraints": { + "fields": [{ + "path": ["$.type"], + "filter": {"type": "string", "const": "NutsOrganizationCredential"} + }] + } + }] + }`), &presentationDefinition)) + walletOwnerMapping := pe.WalletOwnerMapping{pe.WalletOwnerOrganization: presentationDefinition} + + var submission pe.PresentationSubmission + require.NoError(t, json.Unmarshal([]byte(`{ + "descriptor_map": [{"id": "1", "path": "$.verifiableCredential", "format": "ldp_vc"}] + }`), &submission)) + submissionJSONBytes, _ := json.Marshal(submission) + submissionJSON := string(submissionJSONBytes) + + verifiableCredential := test.ValidNutsOrganizationCredential(t) + subjectDID, _ := verifiableCredential.SubjectDID() + proofVisitor := test.LDProofVisitor(func(p *proof.LDProof) { + p.Domain = &issuerClientID + }) + presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential) + + dpopHeader, _, _ := newSignedTestDPoP() + httpRequest := &http.Request{Header: http.Header{"Dpop": []string{dpopHeader.String()}}} + contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) + clientID := "https://example.com/oauth2/holder" + + t.Run("PDP approves all scopes - token issued with all scopes", func(t *testing.T) { + var receivedRequest authzen.EvaluationsRequest + pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/access/v1/evaluations", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.NoError(t, json.NewDecoder(r.Body).Decode(&receivedRequest)) + + resp := authzen.EvaluationsResponse{ + Evaluations: []authzen.EvaluationResult{ + {Decision: true}, + {Decision: true}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer pdpServer.Close() + + realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) + + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(realAuthzenClient) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + require.NoError(t, err) + tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) + assert.Equal(t, "example-scope extra-scope", *tokenResponse.Scope) + + // Verify the PDP received a well-formed request via actual HTTP roundtrip + assert.Equal(t, "organization", receivedRequest.Subject.Type) + assert.Equal(t, "request_scope", receivedRequest.Action.Name) + assert.Equal(t, "example-scope", receivedRequest.Context.Policy) + require.Len(t, receivedRequest.Evaluations, 2) + assert.Equal(t, "scope", receivedRequest.Evaluations[0].Resource.Type) + assert.Equal(t, "example-scope", receivedRequest.Evaluations[0].Resource.ID) + assert.Equal(t, "extra-scope", receivedRequest.Evaluations[1].Resource.ID) + }) + + t.Run("PDP partial denial - denied scopes excluded from token", func(t *testing.T) { + pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := authzen.EvaluationsResponse{ + Evaluations: []authzen.EvaluationResult{ + {Decision: true}, + {Decision: false, Context: &authzen.EvaluationResultContext{Reason: "not permitted"}}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer pdpServer.Close() + realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) + + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(realAuthzenClient) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + require.NoError(t, err) + tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) + assert.Equal(t, "example-scope", *tokenResponse.Scope) + }) + + t.Run("PDP returns HTTP 500 - server_error", func(t *testing.T) { + pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer pdpServer.Close() + realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) + + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(realAuthzenClient) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + _ = assertOAuthErrorWithCode(t, err, oauth.ServerError, "policy decision point unavailable") + assert.Nil(t, resp) + }) +} From 28f442bff8b74195dbeee632564e1ea9b58ec90a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 16:00:47 +0200 Subject: [PATCH 29/34] Add introspection tests for multi-scope tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify that tokens with space-delimited scope strings (from multi-scope requests) are returned unchanged via both IntrospectAccessToken and IntrospectAccessTokenExtended. Also cover backwards compatibility for single-scope legacy tokens. No production code changes needed — the existing introspection passes AccessToken.Scope through as-is, which correctly handles the OAuth2 space-delimited scope format. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api_test.go | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 5424ab8e1d..949e6a4d98 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -726,6 +726,54 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { require.True(t, ok) assert.Equal(t, "Doe", tokenResponse.AdditionalProperties["family_name"]) }) + t.Run("multi-scope token returns space-delimited scope", func(t *testing.T) { + token := AccessToken{ + Expiration: time.Now().Add(time.Hour), + Scope: "urn:nuts:medication-overview patient/Observation.read launch/patient", + } + require.NoError(t, ctx.client.accessTokenServerStore().Put("multi-scope-token", token)) + + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "multi-scope-token"}}) + + require.NoError(t, err) + tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) + require.True(t, ok) + assert.True(t, tokenResponse.Active) + require.NotNil(t, tokenResponse.Scope) + assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read launch/patient", *tokenResponse.Scope) + }) + t.Run("multi-scope token via extended introspection returns space-delimited scope", func(t *testing.T) { + token := AccessToken{ + Expiration: time.Now().Add(time.Hour), + Scope: "urn:nuts:medication-overview patient/Observation.read", + } + require.NoError(t, ctx.client.accessTokenServerStore().Put("multi-scope-ext-token", token)) + + res, err := ctx.client.IntrospectAccessTokenExtended(reqCtx, IntrospectAccessTokenExtendedRequestObject{Body: &TokenIntrospectionRequest{Token: "multi-scope-ext-token"}}) + + require.NoError(t, err) + tokenResponse, ok := res.(IntrospectAccessTokenExtended200JSONResponse) + require.True(t, ok) + assert.True(t, tokenResponse.Active) + require.NotNil(t, tokenResponse.Scope) + assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read", *tokenResponse.Scope) + }) + t.Run("single-scope token (backwards compatibility)", func(t *testing.T) { + // A token issued before the multi-scope feature — only a single scope, no OtherScopes tracked. + token := AccessToken{ + Expiration: time.Now().Add(time.Hour), + Scope: "legacy-single-scope", + } + require.NoError(t, ctx.client.accessTokenServerStore().Put("legacy-token", token)) + + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "legacy-token"}}) + + require.NoError(t, err) + tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) + require.True(t, ok) + assert.True(t, tokenResponse.Active) + assert.Equal(t, "legacy-single-scope", *tokenResponse.Scope) + }) t.Run("InputDescriptorConstraintIdMap contains reserved claim", func(t *testing.T) { token := AccessToken{ Expiration: time.Now().Add(time.Second), From 08c398117b6812236540e3e9ec8530141297e298 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 16:01:59 +0200 Subject: [PATCH 30/34] Add introspection test for claims on multi-scope dynamic tokens Verifies that tokens issued via dynamic scope policy carry their validated credential claims through to the introspection response as AdditionalProperties, enabling resource servers to make authorization decisions without re-processing VPs. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 949e6a4d98..eca7f41592 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -758,6 +758,30 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { require.NotNil(t, tokenResponse.Scope) assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read", *tokenResponse.Scope) }) + t.Run("multi-scope token carries claims via AdditionalProperties", func(t *testing.T) { + // Tokens issued via dynamic scope policy populate InputDescriptorConstraintIdMap + // from the validated credentials. These claims flow through to the introspection + // response as AdditionalProperties, enabling PDPs/resource servers to make + // authorization decisions without re-processing VPs. + token := AccessToken{ + Expiration: time.Now().Add(time.Hour), + Scope: "urn:nuts:medication-overview patient/Observation.read", + InputDescriptorConstraintIdMap: map[string]any{ + "organization_name": "Hospital B.V.", + "organization_ura": "12345678", + }, + } + require.NoError(t, ctx.client.accessTokenServerStore().Put("dynamic-token", token)) + + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "dynamic-token"}}) + + require.NoError(t, err) + tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) + require.True(t, ok) + assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read", *tokenResponse.Scope) + assert.Equal(t, "Hospital B.V.", tokenResponse.AdditionalProperties["organization_name"]) + assert.Equal(t, "12345678", tokenResponse.AdditionalProperties["organization_ura"]) + }) t.Run("single-scope token (backwards compatibility)", func(t *testing.T) { // A token issued before the multi-scope feature — only a single scope, no OtherScopes tracked. token := AccessToken{ From 828a1c1fdc12df1c8907adec0371ecc886360b4a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 16:16:03 +0200 Subject: [PATCH 31/34] Apply self-review fixes: consolidate integration and introspection tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop tests that duplicated existing unit-test coverage or tested trivial pointer pass-through: - Remove HTTP-500 integration test (covered by authzen client tests) - Remove multi-scope introspection tests (introspection is a pointer pass-through; no multi-scope-specific code exists) - Remove backwards-compat introspection test (no compat code exists) - Remove multi-scope claims introspection test (duplicates existing InputDescriptorConstraintIdMap test) Add the security-critical path: - PDP denies credential profile scope over real HTTP → access_denied Use t.Cleanup for httptest server cleanup (proper subtest scoping). Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api_test.go | 72 -------------------------------- auth/api/iam/integration_test.go | 64 +++++++++++++--------------- 2 files changed, 30 insertions(+), 106 deletions(-) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index eca7f41592..5424ab8e1d 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -726,78 +726,6 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { require.True(t, ok) assert.Equal(t, "Doe", tokenResponse.AdditionalProperties["family_name"]) }) - t.Run("multi-scope token returns space-delimited scope", func(t *testing.T) { - token := AccessToken{ - Expiration: time.Now().Add(time.Hour), - Scope: "urn:nuts:medication-overview patient/Observation.read launch/patient", - } - require.NoError(t, ctx.client.accessTokenServerStore().Put("multi-scope-token", token)) - - res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "multi-scope-token"}}) - - require.NoError(t, err) - tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) - require.True(t, ok) - assert.True(t, tokenResponse.Active) - require.NotNil(t, tokenResponse.Scope) - assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read launch/patient", *tokenResponse.Scope) - }) - t.Run("multi-scope token via extended introspection returns space-delimited scope", func(t *testing.T) { - token := AccessToken{ - Expiration: time.Now().Add(time.Hour), - Scope: "urn:nuts:medication-overview patient/Observation.read", - } - require.NoError(t, ctx.client.accessTokenServerStore().Put("multi-scope-ext-token", token)) - - res, err := ctx.client.IntrospectAccessTokenExtended(reqCtx, IntrospectAccessTokenExtendedRequestObject{Body: &TokenIntrospectionRequest{Token: "multi-scope-ext-token"}}) - - require.NoError(t, err) - tokenResponse, ok := res.(IntrospectAccessTokenExtended200JSONResponse) - require.True(t, ok) - assert.True(t, tokenResponse.Active) - require.NotNil(t, tokenResponse.Scope) - assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read", *tokenResponse.Scope) - }) - t.Run("multi-scope token carries claims via AdditionalProperties", func(t *testing.T) { - // Tokens issued via dynamic scope policy populate InputDescriptorConstraintIdMap - // from the validated credentials. These claims flow through to the introspection - // response as AdditionalProperties, enabling PDPs/resource servers to make - // authorization decisions without re-processing VPs. - token := AccessToken{ - Expiration: time.Now().Add(time.Hour), - Scope: "urn:nuts:medication-overview patient/Observation.read", - InputDescriptorConstraintIdMap: map[string]any{ - "organization_name": "Hospital B.V.", - "organization_ura": "12345678", - }, - } - require.NoError(t, ctx.client.accessTokenServerStore().Put("dynamic-token", token)) - - res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "dynamic-token"}}) - - require.NoError(t, err) - tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) - require.True(t, ok) - assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read", *tokenResponse.Scope) - assert.Equal(t, "Hospital B.V.", tokenResponse.AdditionalProperties["organization_name"]) - assert.Equal(t, "12345678", tokenResponse.AdditionalProperties["organization_ura"]) - }) - t.Run("single-scope token (backwards compatibility)", func(t *testing.T) { - // A token issued before the multi-scope feature — only a single scope, no OtherScopes tracked. - token := AccessToken{ - Expiration: time.Now().Add(time.Hour), - Scope: "legacy-single-scope", - } - require.NoError(t, ctx.client.accessTokenServerStore().Put("legacy-token", token)) - - res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "legacy-token"}}) - - require.NoError(t, err) - tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) - require.True(t, ok) - assert.True(t, tokenResponse.Active) - assert.Equal(t, "legacy-single-scope", *tokenResponse.Scope) - }) t.Run("InputDescriptorConstraintIdMap contains reserved claim", func(t *testing.T) { token := AccessToken{ Expiration: time.Now().Add(time.Second), diff --git a/auth/api/iam/integration_test.go b/auth/api/iam/integration_test.go index fdaea0b9c0..3f0c4be2e2 100644 --- a/auth/api/iam/integration_test.go +++ b/auth/api/iam/integration_test.go @@ -38,10 +38,16 @@ import ( // TestIntegration_DynamicScopePolicy_AuthZenEndToEnd exercises the server-side token handler // with a real AuthZen HTTP client talking to an httptest server. Unlike the unit tests in -// s2s_vptoken_test.go which mock the AuthZen evaluator directly, this test validates the -// full HTTP roundtrip: request serialization, response parsing, and error propagation. +// s2s_vptoken_test.go which mock the AuthZen evaluator, this test validates the full HTTP +// roundtrip: request serialization, response parsing, and outcomes that depend on the +// evaluator actually being called. +// +// Scope is intentionally narrow: scenarios covered by policy/authzen/client_test.go (HTTP +// errors, malformed response, timeouts) or by the s2s unit tests (VP validation, profile-only +// rejection) are not duplicated here. The tests below cover the outcomes that require the +// server-side flow + real HTTP together: approved scopes end up in the token, denied extra +// scopes are excluded, and PDP denial of the credential profile scope blocks token issuance. func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { - // Shared fixtures var presentationDefinition pe.PresentationDefinition require.NoError(t, json.Unmarshal([]byte(`{ "format": { @@ -78,24 +84,22 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) clientID := "https://example.com/oauth2/holder" - t.Run("PDP approves all scopes - token issued with all scopes", func(t *testing.T) { + // startPDP starts an httptest server that responds with the given decisions and captures + // the decoded AuthZen request for post-call assertions. + startPDP := func(t *testing.T, decisions []authzen.EvaluationResult) (*httptest.Server, *authzen.EvaluationsRequest) { var receivedRequest authzen.EvaluationsRequest - pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/access/v1/evaluations", r.URL.Path) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) require.NoError(t, json.NewDecoder(r.Body).Decode(&receivedRequest)) - - resp := authzen.EvaluationsResponse{ - Evaluations: []authzen.EvaluationResult{ - {Decision: true}, - {Decision: true}, - }, - } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(authzen.EvaluationsResponse{Evaluations: decisions}) })) - defer pdpServer.Close() + t.Cleanup(server.Close) + return server, &receivedRequest + } + t.Run("PDP approves all scopes - token issued and request shape correct over the wire", func(t *testing.T) { + pdpServer, receivedRequest := startPDP(t, []authzen.EvaluationResult{{Decision: true}, {Decision: true}}) realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) ctx := newTestClient(t) @@ -114,28 +118,20 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) assert.Equal(t, "example-scope extra-scope", *tokenResponse.Scope) - // Verify the PDP received a well-formed request via actual HTTP roundtrip + // Validate request serialization over the wire (not covered by mock-based unit tests). assert.Equal(t, "organization", receivedRequest.Subject.Type) assert.Equal(t, "request_scope", receivedRequest.Action.Name) assert.Equal(t, "example-scope", receivedRequest.Context.Policy) require.Len(t, receivedRequest.Evaluations, 2) - assert.Equal(t, "scope", receivedRequest.Evaluations[0].Resource.Type) assert.Equal(t, "example-scope", receivedRequest.Evaluations[0].Resource.ID) assert.Equal(t, "extra-scope", receivedRequest.Evaluations[1].Resource.ID) }) t.Run("PDP partial denial - denied scopes excluded from token", func(t *testing.T) { - pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resp := authzen.EvaluationsResponse{ - Evaluations: []authzen.EvaluationResult{ - {Decision: true}, - {Decision: false, Context: &authzen.EvaluationResultContext{Reason: "not permitted"}}, - }, - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - })) - defer pdpServer.Close() + pdpServer, _ := startPDP(t, []authzen.EvaluationResult{ + {Decision: true}, + {Decision: false, Context: &authzen.EvaluationResultContext{Reason: "not permitted"}}, + }) realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) ctx := newTestClient(t) @@ -155,11 +151,11 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { assert.Equal(t, "example-scope", *tokenResponse.Scope) }) - t.Run("PDP returns HTTP 500 - server_error", func(t *testing.T) { - pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer pdpServer.Close() + t.Run("PDP denies credential profile scope - access_denied, no token issued", func(t *testing.T) { + pdpServer, _ := startPDP(t, []authzen.EvaluationResult{ + {Decision: false}, + {Decision: true}, + }) realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) ctx := newTestClient(t) @@ -174,7 +170,7 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) - _ = assertOAuthErrorWithCode(t, err, oauth.ServerError, "policy decision point unavailable") + _ = assertOAuthErrorWithCode(t, err, oauth.AccessDenied, `PDP denied credential profile scope "example-scope"`) assert.Nil(t, resp) }) } From 513810f0a2a0803208f58245513f88f463976208 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 21 Apr 2026 10:26:42 +0200 Subject: [PATCH 32/34] Address review: use core.TestResponseCode, clarify duplicate-ID check - Adopt core.TestResponseCode for status validation; drops bespoke status check and error-body truncation (HttpError message omits the body, so no log-injection risk). - Wrap PDP error as "authzen: PDP call failed" to disambiguate from the AS server in mixed log output. - Replace duplicate-resource-ID comment with the actual rationale: AuthZen correlates request/response by index, so duplicate IDs would collapse map[string]bool decisions silently. - Clarify NewClient godoc: httpClient must enforce timeouts, TLS, and body size limits (use http/client.StrictHTTPClient in production). Co-Authored-By: Claude Opus 4.7 (1M context) --- policy/authzen/client.go | 17 ++++------------- policy/authzen/client_test.go | 2 +- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/policy/authzen/client.go b/policy/authzen/client.go index d523bb3012..f367510a66 100644 --- a/policy/authzen/client.go +++ b/policy/authzen/client.go @@ -24,7 +24,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "github.com/nuts-foundation/nuts-node/core" @@ -38,8 +37,7 @@ type Client struct { httpClient core.HTTPRequestDoer } -// NewClient creates a new AuthZen client. The endpoint is the base URL of the PDP. -// The httpClient handles timeouts and TLS configuration. +// NewClient creates a new AuthZen client. The httpClient must enforce timeouts, TLS configuration, and response body size limits (use http/client.StrictHTTPClient in production). func NewClient(endpoint string, httpClient core.HTTPRequestDoer) *Client { return &Client{ endpoint: endpoint, @@ -60,7 +58,7 @@ func (c *Client) Evaluate(ctx context.Context, req EvaluationsRequest) (map[stri httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json") - // Check for duplicate resource IDs before sending the request + // AuthZen correlates request and response by index, not by resource ID — duplicate IDs would collapse map[string]bool decisions silently, so reject them at the boundary. seen := make(map[string]bool, len(req.Evaluations)) for _, eval := range req.Evaluations { if seen[eval.Resource.ID] { @@ -75,15 +73,8 @@ func (c *Client) Evaluate(ctx context.Context, req EvaluationsRequest) (map[stri } defer httpResp.Body.Close() - if httpResp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(httpResp.Body) - // Truncate to keep error messages reasonable and prevent log injection - const maxErrorLen = 200 - bodyStr := string(respBody) - if len(bodyStr) > maxErrorLen { - bodyStr = bodyStr[:maxErrorLen] + "..." - } - return nil, fmt.Errorf("authzen: PDP returned HTTP %d: %s", httpResp.StatusCode, bodyStr) + if err := core.TestResponseCode(http.StatusOK, httpResp); err != nil { + return nil, fmt.Errorf("authzen: PDP call failed: %w", err) } var resp EvaluationsResponse diff --git a/policy/authzen/client_test.go b/policy/authzen/client_test.go index 4ad9513f03..2f2dee5e21 100644 --- a/policy/authzen/client_test.go +++ b/policy/authzen/client_test.go @@ -111,7 +111,7 @@ func TestClient_Evaluate(t *testing.T) { }, }) - assert.ErrorContains(t, err, "PDP returned HTTP 500") + assert.ErrorContains(t, err, "authzen: PDP call failed: server returned HTTP 500") }) t.Run("PDP unreachable", func(t *testing.T) { client := NewClient("http://localhost:1", http.DefaultClient) 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 33/34] 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") }) } From bd2ccb455eb4e1e9604984e849f2a244b6a01689 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 11 May 2026 11:59:42 +0200 Subject: [PATCH 34/34] Address review: extract ScopeGranter, rename, document AuthZen flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move scope-policy evaluation out of the API layer into policy.ScopeGranter with per-policy implementations. Addresses reinkrul's comment that the AuthZen request construction should not live in auth/api/iam/. - Introduce policy.ScopeEvaluator + ScopeEvaluationInput as the generic abstraction over PDP backends; AuthZen becomes an adapter wired up via policy.NewAuthZenScopeEvaluator and exposed by LocalPDP.ScopeEvaluator(). - Fail fast in NewScopeGranter for profile-only + extra scopes, dynamic without an evaluator, and unsupported policies — these errors surface before VP cryptographic verification work is done. - Rename `match` to `credentialProfile` in s2s_vptoken.go and the granter. - Note startup failure in the policy.authzen.endpoint flag description. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/api_test.go | 6 +- auth/api/iam/integration_test.go | 6 +- auth/api/iam/s2s_vptoken.go | 115 +++------------- auth/api/iam/s2s_vptoken_test.go | 32 ++--- policy/cmd.go | 2 +- policy/interface.go | 37 ++++- policy/local.go | 14 +- policy/mock.go | 67 +++++++-- policy/scope_granter.go | 166 +++++++++++++++++++++++ policy/scope_granter_test.go | 226 +++++++++++++++++++++++++++++++ 10 files changed, 524 insertions(+), 147 deletions(-) create mode 100644 policy/scope_granter.go create mode 100644 policy/scope_granter_test.go diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 5424ab8e1d..a3a016ce35 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1579,7 +1579,7 @@ type testCtx struct { jwtSigner *cryptoNuts.MockJWTSigner keyResolver *resolver.MockKeyResolver policy *policy.MockPDPBackend - authzenEvaluator *policy.MockAuthZenEvaluator + scopeEvaluator *policy.MockScopeEvaluator resolver *resolver.MockDIDResolver relyingParty *oauthServices.MockRelyingParty vcr *vcr.MockVCR @@ -1601,7 +1601,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b storageEngine := storage.NewTestStorageEngine(t) authnServices := auth.NewMockAuthenticationServices(ctrl) policyInstance := policy.NewMockPDPBackend(ctrl) - authzenEvaluator := policy.NewMockAuthZenEvaluator(ctrl) + scopeEvaluator := policy.NewMockScopeEvaluator(ctrl) mockResolver := resolver.NewMockDIDResolver(ctrl) relyingPary := oauthServices.NewMockRelyingParty(ctrl) vcIssuer := issuer.NewMockIssuer(ctrl) @@ -1646,7 +1646,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b ctrl: ctrl, authnServices: authnServices, policy: policyInstance, - authzenEvaluator: authzenEvaluator, + scopeEvaluator: scopeEvaluator, relyingParty: relyingPary, vcIssuer: vcIssuer, vcVerifier: vcVerifier, diff --git a/auth/api/iam/integration_test.go b/auth/api/iam/integration_test.go index 3f0c4be2e2..4d7282b4c5 100644 --- a/auth/api/iam/integration_test.go +++ b/auth/api/iam/integration_test.go @@ -110,7 +110,7 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { ScopePolicy: policy.ScopePolicyDynamic, OtherScopes: []string{"extra-scope"}, }, nil) - ctx.policy.EXPECT().AuthZenEvaluator().Return(realAuthzenClient) + ctx.policy.EXPECT().ScopeEvaluator().Return(policy.NewAuthZenScopeEvaluator(realAuthzenClient)) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) @@ -142,7 +142,7 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { ScopePolicy: policy.ScopePolicyDynamic, OtherScopes: []string{"extra-scope"}, }, nil) - ctx.policy.EXPECT().AuthZenEvaluator().Return(realAuthzenClient) + ctx.policy.EXPECT().ScopeEvaluator().Return(policy.NewAuthZenScopeEvaluator(realAuthzenClient)) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) @@ -166,7 +166,7 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { ScopePolicy: policy.ScopePolicyDynamic, OtherScopes: []string{"extra-scope"}, }, nil) - ctx.policy.EXPECT().AuthZenEvaluator().Return(realAuthzenClient) + ctx.policy.EXPECT().ScopeEvaluator().Return(policy.NewAuthZenScopeEvaluator(realAuthzenClient)) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 8c5cbaf230..34212badbf 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -23,14 +23,12 @@ import ( "errors" "fmt" "net/http" - "strings" "time" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/policy" - "github.com/nuts-foundation/nuts-node/policy/authzen" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -76,17 +74,15 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin return nil, err } } - match, err := r.findCredentialProfile(ctx, scope) + credentialProfile, err := r.findCredentialProfile(ctx, scope) if err != nil { return nil, 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", - } + granter, err := policy.NewScopeGranter(credentialProfile, r.policyBackend.ScopeEvaluator) + if err != nil { + return nil, err } - pexConsumer := newPEXConsumer(match.WalletOwnerMapping) + pexConsumer := newPEXConsumer(credentialProfile.WalletOwnerMapping) if err := pexConsumer.fulfill(*submission, *pexEnvelope); err != nil { return nil, oauthError(oauth.InvalidRequest, err.Error()) } @@ -118,104 +114,33 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin // Compute granted scopes based on scope policy. Never pass through the raw input scope // directly — always derive granted scopes from the policy decision. - grantedScope, err := r.grantedScopesForPolicy(ctx, match, credentialSubjectID, *pexConsumer) - if err != nil { - return nil, err - } - - // All OK, allow access - issuerURL := r.subjectToBaseURL(subject) - response, err := r.createAccessToken(issuerURL.String(), clientID, time.Now(), grantedScope, *pexConsumer, dpopProof) - if err != nil { - return nil, err - } - return HandleTokenRequest200JSONResponse(*response), nil -} - -// grantedScopesForPolicy returns the scopes to include in the access token based on the scope policy. -// Profile-only grants only the credential profile scope. Passthrough grants the credential profile -// scope plus all other requested scopes. Dynamic calls the configured AuthZen PDP for per-scope evaluation. -func (r Wrapper) grantedScopesForPolicy(ctx context.Context, match *policy.CredentialProfileMatch, subjectDID did.DID, pexState PEXConsumer) (string, error) { - switch match.ScopePolicy { - case policy.ScopePolicyProfileOnly: - return match.CredentialProfileScope, nil - case policy.ScopePolicyPassthrough: - scopes := append([]string{match.CredentialProfileScope}, match.OtherScopes...) - return strings.Join(scopes, " "), nil - case policy.ScopePolicyDynamic: - return r.evaluateDynamicScopes(ctx, match, subjectDID, pexState) - default: - return "", oauth.OAuth2Error{ - Code: oauth.ServerError, - Description: fmt.Sprintf("unsupported scope policy: %s", match.ScopePolicy), - } - } -} - -// evaluateDynamicScopes calls the AuthZen PDP to evaluate each requested scope. -// Returns the space-joined granted scopes. If the PDP denies the credential profile scope, -// the request is rejected. Other denied scopes are simply excluded from the granted set. -func (r Wrapper) evaluateDynamicScopes(ctx context.Context, match *policy.CredentialProfileMatch, subjectDID did.DID, pexState PEXConsumer) (string, error) { - evaluator := r.policyBackend.AuthZenEvaluator() - if evaluator == nil { - // Should be caught at startup by policy.LocalPDP.Configure, but guard here defensively. - return "", oauth.OAuth2Error{ - Code: oauth.ServerError, - Description: "dynamic scope policy configured but no AuthZen evaluator available", - } - } - credentialMap, err := pexState.credentialMap() + credentialMap, err := pexConsumer.credentialMap() if err != nil { - return "", oauth.OAuth2Error{ + return nil, oauth.OAuth2Error{ Code: oauth.ServerError, Description: "failed to extract credentials for scope evaluation", InternalError: err, } } - claims, err := resolveInputDescriptorValues(pexState.RequiredPresentationDefinitions, credentialMap) + claims, err := resolveInputDescriptorValues(pexConsumer.RequiredPresentationDefinitions, credentialMap) if err != nil { - return "", err - } - allScopes := append([]string{match.CredentialProfileScope}, match.OtherScopes...) - request := authzen.EvaluationsRequest{ - Subject: authzen.Subject{ - Type: "organization", - ID: subjectDID.String(), - Properties: authzen.SubjectProperties{ - Organization: claims, - }, - }, - Action: authzen.Action{Name: "request_scope"}, - Context: authzen.EvaluationContext{Policy: match.CredentialProfileScope}, - Evaluations: make([]authzen.Evaluation, len(allScopes)), + return nil, err } - for i, s := range allScopes { - request.Evaluations[i] = authzen.Evaluation{Resource: authzen.Resource{Type: "scope", ID: s}} + grantedScope, err := granter.Grant(ctx, policy.GrantInput{ + SubjectDID: credentialSubjectID, + PresentationClaims: claims, + }) + if err != nil { + return nil, err } - decisions, err := evaluator.Evaluate(ctx, request) + // All OK, allow access + issuerURL := r.subjectToBaseURL(subject) + response, err := r.createAccessToken(issuerURL.String(), clientID, time.Now(), grantedScope, *pexConsumer, dpopProof) if err != nil { - // Keep Description generic to avoid leaking PDP internals to the OAuth2 client. - // Details remain available in InternalError for server-side logging. - return "", oauth.OAuth2Error{ - Code: oauth.ServerError, - Description: "policy decision point unavailable", - InternalError: err, - } - } - if !decisions[match.CredentialProfileScope] { - return "", oauth.OAuth2Error{ - Code: oauth.AccessDenied, - Description: fmt.Sprintf("PDP denied credential profile scope %q", match.CredentialProfileScope), - } - } - granted := []string{match.CredentialProfileScope} - for _, s := range match.OtherScopes { - if decisions[s] { - granted = append(granted, s) - } + return nil, err } - return strings.Join(granted, " "), nil + return HandleTokenRequest200JSONResponse(*response), nil } func resolveInputDescriptorValues(presentationDefinitions pe.WalletOwnerMapping, credentialMap map[string]vc.VerifiableCredential) (map[string]any, error) { diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index eb95d1961b..bb07c86ae4 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -27,7 +27,6 @@ import ( "errors" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/policy" - "github.com/nuts-foundation/nuts-node/policy/authzen" "go.uber.org/mock/gomock" "net/http" "testing" @@ -423,17 +422,14 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { ScopePolicy: policy.ScopePolicyDynamic, OtherScopes: []string{"extra-scope"}, }, nil) - ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) - // Verify the AuthZen request shape matches the PRD contract. - ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) { - assert.Equal(t, "organization", req.Subject.Type) - assert.Equal(t, "request_scope", req.Action.Name) - assert.Equal(t, "example-scope", req.Context.Policy) - require.Len(t, req.Evaluations, 2) - assert.Equal(t, "scope", req.Evaluations[0].Resource.Type) - assert.Equal(t, "example-scope", req.Evaluations[0].Resource.ID) - assert.Equal(t, "extra-scope", req.Evaluations[1].Resource.ID) + ctx.policy.EXPECT().ScopeEvaluator().Return(ctx.scopeEvaluator) + // Verify the handler forwards the right ScopeEvaluationInput to the evaluator. + // AuthZen wire-shape assertions live in TestAuthZenScopeEvaluator (unit) and + // integration_test.go (end-to-end against an httptest PDP). + ctx.scopeEvaluator.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, in policy.ScopeEvaluationInput) (map[string]bool, error) { + assert.Equal(t, "example-scope", in.CredentialProfileScope) + assert.Equal(t, []string{"example-scope", "extra-scope"}, in.Scopes) return map[string]bool{ "example-scope": true, "extra-scope": true, @@ -456,8 +452,8 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { ScopePolicy: policy.ScopePolicyDynamic, OtherScopes: []string{"extra-scope", "other-scope"}, }, nil) - ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) - ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(map[string]bool{ + ctx.policy.EXPECT().ScopeEvaluator().Return(ctx.scopeEvaluator) + ctx.scopeEvaluator.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return(map[string]bool{ "example-scope": true, "extra-scope": true, "other-scope": false, @@ -478,8 +474,8 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { ScopePolicy: policy.ScopePolicyDynamic, OtherScopes: []string{"extra-scope"}, }, nil) - ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) - ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(map[string]bool{ + ctx.policy.EXPECT().ScopeEvaluator().Return(ctx.scopeEvaluator) + ctx.scopeEvaluator.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return(map[string]bool{ "example-scope": false, "extra-scope": true, }, nil) @@ -498,8 +494,8 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { ScopePolicy: policy.ScopePolicyDynamic, OtherScopes: []string{"extra-scope"}, }, nil) - ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) - ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(nil, errors.New("PDP unreachable")) + ctx.policy.EXPECT().ScopeEvaluator().Return(ctx.scopeEvaluator) + ctx.scopeEvaluator.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return(nil, errors.New("PDP unreachable")) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) diff --git a/policy/cmd.go b/policy/cmd.go index b339d06e00..808edbb530 100644 --- a/policy/cmd.go +++ b/policy/cmd.go @@ -27,6 +27,6 @@ func FlagSet() *pflag.FlagSet { defCfg := defaultConfig() flagSet := pflag.NewFlagSet("policy", pflag.ContinueOnError) flagSet.String("policy.directory", defCfg.Directory, "Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping.") - flagSet.String("policy.authzen.endpoint", defCfg.AuthZen.Endpoint, "Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'.") + flagSet.String("policy.authzen.endpoint", defCfg.AuthZen.Endpoint, "Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'; the node refuses to start if such a profile is configured but this flag is empty.") return flagSet } diff --git a/policy/interface.go b/policy/interface.go index 67ed291f27..8a805a236c 100644 --- a/policy/interface.go +++ b/policy/interface.go @@ -21,6 +21,8 @@ package policy import ( "context" "errors" + + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/policy/authzen" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -60,9 +62,32 @@ type CredentialProfileMatch struct { OtherScopes []string } -// AuthZenEvaluator evaluates OAuth2 scopes against an external AuthZen-compatible PDP. -// The interface allows PDPBackend implementations to provide the evaluator (or nil -// when no endpoint is configured) without exposing concrete client types. +// ScopeEvaluationInput is the request passed to a ScopeEvaluator. It carries the data +// a PDP needs to make per-scope decisions for a single access token request. +type ScopeEvaluationInput struct { + // CredentialProfileScope identifies which credential profile drives this request. + // PDPs typically route to per-profile rule sets using this value. + CredentialProfileScope string + // Scopes is the full list of scopes to evaluate, starting with CredentialProfileScope. + Scopes []string + // SubjectDID is the DID of the entity whose credentials backed the request. + SubjectDID did.DID + // PresentationClaims are the role-grouped claims extracted from the validated + // presentation(s). Currently only the organization role is populated. + PresentationClaims map[string]any +} + +// ScopeEvaluator evaluates the scopes of an access token request against an external +// policy decision point. It returns a per-scope decision map. The concrete backend +// (AuthZen, Rego, etc.) is an implementation detail behind this interface. +type ScopeEvaluator interface { + EvaluateScopes(ctx context.Context, in ScopeEvaluationInput) (map[string]bool, error) +} + +// AuthZenEvaluator is the low-level seam to an AuthZen-compatible PDP. It is satisfied +// by *authzen.Client. Most code should depend on ScopeEvaluator, which abstracts over +// the choice of PDP backend; AuthZenEvaluator stays exported so the AuthZen-backed +// ScopeEvaluator adapter can be wired up from outside this package (e.g. tests). type AuthZenEvaluator interface { Evaluate(ctx context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) } @@ -74,7 +99,7 @@ type PDPBackend interface { // It parses the space-delimited scope string, identifies exactly one credential profile scope, // and returns the matched profile along with any remaining scopes. FindCredentialProfile(ctx context.Context, scope string) (*CredentialProfileMatch, error) - // AuthZenEvaluator returns the configured AuthZen evaluator for dynamic scope policy evaluation. - // Returns nil when no AuthZen endpoint is configured. - AuthZenEvaluator() AuthZenEvaluator + // ScopeEvaluator returns the configured PDP evaluator for dynamic scope policy evaluation. + // Returns nil when no PDP endpoint is configured. + ScopeEvaluator() ScopeEvaluator } diff --git a/policy/local.go b/policy/local.go index b8f6d02a04..5c75ac4d67 100644 --- a/policy/local.go +++ b/policy/local.go @@ -58,9 +58,9 @@ type LocalPDP struct { config Config // mapping holds the credential profile configuration per scope mapping map[string]credentialProfileConfig - // authzenClient is created during Configure when an AuthZen endpoint is configured. - // It is nil when no endpoint is configured. - authzenClient AuthZenEvaluator + // scopeEvaluator is the configured ScopeEvaluator used for dynamic scope policy + // evaluation. It is nil when no PDP endpoint is configured. + scopeEvaluator ScopeEvaluator } func (b *LocalPDP) Name() string { @@ -92,14 +92,14 @@ func (b *LocalPDP) Configure(_ core.ServerConfig) error { } } else { // Use StrictHTTPClient: enforces TLS, bounds response body size, applies timeout. - b.authzenClient = authzen.NewClient(b.config.AuthZen.Endpoint, httpClient.New(authzenTimeout)) + b.scopeEvaluator = NewAuthZenScopeEvaluator(authzen.NewClient(b.config.AuthZen.Endpoint, httpClient.New(authzenTimeout))) } return nil } -// AuthZenEvaluator returns the AuthZen evaluator, or nil when no endpoint is configured. -func (b *LocalPDP) AuthZenEvaluator() AuthZenEvaluator { - return b.authzenClient +// ScopeEvaluator returns the configured ScopeEvaluator, or nil when no PDP endpoint is configured. +func (b *LocalPDP) ScopeEvaluator() ScopeEvaluator { + return b.scopeEvaluator } func (b *LocalPDP) Config() interface{} { diff --git a/policy/mock.go b/policy/mock.go index fd51dbfe26..88a06382d6 100644 --- a/policy/mock.go +++ b/policy/mock.go @@ -17,6 +17,45 @@ import ( gomock "go.uber.org/mock/gomock" ) +// MockScopeEvaluator is a mock of ScopeEvaluator interface. +type MockScopeEvaluator struct { + ctrl *gomock.Controller + recorder *MockScopeEvaluatorMockRecorder + isgomock struct{} +} + +// MockScopeEvaluatorMockRecorder is the mock recorder for MockScopeEvaluator. +type MockScopeEvaluatorMockRecorder struct { + mock *MockScopeEvaluator +} + +// NewMockScopeEvaluator creates a new mock instance. +func NewMockScopeEvaluator(ctrl *gomock.Controller) *MockScopeEvaluator { + mock := &MockScopeEvaluator{ctrl: ctrl} + mock.recorder = &MockScopeEvaluatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockScopeEvaluator) EXPECT() *MockScopeEvaluatorMockRecorder { + return m.recorder +} + +// EvaluateScopes mocks base method. +func (m *MockScopeEvaluator) EvaluateScopes(ctx context.Context, in ScopeEvaluationInput) (map[string]bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EvaluateScopes", ctx, in) + ret0, _ := ret[0].(map[string]bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EvaluateScopes indicates an expected call of EvaluateScopes. +func (mr *MockScopeEvaluatorMockRecorder) EvaluateScopes(ctx, in any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EvaluateScopes", reflect.TypeOf((*MockScopeEvaluator)(nil).EvaluateScopes), ctx, in) +} + // MockAuthZenEvaluator is a mock of AuthZenEvaluator interface. type MockAuthZenEvaluator struct { ctrl *gomock.Controller @@ -80,20 +119,6 @@ func (m *MockPDPBackend) EXPECT() *MockPDPBackendMockRecorder { return m.recorder } -// AuthZenEvaluator mocks base method. -func (m *MockPDPBackend) AuthZenEvaluator() AuthZenEvaluator { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AuthZenEvaluator") - ret0, _ := ret[0].(AuthZenEvaluator) - return ret0 -} - -// AuthZenEvaluator indicates an expected call of AuthZenEvaluator. -func (mr *MockPDPBackendMockRecorder) AuthZenEvaluator() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthZenEvaluator", reflect.TypeOf((*MockPDPBackend)(nil).AuthZenEvaluator)) -} - // FindCredentialProfile mocks base method. func (m *MockPDPBackend) FindCredentialProfile(ctx context.Context, scope string) (*CredentialProfileMatch, error) { m.ctrl.T.Helper() @@ -108,3 +133,17 @@ func (mr *MockPDPBackendMockRecorder) FindCredentialProfile(ctx, scope any) *gom mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindCredentialProfile", reflect.TypeOf((*MockPDPBackend)(nil).FindCredentialProfile), ctx, scope) } + +// ScopeEvaluator mocks base method. +func (m *MockPDPBackend) ScopeEvaluator() ScopeEvaluator { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ScopeEvaluator") + ret0, _ := ret[0].(ScopeEvaluator) + return ret0 +} + +// ScopeEvaluator indicates an expected call of ScopeEvaluator. +func (mr *MockPDPBackendMockRecorder) ScopeEvaluator() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScopeEvaluator", reflect.TypeOf((*MockPDPBackend)(nil).ScopeEvaluator)) +} diff --git a/policy/scope_granter.go b/policy/scope_granter.go new file mode 100644 index 0000000000..9f141c8a7d --- /dev/null +++ b/policy/scope_granter.go @@ -0,0 +1,166 @@ +/* + * 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 policy + +import ( + "context" + "fmt" + "strings" + + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy/authzen" +) + +// ScopeGranter computes the scopes to grant for an access token request, based on a +// credential profile match. There is one implementation per ScopePolicy mode. +type ScopeGranter interface { + Grant(ctx context.Context, in GrantInput) (string, error) +} + +// GrantInput carries the per-request data that ScopeGranter implementations may need. +// SubjectDID and PresentationClaims are only consumed by the dynamic granter; the +// other modes ignore them. +type GrantInput struct { + SubjectDID did.DID + PresentationClaims map[string]any +} + +// NewScopeGranter returns a ScopeGranter that implements credentialProfile.ScopePolicy. +// resolveEvaluator is invoked only when the scope policy is ScopePolicyDynamic; the +// other modes do not need a ScopeEvaluator and the function is not called. +func NewScopeGranter(credentialProfile *CredentialProfileMatch, resolveEvaluator func() ScopeEvaluator) (ScopeGranter, error) { + if credentialProfile == nil { + return nil, fmt.Errorf("credential profile is required") + } + switch credentialProfile.ScopePolicy { + case ScopePolicyProfileOnly: + // Fail fast: reject extra scopes at construction time so the caller can + // short-circuit before doing expensive VP verification work. + if len(credentialProfile.OtherScopes) > 0 { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidScope, + Description: "scope policy 'profile-only' does not allow additional scopes", + } + } + return profileOnlyGranter{credentialProfile: credentialProfile}, nil + case ScopePolicyPassthrough: + return passthroughGranter{credentialProfile: credentialProfile}, nil + case ScopePolicyDynamic: + evaluator := resolveEvaluator() + if evaluator == nil { + // Should be caught at startup by LocalPDP.Configure, but guard here defensively. + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "dynamic scope policy configured but no ScopeEvaluator available", + } + } + return dynamicGranter{credentialProfile: credentialProfile, evaluator: evaluator}, nil + default: + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("unsupported scope policy: %s", credentialProfile.ScopePolicy), + } + } +} + +type profileOnlyGranter struct { + credentialProfile *CredentialProfileMatch +} + +func (g profileOnlyGranter) Grant(_ context.Context, _ GrantInput) (string, error) { + return g.credentialProfile.CredentialProfileScope, nil +} + +type passthroughGranter struct { + credentialProfile *CredentialProfileMatch +} + +func (g passthroughGranter) Grant(_ context.Context, _ GrantInput) (string, error) { + scopes := append([]string{g.credentialProfile.CredentialProfileScope}, g.credentialProfile.OtherScopes...) + return strings.Join(scopes, " "), nil +} + +type dynamicGranter struct { + credentialProfile *CredentialProfileMatch + evaluator ScopeEvaluator +} + +func (g dynamicGranter) Grant(ctx context.Context, in GrantInput) (string, error) { + allScopes := append([]string{g.credentialProfile.CredentialProfileScope}, g.credentialProfile.OtherScopes...) + decisions, err := g.evaluator.EvaluateScopes(ctx, ScopeEvaluationInput{ + CredentialProfileScope: g.credentialProfile.CredentialProfileScope, + Scopes: allScopes, + SubjectDID: in.SubjectDID, + PresentationClaims: in.PresentationClaims, + }) + if err != nil { + // Keep Description generic to avoid leaking PDP internals to the OAuth2 client. + // Details remain available in InternalError for server-side logging. + return "", oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "policy decision point unavailable", + InternalError: err, + } + } + if !decisions[g.credentialProfile.CredentialProfileScope] { + return "", oauth.OAuth2Error{ + Code: oauth.AccessDenied, + Description: fmt.Sprintf("PDP denied credential profile scope %q", g.credentialProfile.CredentialProfileScope), + } + } + granted := []string{g.credentialProfile.CredentialProfileScope} + for _, s := range g.credentialProfile.OtherScopes { + if decisions[s] { + granted = append(granted, s) + } + } + return strings.Join(granted, " "), nil +} + +// NewAuthZenScopeEvaluator returns a ScopeEvaluator backed by an AuthZen-compatible PDP. +// The underlying AuthZenEvaluator is typically an *authzen.Client. The adapter is the +// single place that knows the AuthZen wire format; callers work in terms of the generic +// ScopeEvaluationInput shape. +func NewAuthZenScopeEvaluator(client AuthZenEvaluator) ScopeEvaluator { + return authzenScopeEvaluator{client: client} +} + +type authzenScopeEvaluator struct { + client AuthZenEvaluator +} + +func (a authzenScopeEvaluator) EvaluateScopes(ctx context.Context, in ScopeEvaluationInput) (map[string]bool, error) { + request := authzen.EvaluationsRequest{ + Subject: authzen.Subject{ + Type: "organization", + ID: in.SubjectDID.String(), + Properties: authzen.SubjectProperties{ + Organization: in.PresentationClaims, + }, + }, + Action: authzen.Action{Name: "request_scope"}, + Context: authzen.EvaluationContext{Policy: in.CredentialProfileScope}, + Evaluations: make([]authzen.Evaluation, len(in.Scopes)), + } + for i, s := range in.Scopes { + request.Evaluations[i] = authzen.Evaluation{Resource: authzen.Resource{Type: "scope", ID: s}} + } + return a.client.Evaluate(ctx, request) +} diff --git a/policy/scope_granter_test.go b/policy/scope_granter_test.go new file mode 100644 index 0000000000..9201aa850d --- /dev/null +++ b/policy/scope_granter_test.go @@ -0,0 +1,226 @@ +/* + * 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 policy + +import ( + "context" + "errors" + "testing" + + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy/authzen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestNewScopeGranter(t *testing.T) { + profile := func(p ScopePolicy, others ...string) *CredentialProfileMatch { + return &CredentialProfileMatch{ + CredentialProfileScope: "profile-scope", + ScopePolicy: p, + OtherScopes: others, + } + } + noEvaluator := func() ScopeEvaluator { return nil } + + t.Run("nil credential profile returns error", func(t *testing.T) { + _, err := NewScopeGranter(nil, noEvaluator) + require.Error(t, err) + }) + t.Run("unsupported scope policy returns server_error", func(t *testing.T) { + _, err := NewScopeGranter(profile("bogus"), noEvaluator) + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.ServerError, oauthErr.Code) + }) + t.Run("dynamic without evaluator returns server_error", func(t *testing.T) { + _, err := NewScopeGranter(profile(ScopePolicyDynamic), noEvaluator) + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.ServerError, oauthErr.Code) + }) + t.Run("non-dynamic policies do not invoke resolveEvaluator", func(t *testing.T) { + called := false + resolve := func() ScopeEvaluator { + called = true + return nil + } + _, err := NewScopeGranter(profile(ScopePolicyProfileOnly), resolve) + require.NoError(t, err) + _, err = NewScopeGranter(profile(ScopePolicyPassthrough), resolve) + require.NoError(t, err) + assert.False(t, called, "resolveEvaluator should not be called for non-dynamic policies") + }) +} + +func TestProfileOnlyGranter(t *testing.T) { + t.Run("grants the credential profile scope when no other scopes are present", func(t *testing.T) { + g, err := NewScopeGranter(&CredentialProfileMatch{ + CredentialProfileScope: "profile-scope", + ScopePolicy: ScopePolicyProfileOnly, + }, nil) + require.NoError(t, err) + granted, err := g.Grant(context.Background(), GrantInput{}) + require.NoError(t, err) + assert.Equal(t, "profile-scope", granted) + }) + t.Run("constructor rejects extra scopes with invalid_scope (fail-fast before VP work)", func(t *testing.T) { + _, err := NewScopeGranter(&CredentialProfileMatch{ + CredentialProfileScope: "profile-scope", + ScopePolicy: ScopePolicyProfileOnly, + OtherScopes: []string{"extra"}, + }, nil) + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.InvalidScope, oauthErr.Code) + }) +} + +func TestPassthroughGranter(t *testing.T) { + g, err := NewScopeGranter(&CredentialProfileMatch{ + CredentialProfileScope: "profile-scope", + ScopePolicy: ScopePolicyPassthrough, + OtherScopes: []string{"extra-a", "extra-b"}, + }, nil) + require.NoError(t, err) + granted, err := g.Grant(context.Background(), GrantInput{}) + require.NoError(t, err) + assert.Equal(t, "profile-scope extra-a extra-b", granted) +} + +func TestDynamicGranter(t *testing.T) { + subjectDID := did.MustParseDID("did:web:example.com") + match := &CredentialProfileMatch{ + CredentialProfileScope: "profile-scope", + ScopePolicy: ScopePolicyDynamic, + OtherScopes: []string{"extra-a", "extra-b"}, + } + + t.Run("forwards subject, profile, claims and full scope list to the evaluator", func(t *testing.T) { + ctrl := gomock.NewController(t) + ev := NewMockScopeEvaluator(ctrl) + ev.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, in ScopeEvaluationInput) (map[string]bool, error) { + assert.Equal(t, "profile-scope", in.CredentialProfileScope) + assert.Equal(t, []string{"profile-scope", "extra-a", "extra-b"}, in.Scopes) + assert.Equal(t, subjectDID, in.SubjectDID) + assert.Equal(t, map[string]any{"name": "Hospital"}, in.PresentationClaims) + return map[string]bool{"profile-scope": true, "extra-a": true, "extra-b": true}, nil + }) + g, err := NewScopeGranter(match, func() ScopeEvaluator { return ev }) + require.NoError(t, err) + granted, err := g.Grant(context.Background(), GrantInput{ + SubjectDID: subjectDID, + PresentationClaims: map[string]any{"name": "Hospital"}, + }) + require.NoError(t, err) + assert.Equal(t, "profile-scope extra-a extra-b", granted) + }) + t.Run("excludes denied other scopes from the granted set", func(t *testing.T) { + ctrl := gomock.NewController(t) + ev := NewMockScopeEvaluator(ctrl) + ev.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return( + map[string]bool{"profile-scope": true, "extra-a": true, "extra-b": false}, nil, + ) + g, _ := NewScopeGranter(match, func() ScopeEvaluator { return ev }) + granted, err := g.Grant(context.Background(), GrantInput{SubjectDID: subjectDID}) + require.NoError(t, err) + assert.Equal(t, "profile-scope extra-a", granted) + }) + t.Run("returns access_denied when the credential profile scope is denied", func(t *testing.T) { + ctrl := gomock.NewController(t) + ev := NewMockScopeEvaluator(ctrl) + ev.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return( + map[string]bool{"profile-scope": false, "extra-a": true}, nil, + ) + g, _ := NewScopeGranter(match, func() ScopeEvaluator { return ev }) + _, err := g.Grant(context.Background(), GrantInput{SubjectDID: subjectDID}) + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.AccessDenied, oauthErr.Code) + }) + t.Run("returns server_error when the evaluator fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + ev := NewMockScopeEvaluator(ctrl) + ev.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return(nil, errors.New("pdp boom")) + g, _ := NewScopeGranter(match, func() ScopeEvaluator { return ev }) + _, err := g.Grant(context.Background(), GrantInput{SubjectDID: subjectDID}) + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.ServerError, oauthErr.Code) + assert.Equal(t, "policy decision point unavailable", oauthErr.Description) + }) +} + +// TestAuthZenScopeEvaluator covers the AuthZen-specific wire shape produced by the +// adapter. This is the boundary where the generic ScopeEvaluationInput is translated +// into the AuthZen Access Evaluations request format documented in PRD #4144. +func TestAuthZenScopeEvaluator(t *testing.T) { + subjectDID := did.MustParseDID("did:web:hospital.example.com") + claims := map[string]any{"name": "Hospital B.V.", "ura": "12345678"} + in := ScopeEvaluationInput{ + CredentialProfileScope: "urn:nuts:medication-overview", + Scopes: []string{"urn:nuts:medication-overview", "patient/Observation.read"}, + SubjectDID: subjectDID, + PresentationClaims: claims, + } + + t.Run("translates input to AuthZen request shape per PRD contract", func(t *testing.T) { + ctrl := gomock.NewController(t) + client := NewMockAuthZenEvaluator(ctrl) + client.EXPECT().Evaluate(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) { + assert.Equal(t, "organization", req.Subject.Type) + assert.Equal(t, subjectDID.String(), req.Subject.ID) + assert.Equal(t, claims, req.Subject.Properties.Organization) + assert.Equal(t, "request_scope", req.Action.Name) + assert.Equal(t, "urn:nuts:medication-overview", req.Context.Policy) + require.Len(t, req.Evaluations, 2) + assert.Equal(t, "scope", req.Evaluations[0].Resource.Type) + assert.Equal(t, "urn:nuts:medication-overview", req.Evaluations[0].Resource.ID) + assert.Equal(t, "scope", req.Evaluations[1].Resource.Type) + assert.Equal(t, "patient/Observation.read", req.Evaluations[1].Resource.ID) + return map[string]bool{ + "urn:nuts:medication-overview": true, + "patient/Observation.read": false, + }, nil + }) + + evaluator := NewAuthZenScopeEvaluator(client) + decisions, err := evaluator.EvaluateScopes(context.Background(), in) + + require.NoError(t, err) + assert.Equal(t, map[string]bool{ + "urn:nuts:medication-overview": true, + "patient/Observation.read": false, + }, decisions) + }) + t.Run("propagates client error", func(t *testing.T) { + ctrl := gomock.NewController(t) + client := NewMockAuthZenEvaluator(ctrl) + client.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom")) + + evaluator := NewAuthZenScopeEvaluator(client) + _, err := evaluator.EvaluateScopes(context.Background(), in) + + require.EqualError(t, err, "boom") + }) +}