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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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/91] 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 25c35af2113adae951991b9dc812a288d772b753 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 27 Apr 2026 07:40:23 +0200 Subject: [PATCH 34/91] Make HTTPErrorHandler idempotent on committed responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Echo's BodyDump middleware (used when http.log: metadata-and-body) calls c.Error(err) on its way out and still returns the err, so echo's server loop invokes HTTPErrorHandler a second time for the same request. The first invocation wrote the response correctly; the second logged the operation error a second time and warned "Unable to send error back to client, response already committed" — visible noise without a real problem. echo.DefaultHTTPErrorHandler short-circuits on Committed=true; ours did not. Mirror that behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- core/echo_errors.go | 28 +++++++++++++------------ core/echo_errors_test.go | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/core/echo_errors.go b/core/echo_errors.go index 4848ab2076..6eb8a25f7d 100644 --- a/core/echo_errors.go +++ b/core/echo_errors.go @@ -50,6 +50,15 @@ const unmappedStatusCode = 0 // CreateHTTPErrorHandler returns an Echo HTTPErrorHandler that logs the error with extra fields and returns it as an HTTP response. func CreateHTTPErrorHandler() echo.HTTPErrorHandler { return func(err error, ctx echo.Context) { + // Echo can invoke the error handler twice for the same request: middleware + // like BodyDump calls c.Error(err) on its way out and still returns the err, + // causing echo's server loop to invoke the error handler a second time. + // Skip the second invocation — the response has already been written and + // the error logged by the first invocation. echo.DefaultHTTPErrorHandler + // behaves the same way. + if ctx.Response().Committed { + return + } // HTTPErrors occur e.g. when a parameter bind fails. We map this to a httpStatusCodeError so its status code // and message get directly mapped to a problem. if echoErr, ok := err.(*echo.HTTPError); ok { @@ -76,19 +85,12 @@ func CreateHTTPErrorHandler() echo.HTTPErrorHandler { } else { logMsg.Warn(title) } - if !ctx.Response().Committed { - errorWriter, _ := ctx.Get(ErrorWriterContextKey).(ErrorWriter) - if errorWriter == nil { - errorWriter = &problemErrorWriter{} - } - writeError := errorWriter.Write(ctx, statusCode, title, err) - if writeError != nil { - logger.Error(err) - } - } else { - logger. - WithError(err). - Warn("Unable to send error back to client, response already committed") + errorWriter, _ := ctx.Get(ErrorWriterContextKey).(ErrorWriter) + if errorWriter == nil { + errorWriter = &problemErrorWriter{} + } + if writeError := errorWriter.Write(ctx, statusCode, title, err); writeError != nil { + logger.Error(err) } } } diff --git a/core/echo_errors_test.go b/core/echo_errors_test.go index baedee9b1f..b50e12291a 100644 --- a/core/echo_errors_test.go +++ b/core/echo_errors_test.go @@ -21,6 +21,9 @@ package core import ( "errors" "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/sirupsen/logrus" + logrustest "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/assert" "io" "net/http" @@ -89,6 +92,47 @@ func TestHttpErrorHandler(t *testing.T) { }) } +func TestHttpErrorHandler_idempotent_with_BodyDump(t *testing.T) { + // Echo's BodyDump middleware (and any other middleware that calls c.Error + // on the way out and still returns the error) causes echo to invoke the + // HTTPErrorHandler twice for the same request: once via c.Error and once + // via the err returned to echo's server loop. The second invocation must + // be a no-op so it does not log "response already committed" warnings. + logger, hook := logrustest.NewNullLogger() + logger.SetLevel(logrus.DebugLevel) + prevLogger := logrus.StandardLogger() + logrus.SetOutput(io.Discard) + prevHooks := prevLogger.Hooks + prevLogger.Hooks = make(logrus.LevelHooks) + prevLogger.AddHook(hook) + t.Cleanup(func() { + prevLogger.Hooks = prevHooks + logrus.SetOutput(prevLogger.Out) + }) + + es := echo.New() + es.HTTPErrorHandler = CreateHTTPErrorHandler() + es.Use(middleware.BodyDump(func(c echo.Context, _, _ []byte) {})) + es.Add(http.MethodGet, "/", func(c echo.Context) error { + c.Set(OperationIDContextKey, "test") + return errors.New("upstream failed") + }) + server := httptest.NewServer(es) + t.Cleanup(server.Close) + + resp, err := http.Get(server.URL + "/") + assert.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "upstream failed", + "the error must be written to the response exactly once") + + for _, entry := range hook.AllEntries() { + assert.NotContains(t, entry.Message, "response already committed", + "HTTPErrorHandler must be idempotent when invoked on a committed response") + } +} + func Test_NotFoundError(t *testing.T) { err := NotFoundError("failed: %s", "oops").(httpStatusCodeError) assert.EqualError(t, err, "failed: oops") From 81818a364cba8f9e0d33bc6c0b7705e1587c178b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:53:59 +0200 Subject: [PATCH 35/91] Initialize branch for PR From 8c7379108d89626db6ff9c98cb8c01c2f290d22e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 16:27:54 +0200 Subject: [PATCH 36/91] Load client PD block from policy config Adds the WalletOwnerClient constant and threads an optional `client` PresentationDefinition through the policy config loader. Consumers iterating WalletOwnerMapping see the new entry only when a profile declares a `client` block; existing org-only profiles are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- policy/local.go | 4 ++ policy/local_test.go | 17 ++++++ policy/test/client/with_org_client_user.json | 61 ++++++++++++++++++++ vcr/pe/policy.go | 3 + 4 files changed, 85 insertions(+) create mode 100644 policy/test/client/with_org_client_user.json diff --git a/policy/local.go b/policy/local.go index b8f6d02a04..f4e7e4971a 100644 --- a/policy/local.go +++ b/policy/local.go @@ -204,6 +204,7 @@ func (b *LocalPDP) loadFromFile(filename string) error { // credentialProfileConfig holds the configuration for a single credential profile. type credentialProfileConfig struct { Organization *validatingPresentationDefinition `json:"organization,omitempty"` + Client *validatingPresentationDefinition `json:"client,omitempty"` User *validatingPresentationDefinition `json:"user,omitempty"` ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` } @@ -213,6 +214,9 @@ func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { if c.Organization != nil { m[pe.WalletOwnerOrganization] = pe.PresentationDefinition(*c.Organization) } + if c.Client != nil { + m[pe.WalletOwnerClient] = pe.PresentationDefinition(*c.Client) + } if c.User != nil { m[pe.WalletOwnerUser] = pe.PresentationDefinition(*c.User) } diff --git a/policy/local_test.go b/policy/local_test.go index b25e931436..2306812fa5 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -21,6 +21,7 @@ package policy import ( "context" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/pe" "testing" "github.com/stretchr/testify/assert" @@ -128,6 +129,22 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { }) } +func TestLocalPDP_ClientPD(t *testing.T) { + t.Run("loads organization, client, and user PDs from a single profile", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/client/with_org_client_user.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), "example-scope") + require.NoError(t, err) + + assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerOrganization) + assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerClient) + assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerUser) + assert.Equal(t, "pd_client", match.WalletOwnerMapping[pe.WalletOwnerClient].Id) + }) +} + func TestLocalPDP_ScopePolicyConfig(t *testing.T) { t.Run("scope_policy parsed from config", func(t *testing.T) { store := LocalPDP{} diff --git a/policy/test/client/with_org_client_user.json b/policy/test/client/with_org_client_user.json new file mode 100644 index 0000000000..f9250305a0 --- /dev/null +++ b/policy/test/client/with_org_client_user.json @@ -0,0 +1,61 @@ +{ + "example-scope": { + "organization": { + "id": "pd_organization", + "input_descriptors": [ + { + "id": "id_org_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + } + ] + } + } + ] + }, + "client": { + "id": "pd_client", + "input_descriptors": [ + { + "id": "id_client_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "ServiceProviderCredential" + } + } + ] + } + } + ] + }, + "user": { + "id": "pd_user", + "input_descriptors": [ + { + "id": "id_user_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "EmployeeCredential" + } + } + ] + } + } + ] + } + } +} diff --git a/vcr/pe/policy.go b/vcr/pe/policy.go index 32d067320f..1d0811df1c 100644 --- a/vcr/pe/policy.go +++ b/vcr/pe/policy.go @@ -29,4 +29,7 @@ const ( WalletOwnerOrganization = WalletOwnerType("organization") // WalletOwnerUser is used in a WalletOwnerMapping when the PresentationDefinition is intended for a user WalletOwnerUser = WalletOwnerType("user") + // WalletOwnerClient is used in a WalletOwnerMapping when the PresentationDefinition is intended for an OAuth client + // (e.g. a service provider acting on behalf of an organization in the RFC 7523 jwt-bearer flow). + WalletOwnerClient = WalletOwnerType("client") ) From 97309961549cb699e179b319ae2a4b869911e48e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 16:29:26 +0200 Subject: [PATCH 37/91] Guard schema validation of the client PD block Locks the invariant that the client field uses the validating type, so a malformed client PD (e.g. missing input_descriptors) is rejected at load time with the same schema error as organization and user. Co-Authored-By: Claude Opus 4.7 (1M context) --- policy/local_test.go | 7 ++++++ policy/test/invalid/invalid_client_pd.json | 26 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 policy/test/invalid/invalid_client_pd.json diff --git a/policy/local_test.go b/policy/local_test.go index 2306812fa5..efc93bd781 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -143,6 +143,13 @@ func TestLocalPDP_ClientPD(t *testing.T) { assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerUser) assert.Equal(t, "pd_client", match.WalletOwnerMapping[pe.WalletOwnerClient].Id) }) + t.Run("malformed client PD is rejected with a schema-validation error", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/invalid/invalid_client_pd.json") + + assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") + }) } func TestLocalPDP_ScopePolicyConfig(t *testing.T) { diff --git a/policy/test/invalid/invalid_client_pd.json b/policy/test/invalid/invalid_client_pd.json new file mode 100644 index 0000000000..ac36c75a06 --- /dev/null +++ b/policy/test/invalid/invalid_client_pd.json @@ -0,0 +1,26 @@ +{ + "example-scope": { + "organization": { + "id": "pd_organization", + "input_descriptors": [ + { + "id": "id_org_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + } + ] + } + } + ] + }, + "client": { + "id": "pd_client_missing_input_descriptors" + } + } +} From 7f5d69eb3a39e4f31c74775675610dd9b270a9d7 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 16:30:05 +0200 Subject: [PATCH 38/91] Allow client-only credential profiles in policy config Relaxes the load-time check from "at least one of organization or user" to "at least one of organization, client, or user", so a profile that only configures the OAuth-client PD loads. The error message is updated to list all three valid blocks. Co-Authored-By: Claude Opus 4.7 (1M context) --- policy/local.go | 4 ++-- policy/local_test.go | 12 ++++++++++++ policy/test/client/client_only.json | 23 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 policy/test/client/client_only.json diff --git a/policy/local.go b/policy/local.go index f4e7e4971a..fe5c1d0618 100644 --- a/policy/local.go +++ b/policy/local.go @@ -190,8 +190,8 @@ func (b *LocalPDP) loadFromFile(filename string) error { if profile.ScopePolicy == "" { profile.ScopePolicy = ScopePolicyProfileOnly } - if profile.Organization == nil && profile.User == nil { - return fmt.Errorf("credential profile %q must define at least one of 'organization' or 'user' (file=%s)", scope, filename) + if profile.Organization == nil && profile.Client == nil && profile.User == nil { + return fmt.Errorf("credential profile %q must define at least one of 'organization', 'client', or 'user' (file=%s)", scope, filename) } if !profile.ScopePolicy.valid() { return fmt.Errorf("invalid scope_policy %q for scope %q (file=%s)", profile.ScopePolicy, scope, filename) diff --git a/policy/local_test.go b/policy/local_test.go index efc93bd781..d6219161c9 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -150,6 +150,18 @@ func TestLocalPDP_ClientPD(t *testing.T) { assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") }) + t.Run("a profile with only a client PD loads", func(t *testing.T) { + store := LocalPDP{} + err := store.loadFromFile("test/client/client_only.json") + require.NoError(t, err) + + match, err := store.FindCredentialProfile(context.Background(), "client-only-scope") + require.NoError(t, err) + + assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerClient) + assert.NotContains(t, match.WalletOwnerMapping, pe.WalletOwnerOrganization) + assert.NotContains(t, match.WalletOwnerMapping, pe.WalletOwnerUser) + }) } func TestLocalPDP_ScopePolicyConfig(t *testing.T) { diff --git a/policy/test/client/client_only.json b/policy/test/client/client_only.json new file mode 100644 index 0000000000..149631a2ef --- /dev/null +++ b/policy/test/client/client_only.json @@ -0,0 +1,23 @@ +{ + "client-only-scope": { + "client": { + "id": "pd_client_only", + "input_descriptors": [ + { + "id": "id_client_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "ServiceProviderCredential" + } + } + ] + } + } + ] + } + } +} From f1c1054b6743bbd0fa65f5ac3e76d2ad07f3e8f4 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 16:50:53 +0200 Subject: [PATCH 39/91] Self-review fixes for client PD support - Fold the new client-PD subtests into TestStore_LoadFromFile so loading is exercised in one place, and assert WalletOwnerMapping contents instead of just length so an org-only profile no longer accidentally qualifies as a passing absence-of-client check. - Cover the new "no PD defined" validation branch with a dedicated fixture (invalid/no_pds.json), and tighten the malformed-client fixture to contain only a malformed client block so the schema error can only originate from that field. - Document the client block in the operator policy guide, including the RFC021 vs RFC 7523 audience caveat, and add a release-notes entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/pages/deployment/policy.rst | 6 +- docs/pages/release_notes.rst | 1 + policy/local_test.go | 86 ++++++++++++---------- policy/test/invalid/invalid_client_pd.json | 21 +----- policy/test/invalid/no_pds.json | 5 ++ 5 files changed, 59 insertions(+), 60 deletions(-) create mode 100644 policy/test/invalid/no_pds.json diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index d2abaa117e..f7164687e8 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -74,7 +74,11 @@ JSON documents used for policies must have the following structure: Where ``example_scope`` is the scope that the presentation definition is associated with. The ``presentation_definition`` object contains the presentation definition that should be used for the given scope. -The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization`` and ``user``. +The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization``, ``client`` and ``user``. + +The ``client`` block describes the credentials that an OAuth client (a service provider acting on behalf of a healthcare provider in the RFC 7523 ``jwt-bearer`` flow) must present. +It is consulted only when the node initiates an outbound RFC 7523 token request and is ignored when the node acts as a verifier in an RFC021 ``vp_token-bearer`` flow. +A profile may define any combination of ``organization``, ``client`` and ``user`` blocks; at least one is required. OAuth2 Token Introspection field mapping ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index b4bcede5f8..68478183d2 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -8,6 +8,7 @@ Unreleased ## New features * #4063: Enable ``storage.debug`` flag to log go-leia performance issues (full table scans, suboptimal index usage) by @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4064 +* #4078: Allow policy profiles to define a ``client`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 **************** Peanut (v6.2.1) diff --git a/policy/local_test.go b/policy/local_test.go index d6219161c9..ce4f06a4ba 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -29,14 +29,41 @@ import ( ) func TestStore_LoadFromFile(t *testing.T) { - t.Run("loads the mapping from the file", func(t *testing.T) { + t.Run("loads an organization-only profile and exposes it via WalletOwnerMapping", func(t *testing.T) { store := LocalPDP{} err := store.loadFromFile("test/definition_mapping.json") require.NoError(t, err) - assert.Len(t, store.mapping, 1) - assert.NotNil(t, store.mapping["example-scope"]) + require.Len(t, store.mapping, 1) + mapping := store.mapping["example-scope"].toWalletOwnerMapping() + assert.Contains(t, mapping, pe.WalletOwnerOrganization) + assert.NotContains(t, mapping, pe.WalletOwnerClient) + assert.NotContains(t, mapping, pe.WalletOwnerUser) + }) + + t.Run("loads organization, client, and user PDs from a single profile", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/client/with_org_client_user.json") + + require.NoError(t, err) + mapping := store.mapping["example-scope"].toWalletOwnerMapping() + assert.Equal(t, "pd_organization", mapping[pe.WalletOwnerOrganization].Id) + assert.Equal(t, "pd_client", mapping[pe.WalletOwnerClient].Id) + assert.Equal(t, "pd_user", mapping[pe.WalletOwnerUser].Id) + }) + + t.Run("loads a profile that defines only a client PD", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/client/client_only.json") + + require.NoError(t, err) + mapping := store.mapping["client-only-scope"].toWalletOwnerMapping() + assert.Equal(t, "pd_client_only", mapping[pe.WalletOwnerClient].Id) + assert.NotContains(t, mapping, pe.WalletOwnerOrganization) + assert.NotContains(t, mapping, pe.WalletOwnerUser) }) t.Run("returns an error if the file doesn't exist", func(t *testing.T) { @@ -47,13 +74,29 @@ func TestStore_LoadFromFile(t *testing.T) { assert.Error(t, err) }) - t.Run("returns an error if a presentation definition is invalid", func(t *testing.T) { + t.Run("returns an error if the organization PD is invalid", func(t *testing.T) { store := LocalPDP{} err := store.loadFromFile("test/invalid/invalid_definition_mapping.json") assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") }) + + t.Run("returns an error if the client PD is invalid", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/invalid/invalid_client_pd.json") + + assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") + }) + + t.Run("returns an error if no PD is defined for a profile", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/invalid/no_pds.json") + + assert.ErrorContains(t, err, "must define at least one of 'organization', 'client', or 'user'") + }) } func TestLocalPDP_FindCredentialProfile(t *testing.T) { @@ -129,41 +172,6 @@ func TestLocalPDP_FindCredentialProfile(t *testing.T) { }) } -func TestLocalPDP_ClientPD(t *testing.T) { - t.Run("loads organization, client, and user PDs from a single profile", func(t *testing.T) { - store := LocalPDP{} - err := store.loadFromFile("test/client/with_org_client_user.json") - require.NoError(t, err) - - match, err := store.FindCredentialProfile(context.Background(), "example-scope") - require.NoError(t, err) - - assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerOrganization) - assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerClient) - assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerUser) - assert.Equal(t, "pd_client", match.WalletOwnerMapping[pe.WalletOwnerClient].Id) - }) - t.Run("malformed client PD is rejected with a schema-validation error", func(t *testing.T) { - store := LocalPDP{} - - err := store.loadFromFile("test/invalid/invalid_client_pd.json") - - assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") - }) - t.Run("a profile with only a client PD loads", func(t *testing.T) { - store := LocalPDP{} - err := store.loadFromFile("test/client/client_only.json") - require.NoError(t, err) - - match, err := store.FindCredentialProfile(context.Background(), "client-only-scope") - require.NoError(t, err) - - assert.Contains(t, match.WalletOwnerMapping, pe.WalletOwnerClient) - assert.NotContains(t, match.WalletOwnerMapping, pe.WalletOwnerOrganization) - assert.NotContains(t, match.WalletOwnerMapping, pe.WalletOwnerUser) - }) -} - func TestLocalPDP_ScopePolicyConfig(t *testing.T) { t.Run("scope_policy parsed from config", func(t *testing.T) { store := LocalPDP{} diff --git a/policy/test/invalid/invalid_client_pd.json b/policy/test/invalid/invalid_client_pd.json index ac36c75a06..ec5915e570 100644 --- a/policy/test/invalid/invalid_client_pd.json +++ b/policy/test/invalid/invalid_client_pd.json @@ -1,24 +1,5 @@ { - "example-scope": { - "organization": { - "id": "pd_organization", - "input_descriptors": [ - { - "id": "id_org_cred", - "constraints": { - "fields": [ - { - "path": ["$.type"], - "filter": { - "type": "string", - "const": "NutsOrganizationCredential" - } - } - ] - } - } - ] - }, + "client-only-malformed-scope": { "client": { "id": "pd_client_missing_input_descriptors" } diff --git a/policy/test/invalid/no_pds.json b/policy/test/invalid/no_pds.json new file mode 100644 index 0000000000..177d82047c --- /dev/null +++ b/policy/test/invalid/no_pds.json @@ -0,0 +1,5 @@ +{ + "no-pd-scope": { + "scope_policy": "profile_only" + } +} From ab3c606e1bbab4f48ded778634ab9c8c02889176 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:20:41 +0200 Subject: [PATCH 40/91] Rename client wallet-owner type to service_provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original "client" name overloaded an already-busy term in this codebase (httpClient, authzenClient, the OAuth client_id form param) and required a comment to disambiguate. Switching to the domain term "service_provider" — the role this PD describes in the LSPxNuts delegation model — removes that ambiguity and matches the snake_case convention used by sibling compound keys (wallet_owner_type, scope_policy, auth.experimental.jwt_bearer_client). Renames the WalletOwnerClient constant, the credentialProfileConfig field, the JSON config key, the test fixtures and directory, the operator-doc paragraph, and the release-notes entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/pages/deployment/policy.rst | 6 ++--- docs/pages/release_notes.rst | 2 +- policy/local.go | 16 +++++++------- policy/local_test.go | 22 +++++++++---------- policy/test/invalid/invalid_client_pd.json | 7 ------ .../invalid/invalid_service_provider_pd.json | 7 ++++++ .../service_provider_only.json} | 8 +++---- .../with_org_sp_user.json} | 6 ++--- vcr/pe/policy.go | 6 ++--- 9 files changed, 40 insertions(+), 40 deletions(-) delete mode 100644 policy/test/invalid/invalid_client_pd.json create mode 100644 policy/test/invalid/invalid_service_provider_pd.json rename policy/test/{client/client_only.json => service_provider/service_provider_only.json} (72%) rename policy/test/{client/with_org_client_user.json => service_provider/with_org_sp_user.json} (93%) diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index f7164687e8..f3b8bb6b87 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -74,11 +74,11 @@ JSON documents used for policies must have the following structure: Where ``example_scope`` is the scope that the presentation definition is associated with. The ``presentation_definition`` object contains the presentation definition that should be used for the given scope. -The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization``, ``client`` and ``user``. +The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization``, ``service_provider`` and ``user``. -The ``client`` block describes the credentials that an OAuth client (a service provider acting on behalf of a healthcare provider in the RFC 7523 ``jwt-bearer`` flow) must present. +The ``service_provider`` block describes the credentials that a service provider acting on behalf of a healthcare provider (the OAuth client in the RFC 7523 ``jwt-bearer`` flow) must present. It is consulted only when the node initiates an outbound RFC 7523 token request and is ignored when the node acts as a verifier in an RFC021 ``vp_token-bearer`` flow. -A profile may define any combination of ``organization``, ``client`` and ``user`` blocks; at least one is required. +A profile may define any combination of ``organization``, ``service_provider`` and ``user`` blocks; at least one is required. OAuth2 Token Introspection field mapping ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index 68478183d2..7168d3f8e5 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -8,7 +8,7 @@ Unreleased ## New features * #4063: Enable ``storage.debug`` flag to log go-leia performance issues (full table scans, suboptimal index usage) by @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4064 -* #4078: Allow policy profiles to define a ``client`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 +* #4078: Allow policy profiles to define a ``service_provider`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 **************** Peanut (v6.2.1) diff --git a/policy/local.go b/policy/local.go index fe5c1d0618..7ea5abd549 100644 --- a/policy/local.go +++ b/policy/local.go @@ -190,8 +190,8 @@ func (b *LocalPDP) loadFromFile(filename string) error { if profile.ScopePolicy == "" { profile.ScopePolicy = ScopePolicyProfileOnly } - if profile.Organization == nil && profile.Client == nil && profile.User == nil { - return fmt.Errorf("credential profile %q must define at least one of 'organization', 'client', or 'user' (file=%s)", scope, filename) + if profile.Organization == nil && profile.ServiceProvider == nil && profile.User == nil { + return fmt.Errorf("credential profile %q must define at least one of 'organization', 'service_provider', or 'user' (file=%s)", scope, filename) } if !profile.ScopePolicy.valid() { return fmt.Errorf("invalid scope_policy %q for scope %q (file=%s)", profile.ScopePolicy, scope, filename) @@ -203,10 +203,10 @@ func (b *LocalPDP) loadFromFile(filename string) error { // credentialProfileConfig holds the configuration for a single credential profile. type credentialProfileConfig struct { - Organization *validatingPresentationDefinition `json:"organization,omitempty"` - Client *validatingPresentationDefinition `json:"client,omitempty"` - User *validatingPresentationDefinition `json:"user,omitempty"` - ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` + Organization *validatingPresentationDefinition `json:"organization,omitempty"` + ServiceProvider *validatingPresentationDefinition `json:"service_provider,omitempty"` + User *validatingPresentationDefinition `json:"user,omitempty"` + ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` } func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { @@ -214,8 +214,8 @@ func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { if c.Organization != nil { m[pe.WalletOwnerOrganization] = pe.PresentationDefinition(*c.Organization) } - if c.Client != nil { - m[pe.WalletOwnerClient] = pe.PresentationDefinition(*c.Client) + if c.ServiceProvider != nil { + m[pe.WalletOwnerServiceProvider] = pe.PresentationDefinition(*c.ServiceProvider) } if c.User != nil { m[pe.WalletOwnerUser] = pe.PresentationDefinition(*c.User) diff --git a/policy/local_test.go b/policy/local_test.go index ce4f06a4ba..139b12702d 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -38,30 +38,30 @@ func TestStore_LoadFromFile(t *testing.T) { require.Len(t, store.mapping, 1) mapping := store.mapping["example-scope"].toWalletOwnerMapping() assert.Contains(t, mapping, pe.WalletOwnerOrganization) - assert.NotContains(t, mapping, pe.WalletOwnerClient) + assert.NotContains(t, mapping, pe.WalletOwnerServiceProvider) assert.NotContains(t, mapping, pe.WalletOwnerUser) }) - t.Run("loads organization, client, and user PDs from a single profile", func(t *testing.T) { + t.Run("loads organization, service_provider, and user PDs from a single profile", func(t *testing.T) { store := LocalPDP{} - err := store.loadFromFile("test/client/with_org_client_user.json") + err := store.loadFromFile("test/service_provider/with_org_sp_user.json") require.NoError(t, err) mapping := store.mapping["example-scope"].toWalletOwnerMapping() assert.Equal(t, "pd_organization", mapping[pe.WalletOwnerOrganization].Id) - assert.Equal(t, "pd_client", mapping[pe.WalletOwnerClient].Id) + assert.Equal(t, "pd_service_provider", mapping[pe.WalletOwnerServiceProvider].Id) assert.Equal(t, "pd_user", mapping[pe.WalletOwnerUser].Id) }) - t.Run("loads a profile that defines only a client PD", func(t *testing.T) { + t.Run("loads a profile that defines only a service_provider PD", func(t *testing.T) { store := LocalPDP{} - err := store.loadFromFile("test/client/client_only.json") + err := store.loadFromFile("test/service_provider/service_provider_only.json") require.NoError(t, err) - mapping := store.mapping["client-only-scope"].toWalletOwnerMapping() - assert.Equal(t, "pd_client_only", mapping[pe.WalletOwnerClient].Id) + mapping := store.mapping["service-provider-only-scope"].toWalletOwnerMapping() + assert.Equal(t, "pd_service_provider_only", mapping[pe.WalletOwnerServiceProvider].Id) assert.NotContains(t, mapping, pe.WalletOwnerOrganization) assert.NotContains(t, mapping, pe.WalletOwnerUser) }) @@ -82,10 +82,10 @@ func TestStore_LoadFromFile(t *testing.T) { assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") }) - t.Run("returns an error if the client PD is invalid", func(t *testing.T) { + t.Run("returns an error if the service_provider PD is invalid", func(t *testing.T) { store := LocalPDP{} - err := store.loadFromFile("test/invalid/invalid_client_pd.json") + err := store.loadFromFile("test/invalid/invalid_service_provider_pd.json") assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") }) @@ -95,7 +95,7 @@ func TestStore_LoadFromFile(t *testing.T) { err := store.loadFromFile("test/invalid/no_pds.json") - assert.ErrorContains(t, err, "must define at least one of 'organization', 'client', or 'user'") + assert.ErrorContains(t, err, "must define at least one of 'organization', 'service_provider', or 'user'") }) } diff --git a/policy/test/invalid/invalid_client_pd.json b/policy/test/invalid/invalid_client_pd.json deleted file mode 100644 index ec5915e570..0000000000 --- a/policy/test/invalid/invalid_client_pd.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "client-only-malformed-scope": { - "client": { - "id": "pd_client_missing_input_descriptors" - } - } -} diff --git a/policy/test/invalid/invalid_service_provider_pd.json b/policy/test/invalid/invalid_service_provider_pd.json new file mode 100644 index 0000000000..5687658bcd --- /dev/null +++ b/policy/test/invalid/invalid_service_provider_pd.json @@ -0,0 +1,7 @@ +{ + "service-provider-malformed-scope": { + "service_provider": { + "id": "pd_service_provider_missing_input_descriptors" + } + } +} diff --git a/policy/test/client/client_only.json b/policy/test/service_provider/service_provider_only.json similarity index 72% rename from policy/test/client/client_only.json rename to policy/test/service_provider/service_provider_only.json index 149631a2ef..ae3c883ecf 100644 --- a/policy/test/client/client_only.json +++ b/policy/test/service_provider/service_provider_only.json @@ -1,10 +1,10 @@ { - "client-only-scope": { - "client": { - "id": "pd_client_only", + "service-provider-only-scope": { + "service_provider": { + "id": "pd_service_provider_only", "input_descriptors": [ { - "id": "id_client_cred", + "id": "id_sp_cred", "constraints": { "fields": [ { diff --git a/policy/test/client/with_org_client_user.json b/policy/test/service_provider/with_org_sp_user.json similarity index 93% rename from policy/test/client/with_org_client_user.json rename to policy/test/service_provider/with_org_sp_user.json index f9250305a0..35d00a0b2b 100644 --- a/policy/test/client/with_org_client_user.json +++ b/policy/test/service_provider/with_org_sp_user.json @@ -19,11 +19,11 @@ } ] }, - "client": { - "id": "pd_client", + "service_provider": { + "id": "pd_service_provider", "input_descriptors": [ { - "id": "id_client_cred", + "id": "id_sp_cred", "constraints": { "fields": [ { diff --git a/vcr/pe/policy.go b/vcr/pe/policy.go index 1d0811df1c..e3c18ef65f 100644 --- a/vcr/pe/policy.go +++ b/vcr/pe/policy.go @@ -29,7 +29,7 @@ const ( WalletOwnerOrganization = WalletOwnerType("organization") // WalletOwnerUser is used in a WalletOwnerMapping when the PresentationDefinition is intended for a user WalletOwnerUser = WalletOwnerType("user") - // WalletOwnerClient is used in a WalletOwnerMapping when the PresentationDefinition is intended for an OAuth client - // (e.g. a service provider acting on behalf of an organization in the RFC 7523 jwt-bearer flow). - WalletOwnerClient = WalletOwnerType("client") + // WalletOwnerServiceProvider is used in a WalletOwnerMapping when the PresentationDefinition is intended for a + // service provider acting on behalf of an organization (e.g. the OAuth client in the RFC 7523 jwt-bearer flow). + WalletOwnerServiceProvider = WalletOwnerType("service_provider") ) From 156ed6dcb0871e0c02a088bd89f4c26426803063 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:25:17 +0200 Subject: [PATCH 41/91] Reword service_provider doc to state when it applies, not when it doesn't Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/pages/deployment/policy.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index f3b8bb6b87..7285963463 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -77,7 +77,7 @@ The ``presentation_definition`` object contains the presentation definition that The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization``, ``service_provider`` and ``user``. The ``service_provider`` block describes the credentials that a service provider acting on behalf of a healthcare provider (the OAuth client in the RFC 7523 ``jwt-bearer`` flow) must present. -It is consulted only when the node initiates an outbound RFC 7523 token request and is ignored when the node acts as a verifier in an RFC021 ``vp_token-bearer`` flow. +It applies only to outbound RFC 7523 token requests initiated by the node. A profile may define any combination of ``organization``, ``service_provider`` and ``user`` blocks; at least one is required. OAuth2 Token Introspection field mapping From fe93b2ebf58123aa8aa33845bfc0c70dcf6d18d2 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:54:31 +0200 Subject: [PATCH 42/91] Initialize branch for PR From 7df21b3f3bb44618b75dc6fa0fcc309f2c90a467 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:34:54 +0200 Subject: [PATCH 43/91] Plumb serviceProviderSubjectID and the jwt-bearer feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends RequestRFC021AccessToken's signature with an optional serviceProviderSubjectID *string and registers the experimental auth.experimental.jwt_bearer_client config flag (default false). Mock and existing call sites updated to pass nil; behaviour is unchanged. The new parameter is currently unused — gate logic and the two-VP flow follow. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/api.go | 2 +- auth/api/iam/api_test.go | 18 +++++++++--------- auth/client/iam/interface.go | 4 +++- auth/client/iam/mock.go | 8 ++++---- auth/client/iam/openid4vp.go | 3 ++- auth/client/iam/openid4vp_test.go | 20 ++++++++++---------- auth/cmd/cmd.go | 5 +++++ auth/cmd/cmd_test.go | 1 + auth/config.go | 9 +++++++++ 9 files changed, 44 insertions(+), 26 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 6b8125bf02..32b64b7b9c 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -781,7 +781,7 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection) + tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, nil) if err != nil { // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials return nil, err diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 5424ab8e1d..89442584e8 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -886,7 +886,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} request.Params.CacheControl = to.Ptr("no-cache") // Initial call to populate cache - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(response, nil).Times(2) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(response, nil).Times(2) token, err := ctx.client.RequestServiceAccessToken(nil, request) // Test call to check cache is bypassed @@ -907,7 +907,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { TokenType: "Bearer", ExpiresIn: to.Ptr(900), } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(response, nil) token, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -946,7 +946,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("cache expired", func(t *testing.T) { cacheKey := accessTokenRequestCacheKey(request) _ = ctx.client.accessTokenCache().Delete(cacheKey) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) otherToken, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -963,7 +963,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { Scope: "first second", TokenType: &tokenTypeBearer, } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, nil).Return(&oauth.TokenResponse{}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, nil, nil).Return(&oauth.TokenResponse{}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -972,7 +972,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("ok with expired cache by ttl", func(t *testing.T) { ctx := newTestClient(t) request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -981,7 +981,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { }) t.Run("error - no matching credentials", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(nil, pe.ErrNoCredentials) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(nil, pe.ErrNoCredentials) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -997,8 +997,8 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { ctx.client.storageEngine = mockStorage request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) token1, err := ctx.client.RequestServiceAccessToken(nil, request) require.NoError(t, err) @@ -1023,7 +1023,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { {ID: to.Ptr(ssi.MustParseURI("not empty"))}, } request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil, nil).Return(response, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5ccf9caaf5..a9fc92417c 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -46,8 +46,10 @@ type Client interface { // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021. // credentials are additional VCs to include alongside wallet-stored credentials. // credentialSelection maps PD field IDs to expected values to disambiguate when multiple credentials match an input descriptor. + // serviceProviderSubjectID, when non-nil, identifies a service-provider Nuts subject and triggers the RFC 7523 jwt-bearer two-VP flow + // (PSA 10.10). It is only honored when the experimental jwt-bearer client feature is enabled and the AS advertises jwt-bearer. RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, - credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) + credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. // oauthIssuer is the URL of the issuer as specified by RFC 8414 (OAuth 2.0 Authorization Server Metadata). diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index b6ad933a61..6481f2029c 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -194,18 +194,18 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet } // RequestRFC021AccessToken mocks base method. -func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) { +func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection) + ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // RequestRFC021AccessToken indicates an expected call of RequestRFC021AccessToken. -func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection any) *gomock.Call { +func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) } // VerifiableCredentials mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index c0b90ef73f..ad23fe7f52 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -243,7 +243,8 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd } func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, - useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) { + useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { + _ = serviceProviderSubjectID // honored once two-VP logic is in place iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 68203d791d..f6ba41b693 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -253,7 +253,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -265,7 +265,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.ErrorIs(t, err, pe.ErrNoCredentials) assert.Nil(t, response) @@ -275,7 +275,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.authzServerMetadata.DIDMethodsSupported = []string{"other"} ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrPreconditionFailed) @@ -312,7 +312,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return createdVP, &pe.PresentationSubmission{}, nil }) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -326,7 +326,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -348,7 +348,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) var oauthErrResult oauth.OAuth2Error @@ -366,7 +366,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.True(t, errors.As(err, &oauth.OAuth2Error{})) @@ -376,7 +376,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.metadata = nil - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidClientCall) @@ -390,7 +390,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, _ = writer.Write([]byte("{")) } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrBadGateway) @@ -401,7 +401,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, assert.AnError) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.Error(t, err) }) diff --git a/auth/cmd/cmd.go b/auth/cmd/cmd.go index 5a2abf01e5..329adb0a7b 100644 --- a/auth/cmd/cmd.go +++ b/auth/cmd/cmd.go @@ -47,6 +47,9 @@ const ConfAccessTokenLifeSpan = "auth.accesstokenlifespan" // ConfAuthEndpointEnabled is the config key for enabling the Auth v2 API's Authorization Endpoint const ConfAuthEndpointEnabled = "auth.authorizationendpoint.enabled" +// ConfExperimentalJwtBearerClient toggles the RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). +const ConfExperimentalJwtBearerClient = "auth.experimental.jwt_bearer_client" + // FlagSet returns the configuration flags supported by this module. func FlagSet() *pflag.FlagSet { flags := pflag.NewFlagSet("auth", pflag.ContinueOnError) @@ -61,6 +64,8 @@ func FlagSet() *pflag.FlagSet { flags.StringSlice(ConfContractValidators, defs.ContractValidators, "sets the different contract validators to use") flags.Bool(ConfAuthEndpointEnabled, defs.AuthorizationEndpoint.Enabled, "enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. "+ "This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature.") + flags.Bool(ConfExperimentalJwtBearerClient, defs.Experimental.JwtBearerClient, "enables the experimental RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). "+ + "While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice.") _ = flags.MarkDeprecated("auth.http.timeout", "use httpclient.timeout instead") return flags diff --git a/auth/cmd/cmd_test.go b/auth/cmd/cmd_test.go index 5fe44f4c19..88514b3745 100644 --- a/auth/cmd/cmd_test.go +++ b/auth/cmd/cmd_test.go @@ -46,6 +46,7 @@ func TestFlagSet(t *testing.T) { ConfAuthEndpointEnabled, ConfClockSkew, ConfContractValidators, + ConfExperimentalJwtBearerClient, ConfHTTPTimeout, ConfAutoUpdateIrmaSchemas, ConfIrmaCorsOrigin, diff --git a/auth/config.go b/auth/config.go index 0f30bd3c95..ae787ebbbd 100644 --- a/auth/config.go +++ b/auth/config.go @@ -32,6 +32,15 @@ type Config struct { ContractValidators []string `koanf:"contractvalidators"` AccessTokenLifeSpan int `koanf:"accesstokenlifespan"` AuthorizationEndpoint AuthorizationEndpointConfig `koanf:"authorizationendpoint"` + Experimental ExperimentalConfig `koanf:"experimental"` +} + +// ExperimentalConfig groups feature flags for unstable functionality. +// Anything inside is subject to change without notice and may be removed in a future release. +type ExperimentalConfig struct { + // JwtBearerClient enables the RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). + // While disabled (the default), requests carrying a service-provider subject identifier are rejected. + JwtBearerClient bool `koanf:"jwt_bearer_client"` } type AuthorizationEndpointConfig struct { From cfc6d48893e177261df8ddd0e01b6343d3927396 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:36:35 +0200 Subject: [PATCH 44/91] Gate the two-VP flow behind the experimental flag When serviceProviderSubjectID is set but auth.experimental.jwt_bearer_client is false, the request is rejected immediately. The flag value is passed into OpenID4VPClient via NewClient so tests can exercise both states by setting the field directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/auth.go | 2 +- auth/client/iam/openid4vp.go | 41 ++++++++++++++++++------------- auth/client/iam/openid4vp_test.go | 18 ++++++++++++++ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index e869f35f87..57f7d72609 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -129,7 +129,7 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty { func (auth *Auth) IAMClient() iam.Client { keyResolver := resolver.DIDKeyResolver{Resolver: auth.vdrInstance.Resolver()} - return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout) + return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout, auth.config.Experimental.JwtBearerClient) } // Configure the Auth struct by creating a validator and create an Irma server diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index ad23fe7f52..c48a747f68 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -54,32 +54,37 @@ var ErrPreconditionFailed = errors.New("precondition failed") var _ Client = (*OpenID4VPClient)(nil) type OpenID4VPClient struct { - httpClient HTTPClient - jwtSigner nutsCrypto.JWTSigner - keyResolver resolver.KeyResolver - strictMode bool - wallet holder.Wallet - ldDocumentLoader ld.DocumentLoader - subjectManager didsubject.Manager - pdResolver PresentationDefinitionResolver + httpClient HTTPClient + jwtSigner nutsCrypto.JWTSigner + keyResolver resolver.KeyResolver + strictMode bool + wallet holder.Wallet + ldDocumentLoader ld.DocumentLoader + subjectManager didsubject.Manager + pdResolver PresentationDefinitionResolver + policyBackend policy.PDPBackend + experimentalJwtBearerClient bool } // NewClient returns an implementation of Holder func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectManager didsubject.Manager, jwtSigner nutsCrypto.JWTSigner, - ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { + ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration, + experimentalJwtBearerClient bool) *OpenID4VPClient { httpClient := HTTPClient{ strictMode: strictMode, httpClient: client.NewWithCache(httpClientTimeout), keyResolver: keyResolver, } client := &OpenID4VPClient{ - httpClient: httpClient, - keyResolver: keyResolver, - jwtSigner: jwtSigner, - ldDocumentLoader: ldDocumentLoader, - subjectManager: subjectManager, - strictMode: strictMode, - wallet: wallet, + httpClient: httpClient, + keyResolver: keyResolver, + jwtSigner: jwtSigner, + ldDocumentLoader: ldDocumentLoader, + subjectManager: subjectManager, + strictMode: strictMode, + wallet: wallet, + policyBackend: policyBackend, + experimentalJwtBearerClient: experimentalJwtBearerClient, } client.pdResolver = PresentationDefinitionResolver{ pdFetcher: client, @@ -244,7 +249,9 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { - _ = serviceProviderSubjectID // honored once two-VP logic is in place + if serviceProviderSubjectID != nil && !c.experimentalJwtBearerClient { + return nil, errors.New("jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true") + } iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index f6ba41b693..a3e61bc364 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -407,6 +407,24 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) } +func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { + const subjectID = "subby" + const subjectClientID = "https://example.com/oauth2/subby" + const spSubjectID = "service-provider-subby" + scopes := "first second" + + t.Run("rejects the request when the experimental jwt-bearer feature is disabled", func(t *testing.T) { + sp := spSubjectID + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = false + + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.Error(t, err) + assert.ErrorContains(t, err, "jwt-bearer") + }) +} + func TestIAMClient_RequestObjectByGet(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) From ef61c0f88173e3c6d779df6a8a916d22ee5c78fd Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:39:22 +0200 Subject: [PATCH 45/91] Reject two-VP requests when AS does not advertise jwt-bearer Adds the JwtBearerGrantType / JwtBearerClientAssertionType / client- assertion form-param constants and fails the request if the resolved authorization-server metadata does not list jwt-bearer in grant_types_supported. This is the no-silent-fallback rule from the PRD: when the caller has opted into the two-VP flow, an unsupported peer must produce a clear error rather than dropping back to RFC021. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 3 +++ auth/client/iam/openid4vp_test.go | 12 ++++++++++++ auth/oauth/types.go | 8 ++++++++ 3 files changed, 23 insertions(+) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index c48a747f68..1acacf2639 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -257,6 +257,9 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID if err != nil { return nil, err } + if serviceProviderSubjectID != nil && !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { + return nil, errors.New("authorization server does not advertise jwt-bearer support") + } // Resolve the presentation definition: from remote AS when available, local policy otherwise resolved, err := c.pdResolver.Resolve(ctx, scopes, *metadata) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index a3e61bc364..925c791d46 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -423,6 +423,18 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "jwt-bearer") }) + + t.Run("rejects the request when the AS does not advertise jwt-bearer", func(t *testing.T) { + sp := spSubjectID + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + // The default AS metadata in the test setup does not include JwtBearerGrantType in grant_types_supported. + + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.Error(t, err) + assert.ErrorContains(t, err, "authorization server does not advertise jwt-bearer") + }) } func TestIAMClient_RequestObjectByGet(t *testing.T) { diff --git a/auth/oauth/types.go b/auth/oauth/types.go index c0a6d769d2..72a8d6af7e 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -189,6 +189,10 @@ const ( ScopeParam = "scope" // StateParam is the parameter name for the state parameter. (RFC6749) StateParam = "state" + // ClientAssertionTypeParam is the parameter name for the client_assertion_type parameter. (RFC7521) + ClientAssertionTypeParam = "client_assertion_type" + // ClientAssertionParam is the parameter name for the client_assertion parameter. (RFC7521) + ClientAssertionParam = "client_assertion" // VpTokenParam is the parameter name for the vp_token parameter. (OpenID4VP) VpTokenParam = "vp_token" // WalletMetadataParam is used by the wallet to provide its metadata in an authorization request when RequestURIMethodParam is 'post' @@ -205,6 +209,10 @@ const ( PreAuthorizedCodeGrantType = "urn:ietf:params:oauth:grant-type:pre-authorized_code" // VpTokenGrantType is the grant_type for the vp_token-bearer grant type. (RFC021) VpTokenGrantType = "vp_token-bearer" + // JwtBearerGrantType is the grant_type for the RFC 7523 JWT bearer grant type, used by the LSPxNuts two-VP flow (PSA 10.10). + JwtBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" + // JwtBearerClientAssertionType is the client_assertion_type for the RFC 7523 JWT bearer client assertion. + JwtBearerClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" ) // response types From df974c97447fbc3f84cbf23498930f82b586521b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:41:37 +0200 Subject: [PATCH 46/91] Reject two-VP requests when no service_provider PD is configured Once the gate and AS-advertisement checks pass, the two-VP path looks up the credential profile from the local policy backend (the service_provider PD has no remote-endpoint equivalent) and fails loud when the resolved profile does not contain a service_provider PresentationDefinition. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 10 ++++++++++ auth/client/iam/openid4vp_test.go | 29 ++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 1acacf2639..3b66696404 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -260,6 +260,16 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID if serviceProviderSubjectID != nil && !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { return nil, errors.New("authorization server does not advertise jwt-bearer support") } + if serviceProviderSubjectID != nil { + match, err := c.policyBackend.FindCredentialProfile(ctx, scopes) + if err != nil { + return nil, fmt.Errorf("local PD resolution failed: %w", err) + } + if _, ok := match.WalletOwnerMapping[pe.WalletOwnerServiceProvider]; !ok { + return nil, fmt.Errorf("no service_provider presentation definition for scope %q", match.CredentialProfileScope) + } + _ = match // remaining two-VP construction follows in subsequent cycles + } // Resolve the presentation definition: from remote AS when available, local policy otherwise resolved, err := c.pdResolver.Resolve(ctx, scopes, *metadata) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 925c791d46..65fd27d55a 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -40,6 +40,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/policy" http2 "github.com/nuts-foundation/nuts-node/test/http" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -435,6 +436,24 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "authorization server does not advertise jwt-bearer") }) + + t.Run("rejects the request when no service_provider PD is configured for the scope", func(t *testing.T) { + sp := spSubjectID + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + }, + }, nil) + + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.Error(t, err) + assert.ErrorContains(t, err, "no service_provider presentation definition") + }) } func TestIAMClient_RequestObjectByGet(t *testing.T) { @@ -504,6 +523,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon keyResolver := resolver.NewMockKeyResolver(ctrl) subjectManager := didsubject.NewMockManager(ctrl) wallet := holder.NewMockWallet(ctrl) + policyBackend := policy.NewMockPDPBackend(ctrl) if tlsConfig == nil { tlsConfig = &tls.Config{} } @@ -516,10 +536,11 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon strictMode: false, httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), }, - jwtSigner: jwtSigner, - keyResolver: keyResolver, + jwtSigner: jwtSigner, + keyResolver: keyResolver, + policyBackend: policyBackend, } - testClient.pdResolver = PresentationDefinitionResolver{pdFetcher: testClient} + testClient.pdResolver = PresentationDefinitionResolver{pdFetcher: testClient, policyBackend: policyBackend} return &clientTestContext{ audit: audit.TestContext(), @@ -529,6 +550,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon keyResolver: keyResolver, wallet: wallet, subjectManager: subjectManager, + policyBackend: policyBackend, } } @@ -540,6 +562,7 @@ type clientTestContext struct { keyResolver *resolver.MockKeyResolver wallet *holder.MockWallet subjectManager *didsubject.MockManager + policyBackend *policy.MockPDPBackend } type clientServerTestContext struct { From ea4bbc3c652817e80359ae0480d685d3085d9c29 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 17:50:55 +0200 Subject: [PATCH 47/91] Build the jwt-bearer two-VP token request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When serviceProviderSubjectID is set, the request branches into a dedicated path that builds VP1 from the HCP wallet using the organization PD and VP2 from the SP wallet using the service_provider PD, and posts a form body with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, the two VPs as assertion and client_assertion, and no presentation_submission. Extracts filterDIDsByMethods so the new path and the existing single-VP path share the AS-method filtering instead of duplicating the loop. Cross-VP field-id binding is intentionally not yet wired — the next cycle adds the resolveInputDescriptorValues capture and the additive merge into credential_selection. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/interface.go | 4 +- auth/client/iam/openid4vp.go | 134 ++++++++++++++++++++++++------ auth/client/iam/openid4vp_test.go | 55 +++++++++++- auth/cmd/cmd.go | 4 +- auth/config.go | 2 +- auth/oauth/types.go | 2 +- 6 files changed, 164 insertions(+), 37 deletions(-) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index a9fc92417c..440d735863 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -46,8 +46,8 @@ type Client interface { // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021. // credentials are additional VCs to include alongside wallet-stored credentials. // credentialSelection maps PD field IDs to expected values to disambiguate when multiple credentials match an input descriptor. - // serviceProviderSubjectID, when non-nil, identifies a service-provider Nuts subject and triggers the RFC 7523 jwt-bearer two-VP flow - // (PSA 10.10). It is only honored when the experimental jwt-bearer client feature is enabled and the AS advertises jwt-bearer. + // serviceProviderSubjectID, when non-nil, identifies a service-provider Nuts subject and triggers the RFC 7523 jwt-bearer two-VP flow. + // It is only honored when the experimental jwt-bearer client feature is enabled and the AS advertises jwt-bearer. RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 3b66696404..255723a8c6 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -261,14 +261,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return nil, errors.New("authorization server does not advertise jwt-bearer support") } if serviceProviderSubjectID != nil { - match, err := c.policyBackend.FindCredentialProfile(ctx, scopes) - if err != nil { - return nil, fmt.Errorf("local PD resolution failed: %w", err) - } - if _, ok := match.WalletOwnerMapping[pe.WalletOwnerServiceProvider]; !ok { - return nil, fmt.Errorf("no service_provider presentation definition for scope %q", match.CredentialProfileScope) - } - _ = match // remaining two-VP construction follows in subsequent cycles + return c.requestJwtBearerAccessToken(ctx, clientID, subjectID, *serviceProviderSubjectID, authServerURL, scopes, additionalCredentials, credentialSelection, metadata) } // Resolve the presentation definition: from remote AS when available, local policy otherwise @@ -291,25 +284,9 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return nil, err } - // in the s2s flow we use the metadata to determine the DID methods supported by the verifier - // filter walletDIDs on the DID methods supported by the verifier - j := 0 - allMethods := map[string]struct{}{} - for i, d := range subjectDIDs { - allMethods[d.Method] = struct{}{} - if slices.Contains(metadata.DIDMethodsSupported, d.Method) { - subjectDIDs[j] = subjectDIDs[i] - j++ - } - } - subjectDIDs = subjectDIDs[:j] - - if len(subjectDIDs) == 0 { - availableMethods := make([]string, 0, len(allMethods)) - for key := range maps.Keys(allMethods) { - availableMethods = append(availableMethods, key) - } - return nil, errors.Join(ErrPreconditionFailed, fmt.Errorf("did method mismatch, requested: %v, available: %v", metadata.DIDMethodsSupported, availableMethods)) + subjectDIDs, err = filterDIDsByMethods(subjectDIDs, metadata.DIDMethodsSupported) + if err != nil { + return nil, err } // each additional credential can be used by each DID @@ -369,6 +346,109 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return &tokenResponse, nil } +// requestJwtBearerAccessToken implements the RFC 7523 jwt-bearer two-VP token request flow. +// It builds VP1 from the HCP wallet (using the organization PD) and VP2 from the SP wallet (using the +// service_provider PD), assembles them as `assertion` and `client_assertion`, and POSTs the token request. +func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clientID string, subjectID string, serviceProviderSubjectID string, + authServerURL string, scopes string, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, + metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { + match, err := c.policyBackend.FindCredentialProfile(ctx, scopes) + if err != nil { + return nil, fmt.Errorf("local PD resolution failed: %w", err) + } + orgPD, hasOrg := match.WalletOwnerMapping[pe.WalletOwnerOrganization] + if !hasOrg { + return nil, fmt.Errorf("no organization presentation definition for scope %q", match.CredentialProfileScope) + } + spPD, hasSP := match.WalletOwnerMapping[pe.WalletOwnerServiceProvider] + if !hasSP { + return nil, fmt.Errorf("no service_provider presentation definition for scope %q", match.CredentialProfileScope) + } + params := holder.BuildParams{ + Audience: authServerURL, + DIDMethods: metadata.DIDMethodsSupported, + Expires: time.Now().Add(time.Second * 5), + Format: metadata.VPFormatsSupported, + Nonce: nutsCrypto.GenerateNonce(), + } + vp1, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + if err != nil { + return nil, err + } + vp2, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + if err != nil { + return nil, err + } + data := url.Values{} + data.Set(oauth.ClientIDParam, clientID) + data.Set(oauth.GrantTypeParam, oauth.JwtBearerGrantType) + data.Set(oauth.AssertionParam, vp1.Raw()) + data.Set(oauth.ClientAssertionTypeParam, oauth.JwtBearerClientAssertionType) + data.Set(oauth.ClientAssertionParam, vp2.Raw()) + data.Set(oauth.ScopeParam, scopes) + + log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n VP1: %s\n VP2: %s", metadata.TokenEndpoint, scopes, vp1.Raw(), vp2.Raw()) + token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, "") + if err != nil { + return nil, err + } + return &oauth.TokenResponse{ + AccessToken: token.AccessToken, + ExpiresIn: token.ExpiresIn, + TokenType: token.TokenType, + Scope: &scopes, + }, nil +} + +// buildSubmissionForSubject lists DIDs for the given subject, filters them to those whose method is supported +// by the AS, and asks the wallet to build a VP that fulfills the given PresentationDefinition. +func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subjectID string, presentationDefinition pe.PresentationDefinition, + additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, params holder.BuildParams, + supportedDIDMethods []string) (*vc.VerifiablePresentation, error) { + subjectDIDs, err := c.subjectManager.ListDIDs(ctx, subjectID) + if err != nil { + return nil, err + } + subjectDIDs, err = filterDIDsByMethods(subjectDIDs, supportedDIDMethods) + if err != nil { + return nil, err + } + additionalWalletCredentials := map[did.DID][]vc.VerifiableCredential{} + for _, subjectDID := range subjectDIDs { + for _, curr := range additionalCredentials { + additionalWalletCredentials[subjectDID] = append(additionalWalletCredentials[subjectDID], credential.AutoCorrectSelfAttestedCredential(curr, subjectDID)) + } + } + vp, _, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) + if err != nil { + return nil, err + } + return vp, nil +} + +// filterDIDsByMethods drops DIDs whose method is not in supportedMethods. Returns ErrPreconditionFailed when +// none of the subject's DIDs use a supported method. +func filterDIDsByMethods(subjectDIDs []did.DID, supportedMethods []string) ([]did.DID, error) { + j := 0 + allMethods := map[string]struct{}{} + for i, d := range subjectDIDs { + allMethods[d.Method] = struct{}{} + if slices.Contains(supportedMethods, d.Method) { + subjectDIDs[j] = subjectDIDs[i] + j++ + } + } + subjectDIDs = subjectDIDs[:j] + if len(subjectDIDs) == 0 { + availableMethods := make([]string, 0, len(allMethods)) + for key := range maps.Keys(allMethods) { + availableMethods = append(availableMethods, key) + } + return nil, errors.Join(ErrPreconditionFailed, fmt.Errorf("did method mismatch, requested: %v, available: %v", supportedMethods, availableMethods)) + } + return subjectDIDs, nil +} + func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) { iamClient := c.httpClient rsp, err := iamClient.OpenIdCredentialIssuerMetadata(ctx, oauthIssuerURI) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 65fd27d55a..3d5696661d 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -341,7 +341,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { } oauthErrorBytes, _ := json.Marshal(oauthError) ctx := createClientServerTestContext(t) - ctx.token = func(writer http.ResponseWriter) { + ctx.token = func(writer http.ResponseWriter, _ *http.Request) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusBadRequest) _, _ = writer.Write(oauthErrorBytes) @@ -454,6 +454,53 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "no service_provider presentation definition") }) + + t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) { + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + vp1, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + vp2, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // VP1 is built from the HCP wallet using the organization PD; VP2 from the SP wallet using the service_provider PD. + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(vp1, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), + pe.PresentationDefinition{Id: "sp_pd"}, gomock.Any(), gomock.Any()).Return(vp2, &pe.PresentationSubmission{}, nil) + + var capturedForm url.Values + ctx.token = func(writer http.ResponseWriter, request *http.Request) { + require.NoError(t, request.ParseForm()) + capturedForm = request.PostForm + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) + } + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, oauth.JwtBearerGrantType, capturedForm.Get(oauth.GrantTypeParam)) + assert.Equal(t, vp1.Raw(), capturedForm.Get(oauth.AssertionParam)) + assert.Equal(t, oauth.JwtBearerClientAssertionType, capturedForm.Get(oauth.ClientAssertionTypeParam)) + assert.Equal(t, vp2.Raw(), capturedForm.Get(oauth.ClientAssertionParam)) + assert.Empty(t, capturedForm.Get(oauth.PresentationSubmissionParam)) + }) } func TestIAMClient_RequestObjectByGet(t *testing.T) { @@ -579,7 +626,7 @@ type clientServerTestContext struct { credentialIssuerMetadata func(writer http.ResponseWriter) presentationDefinition func(writer http.ResponseWriter) response func(writer http.ResponseWriter) - token func(writer http.ResponseWriter) + token func(writer http.ResponseWriter, request *http.Request) credentials func(writer http.ResponseWriter) requestObjectJWT func(writer http.ResponseWriter) } @@ -628,7 +675,7 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { _, _ = writer.Write(bytes) return }, - token: func(writer http.ResponseWriter) { + token: func(writer http.ResponseWriter, _ *http.Request) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) @@ -681,7 +728,7 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { } case "/token": if ctx.token != nil { - ctx.token(writer) + ctx.token(writer, request) return } case "/credentials": diff --git a/auth/cmd/cmd.go b/auth/cmd/cmd.go index 329adb0a7b..137a019f9d 100644 --- a/auth/cmd/cmd.go +++ b/auth/cmd/cmd.go @@ -47,7 +47,7 @@ const ConfAccessTokenLifeSpan = "auth.accesstokenlifespan" // ConfAuthEndpointEnabled is the config key for enabling the Auth v2 API's Authorization Endpoint const ConfAuthEndpointEnabled = "auth.authorizationendpoint.enabled" -// ConfExperimentalJwtBearerClient toggles the RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). +// ConfExperimentalJwtBearerClient toggles the RFC 7523 jwt-bearer two-VP token request flow. const ConfExperimentalJwtBearerClient = "auth.experimental.jwt_bearer_client" // FlagSet returns the configuration flags supported by this module. @@ -64,7 +64,7 @@ func FlagSet() *pflag.FlagSet { flags.StringSlice(ConfContractValidators, defs.ContractValidators, "sets the different contract validators to use") flags.Bool(ConfAuthEndpointEnabled, defs.AuthorizationEndpoint.Enabled, "enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. "+ "This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature.") - flags.Bool(ConfExperimentalJwtBearerClient, defs.Experimental.JwtBearerClient, "enables the experimental RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). "+ + flags.Bool(ConfExperimentalJwtBearerClient, defs.Experimental.JwtBearerClient, "enables the experimental RFC 7523 jwt-bearer two-VP token request flow. "+ "While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice.") _ = flags.MarkDeprecated("auth.http.timeout", "use httpclient.timeout instead") diff --git a/auth/config.go b/auth/config.go index ae787ebbbd..0b8387837d 100644 --- a/auth/config.go +++ b/auth/config.go @@ -38,7 +38,7 @@ type Config struct { // ExperimentalConfig groups feature flags for unstable functionality. // Anything inside is subject to change without notice and may be removed in a future release. type ExperimentalConfig struct { - // JwtBearerClient enables the RFC 7523 jwt-bearer two-VP token request flow (LSPxNuts PSA 10.10). + // JwtBearerClient enables the RFC 7523 jwt-bearer two-VP token request flow. // While disabled (the default), requests carrying a service-provider subject identifier are rejected. JwtBearerClient bool `koanf:"jwt_bearer_client"` } diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 72a8d6af7e..e08b62f251 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -209,7 +209,7 @@ const ( PreAuthorizedCodeGrantType = "urn:ietf:params:oauth:grant-type:pre-authorized_code" // VpTokenGrantType is the grant_type for the vp_token-bearer grant type. (RFC021) VpTokenGrantType = "vp_token-bearer" - // JwtBearerGrantType is the grant_type for the RFC 7523 JWT bearer grant type, used by the LSPxNuts two-VP flow (PSA 10.10). + // JwtBearerGrantType is the grant_type for the RFC 7523 JWT bearer grant type. JwtBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" // JwtBearerClientAssertionType is the client_assertion_type for the RFC 7523 JWT bearer client assertion. JwtBearerClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" From 6fe03d598538f5618d9e3e635432af1b00b4b5c5 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 18:00:50 +0200 Subject: [PATCH 48/91] Capture VP1 field-id values into VP2 credential_selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After VP1 is built, resolve the organization PD's id-bearing constraint fields against the submitted credentials and additively merge the resulting field-id → value pairs into the credential_selection map passed to VP2. Existing keys (EHR-supplied) are not overwritten; non-string field values are skipped. This realizes the cross-VP binding that lets a service_provider PD constrain VP2 by values matched in VP1 (for example, a delegating-HCP issuer DID flowing from VP1 into VP2's credential selection) without introducing any non-standard PE features. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 50 +++++++++++++--- auth/client/iam/openid4vp_test.go | 95 +++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 8 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 255723a8c6..a3b7ff446b 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -371,11 +371,17 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clien Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } - vp1, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + vp1, vp1Submission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) if err != nil { return nil, err } - vp2, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + // Capture id-bearing constraint field values resolved against VP1 and additively merge them into the + // credential_selection map for VP2. EHR-supplied keys take precedence over captured values. + credentialSelection, err = mergeCapturedFields(credentialSelection, vp1, vp1Submission, orgPD) + if err != nil { + return nil, err + } + vp2, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) if err != nil { return nil, err } @@ -404,14 +410,14 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clien // by the AS, and asks the wallet to build a VP that fulfills the given PresentationDefinition. func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subjectID string, presentationDefinition pe.PresentationDefinition, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, params holder.BuildParams, - supportedDIDMethods []string) (*vc.VerifiablePresentation, error) { + supportedDIDMethods []string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { subjectDIDs, err := c.subjectManager.ListDIDs(ctx, subjectID) if err != nil { - return nil, err + return nil, nil, err } subjectDIDs, err = filterDIDsByMethods(subjectDIDs, supportedDIDMethods) if err != nil { - return nil, err + return nil, nil, err } additionalWalletCredentials := map[did.DID][]vc.VerifiableCredential{} for _, subjectDID := range subjectDIDs { @@ -419,11 +425,39 @@ func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subject additionalWalletCredentials[subjectDID] = append(additionalWalletCredentials[subjectDID], credential.AutoCorrectSelfAttestedCredential(curr, subjectDID)) } } - vp, _, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) + return c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) +} + +// mergeCapturedFields resolves the id-bearing constraint fields of definition against vp's submitted credentials +// and adds the resulting field-id → value pairs to selection. Existing keys in selection are not overwritten. +// Non-string values are skipped (selection is map[string]string). +func mergeCapturedFields(selection map[string]string, vp *vc.VerifiablePresentation, submission *pe.PresentationSubmission, definition pe.PresentationDefinition) (map[string]string, error) { + envelope, err := pe.ParseEnvelope([]byte(vp.Raw())) if err != nil { - return nil, err + return nil, fmt.Errorf("parse VP envelope for cross-VP binding: %w", err) + } + credentialMap, err := submission.Resolve(*envelope) + if err != nil { + return nil, fmt.Errorf("resolve VP submission for cross-VP binding: %w", err) + } + captured, err := definition.ResolveConstraintsFields(credentialMap) + if err != nil { + return nil, fmt.Errorf("resolve constraint fields for cross-VP binding: %w", err) + } + if selection == nil { + selection = map[string]string{} + } + for k, v := range captured { + if _, exists := selection[k]; exists { + continue + } + s, ok := v.(string) + if !ok { + continue + } + selection[k] = s } - return vp, nil + return selection, nil } // filterDIDsByMethods drops DIDs whose method is not in supportedMethods. Returns ErrPreconditionFailed when diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 3d5696661d..2c45095fef 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -39,6 +39,7 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/core/to" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/policy" http2 "github.com/nuts-foundation/nuts-node/test/http" @@ -455,6 +456,100 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { assert.ErrorContains(t, err, "no service_provider presentation definition") }) + t.Run("captures shared field.id values from VP1 into VP2 credential_selection", func(t *testing.T) { + // Cross-VP binding scenario: when the organization PD and the service_provider PD share the same + // constraint-field `id` (here: "delegating_hcp"), the value matched in VP1 must flow into VP2's + // credential_selection so the wallet building VP2 can pick a credential constrained by that value. + // In this test we follow only the capture-and-merge step end-to-end: assert that VP2's BuildSubmission + // receives credential_selection["delegating_hcp"] = "did:test:hcp", the issuer of VP1's credential. + + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + + // VP1 is a minimal JSON-LD presentation containing one credential whose $.issuer is the HCP DID. + // The presentation needs enough structure for ParseEnvelope + submission.Resolve to walk the JSONPath + // "$.verifiableCredential[0]" and return the embedded credential. + vp1Raw := `{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiablePresentation"], + "verifiableCredential": [{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential"], + "issuer": "did:test:hcp", + "credentialSubject": {"id": "did:test:hcp"}, + "proof": {"type": "JsonWebSignature2020"} + }], + "proof": {"type": "JsonWebSignature2020"} + }` + vp1, err := vc.ParseVerifiablePresentation(vp1Raw) + require.NoError(t, err) + // VP2's body is irrelevant for this test — it never gets parsed because we're only asserting on the + // arguments passed to its BuildSubmission call. + vp2, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + + // VP1's submission tells the resolver that input descriptor "id_org_cred" was satisfied by the first + // credential in the verifiableCredential array of VP1. + vp1Submission := &pe.PresentationSubmission{ + DescriptorMap: []pe.InputDescriptorMappingObject{ + {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, + }, + } + + // orgPD declares one constraint field with id "delegating_hcp" pointing at $.issuer of the matched VC. + // That id is what makes the value capturable; without an id the field would be ignored. + orgPD := pe.PresentationDefinition{ + Id: "org_pd", + InputDescriptors: []*pe.InputDescriptor{{ + Id: "id_org_cred", + Constraints: &pe.Constraints{ + Fields: []pe.Field{{Id: to.Ptr("delegating_hcp"), Path: []string{"$.issuer"}}}, + }, + }}, + } + // spPD shares the field.id "delegating_hcp" by convention. The sharing is what binds VP2 to a value + // matched in VP1 — this PR's job is to plumb that value into credential_selection. The PD itself + // carries no constraints in this test because the wallet mock never inspects it. + spPD := pe.PresentationDefinition{Id: "sp_pd"} + + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + // Advertise jwt-bearer so the gates pass and the two-VP path runs. + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + // The two-VP path resolves both PDs from the local policy backend rather than the AS's PD endpoint. + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: orgPD, + pe.WalletOwnerServiceProvider: spPD, + }, + }, nil) + // VP1 is built from the HCP wallet, VP2 from the SP wallet — different subject IDs, different DIDs. + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // First call: build VP1 against the HCP DID using orgPD. Returns the JSON-LD VP and its submission so + // the production code can resolve constraint fields against them. + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + orgPD, gomock.Any(), gomock.Any()).Return(vp1, vp1Submission, nil) + // Second call: build VP2 against the SP DID using spPD. We capture the credential_selection argument + // so the test can assert the merged field-id value made it through. + var capturedSPSelection map[string]string + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), + spPD, gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, sel map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + capturedSPSelection = sel + return vp2, &pe.PresentationSubmission{}, nil + }) + + _, err = ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + // The HCP DID matched against $.issuer in VP1's credential should be captured under "delegating_hcp" + // and forwarded to VP2's wallet — this is the cross-VP binding payoff. + assert.Equal(t, "did:test:hcp", capturedSPSelection["delegating_hcp"]) + }) + t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") From 2ede2b6fe40fe5fa485bef6a115f292e060068c3 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 11:12:26 +0200 Subject: [PATCH 49/91] Add PresentationSubmission.ResolveVP for single-VP callers Resolve currently requires the caller to wrap a Verifiable Presentation in an Envelope, which forces callers that already hold a parsed VP in memory to round-trip through Raw() + ParseEnvelope just to drop the VP back into a struct so its private asInterface field can be used for JSONPath traversal. ResolveVP wraps the conversion: it computes asInterface from the VP directly and delegates to Resolve. parseJSONObjectOrStringEnvelope is refactored to share the same vpAsInterface helper so the JWT-vs-JSON-LD extraction logic stays in one place. Co-Authored-By: Claude Opus 4.7 (1M context) --- vcr/pe/presentation_submission.go | 14 +++++++++++ vcr/pe/presentation_submission_test.go | 33 ++++++++++++++++++++++++++ vcr/pe/util.go | 29 +++++++++++++++------- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go index fcd011152f..634004e5d5 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -163,6 +163,20 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis return presentationSubmission, signInstruction, nil } +// ResolveVP is a convenience wrapper around Resolve for callers that already hold a single parsed +// VerifiablePresentation in memory (e.g. a freshly built submission from the wallet) and would otherwise +// need to round-trip through Raw() + ParseEnvelope just to call Resolve. +func (s PresentationSubmission) ResolveVP(presentation vc.VerifiablePresentation) (map[string]vc.VerifiableCredential, error) { + asInterface, err := vpAsInterface(presentation) + if err != nil { + return nil, err + } + return s.Resolve(Envelope{ + Presentations: []vc.VerifiablePresentation{presentation}, + asInterface: asInterface, + }) +} + // Resolve returns a map where each of the input descriptors is mapped to the corresponding VerifiableCredential. // If an input descriptor can't be mapped to a VC, an error is returned. // This function is specified by https://identity.foundation/presentation-exchange/#processing-of-submission-entries diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go index 614777ff0c..962369fcf4 100644 --- a/vcr/pe/presentation_submission_test.go +++ b/vcr/pe/presentation_submission_test.go @@ -277,6 +277,39 @@ func TestPresentationSubmissionBuilder_SetCredentialSelector(t *testing.T) { }) } +func TestPresentationSubmission_ResolveVP(t *testing.T) { + t.Run("resolves descriptors against a single parsed VP without round-tripping through Envelope", func(t *testing.T) { + // Caller already has a *VerifiablePresentation in memory (e.g. just built by the wallet); ResolveVP + // must produce the same descriptor → credential mapping that Resolve(envelope) would. + vpRaw := `{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiablePresentation"], + "verifiableCredential": [{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential"], + "id": "urn:vc:42", + "issuer": "did:test:issuer", + "credentialSubject": {"id": "did:test:subject"}, + "proof": {"type": "JsonWebSignature2020"} + }], + "proof": {"type": "JsonWebSignature2020"} + }` + presentation, err := vc.ParseVerifiablePresentation(vpRaw) + require.NoError(t, err) + submission := PresentationSubmission{ + DescriptorMap: []InputDescriptorMappingObject{ + {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, + }, + } + + credentials, err := submission.ResolveVP(*presentation) + + require.NoError(t, err) + require.Contains(t, credentials, "id_org_cred") + assert.Equal(t, "urn:vc:42", credentials["id_org_cred"].ID.String()) + }) +} + func TestPresentationSubmission_Resolve(t *testing.T) { id1 := ssi.MustParseURI("1") id2 := ssi.MustParseURI("2") diff --git a/vcr/pe/util.go b/vcr/pe/util.go index 75ced72c07..024a1d57e3 100644 --- a/vcr/pe/util.go +++ b/vcr/pe/util.go @@ -129,13 +129,24 @@ func parseJSONObjectOrStringEnvelope(envelopeBytes []byte) (interface{}, *vc.Ver if err != nil { return nil, nil, fmt.Errorf("unable to parse PEX envelope as verifiable presentation: %w", err) } - // TODO: This should be part of go-did library; we need to decode a JWT VP (and maybe later VC) and get the properties - // (in this case as map) without losing original cardinality. - // Part of https://github.com/nuts-foundation/go-did/issues/85 + asInterface, err := vpAsInterface(*presentation) + if err != nil { + return nil, nil, err + } + return asInterface, presentation, nil +} + +// vpAsInterface returns the JSON-as-interface representation of vp suitable for jsonpath traversal, +// extracting the inner "vp" claim for JWT presentations so callers see the same shape regardless of format. +// TODO: This should be part of go-did library; we need to decode a JWT VP (and maybe later VC) and get the +// properties (in this case as map) without losing original cardinality. +// Part of https://github.com/nuts-foundation/go-did/issues/85 +func vpAsInterface(presentation vc.VerifiablePresentation) (interface{}, error) { + raw := []byte(presentation.Raw()) if presentation.Format() == vc.JWTPresentationProofFormat { - token, err := jwt.Parse(envelopeBytes, jwt.WithVerify(false), jwt.WithValidate(false)) + token, err := jwt.Parse(raw, jwt.WithVerify(false), jwt.WithValidate(false)) if err != nil { - return nil, nil, fmt.Errorf("unable to parse PEX envelope as JWT verifiable presentation: %w", err) + return nil, fmt.Errorf("unable to parse PEX envelope as JWT verifiable presentation: %w", err) } asMap := make(map[string]interface{}) // use the 'vp' claim as base Verifiable Presentation properties @@ -146,15 +157,15 @@ func parseJSONObjectOrStringEnvelope(envelopeBytes []byte) (interface{}, *vc.Ver if jti, ok := token.Get(jwt.JwtIDKey); ok { asMap["id"] = jti } - return asMap, presentation, nil + return asMap, nil } // For other formats, we can just parse the JSON to get the interface{} for JSON Path to work on var asMap interface{} - if err := json.Unmarshal(envelopeBytes, &asMap); err != nil { + if err := json.Unmarshal(raw, &asMap); err != nil { // Can't actually fail? - return nil, nil, err + return nil, err } - return asMap, presentation, nil + return asMap, nil } // tryParseJSONArray tries to parse the given bytes as a JSON array. From a0d28ea6128b786ccac7366090c503712af4de5a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 11:12:58 +0200 Subject: [PATCH 50/91] Tighten the two-VP path: ResolveVP, params.DIDMethods, no client_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the parse-envelope + submission.Resolve dance with PresentationSubmission.ResolveVP, since we already hold the parsed VP1 in memory after the wallet builds it. - Drop the duplicated supportedDIDMethods argument on buildSubmissionForSubject; the same value is already carried on holder.BuildParams.DIDMethods. - Rename the FindCredentialProfile result variable from match to profile — what we hold is the credential profile, not the act of matching it. - Stop setting the OAuth client_id form parameter in the jwt-bearer path. Per RFC 7521 §4.2 the client_assertion authenticates the client, so client_id is optional; including it would force the API layer to plumb a service-provider base URL through purely for the form field. - Drop the cross-VP orchestration tests; the helper they exercised (mergeCapturedFields) has been replaced by the smaller applyCapturedFieldsToSelection, unit-tested directly with plain maps. The happy-path orchestration test stays as the smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 65 ++++++++-------- auth/client/iam/openid4vp_test.go | 119 ++++++------------------------ 2 files changed, 53 insertions(+), 131 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index a3b7ff446b..6c2c454cd8 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -261,7 +261,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return nil, errors.New("authorization server does not advertise jwt-bearer support") } if serviceProviderSubjectID != nil { - return c.requestJwtBearerAccessToken(ctx, clientID, subjectID, *serviceProviderSubjectID, authServerURL, scopes, additionalCredentials, credentialSelection, metadata) + return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, additionalCredentials, credentialSelection, metadata) } // Resolve the presentation definition: from remote AS when available, local policy otherwise @@ -349,20 +349,22 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID // requestJwtBearerAccessToken implements the RFC 7523 jwt-bearer two-VP token request flow. // It builds VP1 from the HCP wallet (using the organization PD) and VP2 from the SP wallet (using the // service_provider PD), assembles them as `assertion` and `client_assertion`, and POSTs the token request. -func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clientID string, subjectID string, serviceProviderSubjectID string, +// Per RFC 7521 §4.2 the client is authenticated by the client_assertion, so no OAuth client_id form +// parameter is sent on this path. +func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subjectID string, serviceProviderSubjectID string, authServerURL string, scopes string, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { - match, err := c.policyBackend.FindCredentialProfile(ctx, scopes) + profile, err := c.policyBackend.FindCredentialProfile(ctx, scopes) if err != nil { return nil, fmt.Errorf("local PD resolution failed: %w", err) } - orgPD, hasOrg := match.WalletOwnerMapping[pe.WalletOwnerOrganization] + orgPD, hasOrg := profile.WalletOwnerMapping[pe.WalletOwnerOrganization] if !hasOrg { - return nil, fmt.Errorf("no organization presentation definition for scope %q", match.CredentialProfileScope) + return nil, fmt.Errorf("no organization presentation definition for scope %q", profile.CredentialProfileScope) } - spPD, hasSP := match.WalletOwnerMapping[pe.WalletOwnerServiceProvider] + spPD, hasSP := profile.WalletOwnerMapping[pe.WalletOwnerServiceProvider] if !hasSP { - return nil, fmt.Errorf("no service_provider presentation definition for scope %q", match.CredentialProfileScope) + return nil, fmt.Errorf("no service_provider presentation definition for scope %q", profile.CredentialProfileScope) } params := holder.BuildParams{ Audience: authServerURL, @@ -371,22 +373,27 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clien Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } - vp1, vp1Submission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + vp1, vp1Submission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } - // Capture id-bearing constraint field values resolved against VP1 and additively merge them into the - // credential_selection map for VP2. EHR-supplied keys take precedence over captured values. - credentialSelection, err = mergeCapturedFields(credentialSelection, vp1, vp1Submission, orgPD) + // Cross-VP binding: capture id-bearing constraint field values resolved against VP1 and additively merge + // them into the credential_selection map for VP2. The submission tells us which credential satisfied each + // input descriptor; we use that to walk the PD's id-bearing fields and extract their matched values. + credentialMap, err := vp1Submission.ResolveVP(*vp1) if err != nil { - return nil, err + return nil, fmt.Errorf("resolve VP1 submission for cross-VP binding: %w", err) + } + captured, err := orgPD.ResolveConstraintsFields(credentialMap) + if err != nil { + return nil, fmt.Errorf("resolve VP1 constraint fields for cross-VP binding: %w", err) } - vp2, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params, metadata.DIDMethodsSupported) + credentialSelection = applyCapturedFieldsToSelection(credentialSelection, captured) + vp2, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } data := url.Values{} - data.Set(oauth.ClientIDParam, clientID) data.Set(oauth.GrantTypeParam, oauth.JwtBearerGrantType) data.Set(oauth.AssertionParam, vp1.Raw()) data.Set(oauth.ClientAssertionTypeParam, oauth.JwtBearerClientAssertionType) @@ -406,16 +413,15 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, clien }, nil } -// buildSubmissionForSubject lists DIDs for the given subject, filters them to those whose method is supported -// by the AS, and asks the wallet to build a VP that fulfills the given PresentationDefinition. +// buildSubmissionForSubject lists DIDs for the given subject, filters them to those whose method is in +// params.DIDMethods, and asks the wallet to build a VP that fulfills the given PresentationDefinition. func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subjectID string, presentationDefinition pe.PresentationDefinition, - additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, params holder.BuildParams, - supportedDIDMethods []string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, params holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { subjectDIDs, err := c.subjectManager.ListDIDs(ctx, subjectID) if err != nil { return nil, nil, err } - subjectDIDs, err = filterDIDsByMethods(subjectDIDs, supportedDIDMethods) + subjectDIDs, err = filterDIDsByMethods(subjectDIDs, params.DIDMethods) if err != nil { return nil, nil, err } @@ -428,22 +434,9 @@ func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subject return c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) } -// mergeCapturedFields resolves the id-bearing constraint fields of definition against vp's submitted credentials -// and adds the resulting field-id → value pairs to selection. Existing keys in selection are not overwritten. -// Non-string values are skipped (selection is map[string]string). -func mergeCapturedFields(selection map[string]string, vp *vc.VerifiablePresentation, submission *pe.PresentationSubmission, definition pe.PresentationDefinition) (map[string]string, error) { - envelope, err := pe.ParseEnvelope([]byte(vp.Raw())) - if err != nil { - return nil, fmt.Errorf("parse VP envelope for cross-VP binding: %w", err) - } - credentialMap, err := submission.Resolve(*envelope) - if err != nil { - return nil, fmt.Errorf("resolve VP submission for cross-VP binding: %w", err) - } - captured, err := definition.ResolveConstraintsFields(credentialMap) - if err != nil { - return nil, fmt.Errorf("resolve constraint fields for cross-VP binding: %w", err) - } +// applyCapturedFieldsToSelection adds string-valued entries from captured to selection without overwriting +// existing keys. Non-string captured values are skipped (selection is map[string]string). +func applyCapturedFieldsToSelection(selection map[string]string, captured map[string]any) map[string]string { if selection == nil { selection = map[string]string{} } @@ -457,7 +450,7 @@ func mergeCapturedFields(selection map[string]string, vp *vc.VerifiablePresentat } selection[k] = s } - return selection, nil + return selection } // filterDIDsByMethods drops DIDs whose method is not in supportedMethods. Returns ErrPreconditionFailed when diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 2c45095fef..8da4e621d9 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -39,7 +39,6 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" - "github.com/nuts-foundation/nuts-node/core/to" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/policy" http2 "github.com/nuts-foundation/nuts-node/test/http" @@ -456,100 +455,6 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { assert.ErrorContains(t, err, "no service_provider presentation definition") }) - t.Run("captures shared field.id values from VP1 into VP2 credential_selection", func(t *testing.T) { - // Cross-VP binding scenario: when the organization PD and the service_provider PD share the same - // constraint-field `id` (here: "delegating_hcp"), the value matched in VP1 must flow into VP2's - // credential_selection so the wallet building VP2 can pick a credential constrained by that value. - // In this test we follow only the capture-and-merge step end-to-end: assert that VP2's BuildSubmission - // receives credential_selection["delegating_hcp"] = "did:test:hcp", the issuer of VP1's credential. - - sp := spSubjectID - hcpDID := did.MustParseDID("did:test:hcp") - spDID := did.MustParseDID("did:test:sp") - - // VP1 is a minimal JSON-LD presentation containing one credential whose $.issuer is the HCP DID. - // The presentation needs enough structure for ParseEnvelope + submission.Resolve to walk the JSONPath - // "$.verifiableCredential[0]" and return the embedded credential. - vp1Raw := `{ - "@context": ["https://www.w3.org/2018/credentials/v1"], - "type": ["VerifiablePresentation"], - "verifiableCredential": [{ - "@context": ["https://www.w3.org/2018/credentials/v1"], - "type": ["VerifiableCredential"], - "issuer": "did:test:hcp", - "credentialSubject": {"id": "did:test:hcp"}, - "proof": {"type": "JsonWebSignature2020"} - }], - "proof": {"type": "JsonWebSignature2020"} - }` - vp1, err := vc.ParseVerifiablePresentation(vp1Raw) - require.NoError(t, err) - // VP2's body is irrelevant for this test — it never gets parsed because we're only asserting on the - // arguments passed to its BuildSubmission call. - vp2, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:sp#1"}]}`) - require.NoError(t, err) - - // VP1's submission tells the resolver that input descriptor "id_org_cred" was satisfied by the first - // credential in the verifiableCredential array of VP1. - vp1Submission := &pe.PresentationSubmission{ - DescriptorMap: []pe.InputDescriptorMappingObject{ - {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, - }, - } - - // orgPD declares one constraint field with id "delegating_hcp" pointing at $.issuer of the matched VC. - // That id is what makes the value capturable; without an id the field would be ignored. - orgPD := pe.PresentationDefinition{ - Id: "org_pd", - InputDescriptors: []*pe.InputDescriptor{{ - Id: "id_org_cred", - Constraints: &pe.Constraints{ - Fields: []pe.Field{{Id: to.Ptr("delegating_hcp"), Path: []string{"$.issuer"}}}, - }, - }}, - } - // spPD shares the field.id "delegating_hcp" by convention. The sharing is what binds VP2 to a value - // matched in VP1 — this PR's job is to plumb that value into credential_selection. The PD itself - // carries no constraints in this test because the wallet mock never inspects it. - spPD := pe.PresentationDefinition{Id: "sp_pd"} - - ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true - // Advertise jwt-bearer so the gates pass and the two-VP path runs. - ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} - // The two-VP path resolves both PDs from the local policy backend rather than the AS's PD endpoint. - ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ - CredentialProfileScope: "first", - WalletOwnerMapping: pe.WalletOwnerMapping{ - pe.WalletOwnerOrganization: orgPD, - pe.WalletOwnerServiceProvider: spPD, - }, - }, nil) - // VP1 is built from the HCP wallet, VP2 from the SP wallet — different subject IDs, different DIDs. - ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) - ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) - // First call: build VP1 against the HCP DID using orgPD. Returns the JSON-LD VP and its submission so - // the production code can resolve constraint fields against them. - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), - orgPD, gomock.Any(), gomock.Any()).Return(vp1, vp1Submission, nil) - // Second call: build VP2 against the SP DID using spPD. We capture the credential_selection argument - // so the test can assert the merged field-id value made it through. - var capturedSPSelection map[string]string - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), - spPD, gomock.Any(), gomock.Any()). - DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, sel map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { - capturedSPSelection = sel - return vp2, &pe.PresentationSubmission{}, nil - }) - - _, err = ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) - - require.NoError(t, err) - // The HCP DID matched against $.issuer in VP1's credential should be captured under "delegating_hcp" - // and forwarded to VP2's wallet — this is the cross-VP binding payoff. - assert.Equal(t, "did:test:hcp", capturedSPSelection["delegating_hcp"]) - }) - t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") @@ -595,6 +500,30 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { assert.Equal(t, oauth.JwtBearerClientAssertionType, capturedForm.Get(oauth.ClientAssertionTypeParam)) assert.Equal(t, vp2.Raw(), capturedForm.Get(oauth.ClientAssertionParam)) assert.Empty(t, capturedForm.Get(oauth.PresentationSubmissionParam)) + // Per RFC 7521 §4.2 client_id is optional when client_assertion is present and we omit it. + assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) + }) +} + +func TestApplyCapturedFieldsToSelection(t *testing.T) { + t.Run("adds string-valued captured entries to a nil selection", func(t *testing.T) { + merged := applyCapturedFieldsToSelection(nil, map[string]any{"delegating_hcp": "did:test:hcp"}) + + assert.Equal(t, map[string]string{"delegating_hcp": "did:test:hcp"}, merged) + }) + + t.Run("does not overwrite EHR-supplied selection keys", func(t *testing.T) { + ehrSelection := map[string]string{"delegating_hcp": "did:ehr:override"} + + merged := applyCapturedFieldsToSelection(ehrSelection, map[string]any{"delegating_hcp": "did:test:hcp"}) + + assert.Equal(t, "did:ehr:override", merged["delegating_hcp"]) + }) + + t.Run("skips non-string captured values", func(t *testing.T) { + merged := applyCapturedFieldsToSelection(nil, map[string]any{"int_field": 42, "string_field": "ok"}) + + assert.Equal(t, map[string]string{"string_field": "ok"}, merged) }) } From ad8cff6d536662ed18c380c8c053c66d717816b7 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 11:59:21 +0200 Subject: [PATCH 51/91] Extract requestVPTokenAccessToken so RequestRFC021AccessToken dispatches RequestRFC021AccessToken now reads as a thin dispatcher: gate the two-VP flag, fetch AS metadata once, then call requestJwtBearerAccessToken or requestVPTokenAccessToken. Both grant-specific paths live as private methods named after the grant type they assemble, so the structural parallel is obvious and neither path leaks implementation detail into the entry point. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 6c2c454cd8..fa144c4eb2 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -252,18 +252,25 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID if serviceProviderSubjectID != nil && !c.experimentalJwtBearerClient { return nil, errors.New("jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true") } - iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { return nil, err } - if serviceProviderSubjectID != nil && !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { - return nil, errors.New("authorization server does not advertise jwt-bearer support") - } if serviceProviderSubjectID != nil { + if !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { + return nil, errors.New("authorization server does not advertise jwt-bearer support") + } return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, additionalCredentials, credentialSelection, metadata) } + return c.requestVPTokenAccessToken(ctx, clientID, subjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) +} +// requestVPTokenAccessToken implements the single-VP RFC021 vp_token-bearer flow: resolve the +// presentation definition (remotely if the AS advertises one, locally otherwise), build a single VP from +// the caller's wallet, and POST it as `assertion` alongside the PE submission and DPoP header (when used). +func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, + scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, + metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { // Resolve the presentation definition: from remote AS when available, local policy otherwise resolved, err := c.pdResolver.Resolve(ctx, scopes, *metadata) if err != nil { @@ -284,7 +291,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return nil, err } - subjectDIDs, err = filterDIDsByMethods(subjectDIDs, metadata.DIDMethodsSupported) + subjectDIDs, err = filterDIDsByMethods(subjectDIDs, params.DIDMethods) if err != nil { return nil, err } @@ -329,7 +336,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID } log.Logger().Tracef("Requesting access token from '%s' for scope '%s'\n VP: %s\n Submission: %s", metadata.TokenEndpoint, scopes, assertion, string(presentationSubmission)) - token, err := iamClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) + token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) if err != nil { // the error could be a http error, we just relay it here to make use of any 400 status codes. return nil, err From 7d622df5422229dd98567acbac4ba33bad14d309 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 21:10:02 +0200 Subject: [PATCH 52/91] Rename RequestRFC021AccessToken to RequestServiceAccessToken The function used to assemble only the RFC021 vp_token-bearer flow, but it now also dispatches to the RFC 7523 jwt-bearer two-VP path. Renaming aligns with the public API endpoint POST /internal/auth/v2/{subjectID}/request-service-access-token and abstracts the protocol choice that the function makes internally. Mechanical rename of the interface, implementation, mock, callers and test references; no behavioural change. The interface docstring is also updated to drop the misleading "using Nuts RFC021" qualifier. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/api.go | 2 +- auth/api/iam/api_test.go | 18 ++++++++--------- auth/client/iam/interface.go | 10 ++++++---- auth/client/iam/mock.go | 12 ++++++------ auth/client/iam/openid4vp.go | 2 +- auth/client/iam/openid4vp_test.go | 32 +++++++++++++++---------------- 6 files changed, 39 insertions(+), 37 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 32b64b7b9c..3f833d26b0 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -781,7 +781,7 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, nil) + tokenResult, err := r.auth.IAMClient().RequestServiceAccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, nil) if err != nil { // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials return nil, err diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 89442584e8..bfac8c1308 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -886,7 +886,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} request.Params.CacheControl = to.Ptr("no-cache") // Initial call to populate cache - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(response, nil).Times(2) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(response, nil).Times(2) token, err := ctx.client.RequestServiceAccessToken(nil, request) // Test call to check cache is bypassed @@ -907,7 +907,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { TokenType: "Bearer", ExpiresIn: to.Ptr(900), } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(response, nil) token, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -946,7 +946,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("cache expired", func(t *testing.T) { cacheKey := accessTokenRequestCacheKey(request) _ = ctx.client.accessTokenCache().Delete(cacheKey) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) otherToken, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -963,7 +963,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { Scope: "first second", TokenType: &tokenTypeBearer, } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, nil, nil).Return(&oauth.TokenResponse{}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, nil, nil).Return(&oauth.TokenResponse{}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -972,7 +972,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("ok with expired cache by ttl", func(t *testing.T) { ctx := newTestClient(t) request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -981,7 +981,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { }) t.Run("error - no matching credentials", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(nil, pe.ErrNoCredentials) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(nil, pe.ErrNoCredentials) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -997,8 +997,8 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { ctx.client.storageEngine = mockStorage request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) token1, err := ctx.client.RequestServiceAccessToken(nil, request) require.NoError(t, err) @@ -1023,7 +1023,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { {ID: to.Ptr(ssi.MustParseURI("not empty"))}, } request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil, nil).Return(response, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 440d735863..280f6fc454 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -43,12 +43,14 @@ type Client interface { PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (string, error) // PresentationDefinition returns the presentation definition from the given endpoint. PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error) - // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021. + // RequestServiceAccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server. + // When serviceProviderSubjectID is nil, the request uses the Nuts RFC021 vp_token-bearer single-VP flow. + // When serviceProviderSubjectID is non-nil it identifies a service-provider Nuts subject and triggers the RFC 7523 + // jwt-bearer two-VP flow; that flow is only honored when the experimental jwt-bearer client feature is enabled and + // the AS advertises jwt-bearer. // credentials are additional VCs to include alongside wallet-stored credentials. // credentialSelection maps PD field IDs to expected values to disambiguate when multiple credentials match an input descriptor. - // serviceProviderSubjectID, when non-nil, identifies a service-provider Nuts subject and triggers the RFC 7523 jwt-bearer two-VP flow. - // It is only honored when the experimental jwt-bearer client feature is enabled and the AS advertises jwt-bearer. - RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, + RequestServiceAccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 6481f2029c..3a734e2fd2 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -193,19 +193,19 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestObjectByPost", reflect.TypeOf((*MockClient)(nil).RequestObjectByPost), ctx, requestURI, walletMetadata) } -// RequestRFC021AccessToken mocks base method. -func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { +// RequestServiceAccessToken mocks base method. +func (m *MockClient) RequestServiceAccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) + ret := m.ctrl.Call(m, "RequestServiceAccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } -// RequestRFC021AccessToken indicates an expected call of RequestRFC021AccessToken. -func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID any) *gomock.Call { +// RequestServiceAccessToken indicates an expected call of RequestServiceAccessToken. +func (mr *MockClientMockRecorder) RequestServiceAccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestServiceAccessToken", reflect.TypeOf((*MockClient)(nil).RequestServiceAccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) } // VerifiableCredentials mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index fa144c4eb2..65a1c3fb10 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -247,7 +247,7 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd return &token, nil } -func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, +func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { if serviceProviderSubjectID != nil && !c.experimentalJwtBearerClient { return nil, errors.New("jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true") diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 8da4e621d9..a021224e0b 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -237,7 +237,7 @@ func TestIAMClient_AuthorizationServerMetadata(t *testing.T) { }) } -func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { +func TestRelyingParty_RequestServiceAccessToken(t *testing.T) { const subjectID = "subby" const subjectClientID = "https://example.com/oauth2/subby" primaryWalletDID := did.MustParseDID("did:test:primary") @@ -254,7 +254,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -266,7 +266,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.ErrorIs(t, err, pe.ErrNoCredentials) assert.Nil(t, response) @@ -276,7 +276,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.authzServerMetadata.DIDMethodsSupported = []string{"other"} ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrPreconditionFailed) @@ -313,7 +313,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return createdVP, &pe.PresentationSubmission{}, nil }) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -327,7 +327,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -349,7 +349,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) var oauthErrResult oauth.OAuth2Error @@ -367,7 +367,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.True(t, errors.As(err, &oauth.OAuth2Error{})) @@ -377,7 +377,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.metadata = nil - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidClientCall) @@ -391,7 +391,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, _ = writer.Write([]byte("{")) } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrBadGateway) @@ -402,13 +402,13 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, assert.AnError) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.Error(t, err) }) } -func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { +func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { const subjectID = "subby" const subjectClientID = "https://example.com/oauth2/subby" const spSubjectID = "service-provider-subby" @@ -419,7 +419,7 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { ctx := createClientServerTestContext(t) ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = false - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.Error(t, err) assert.ErrorContains(t, err, "jwt-bearer") @@ -431,7 +431,7 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true // The default AS metadata in the test setup does not include JwtBearerGrantType in grant_types_supported. - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.Error(t, err) assert.ErrorContains(t, err, "authorization server does not advertise jwt-bearer") @@ -449,7 +449,7 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { }, }, nil) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.Error(t, err) assert.ErrorContains(t, err, "no service_provider presentation definition") @@ -491,7 +491,7 @@ func TestRelyingParty_RequestRFC021AccessToken_TwoVP(t *testing.T) { _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) } - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.NoError(t, err) require.NotNil(t, response) From 5023f0c65184898aced09d153981a2d5cc2cf886 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 21:12:20 +0200 Subject: [PATCH 53/91] Extract loadAndValidateProfile and unit-test the rules directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both grant-specific paths used to repeat the same preamble: FindCredentialProfile, reject extras under profile-only, require an organization PD, collapse the resolved scope. The rules and error messages were identical; only the surrounding orchestration differed. loadAndValidateProfile now owns those rules; pdResolver.resolveLocal and requestJwtBearerAccessToken both call it and add only their flow-specific bits (single-VP picks the org PD; two-VP additionally requires the service_provider PD). Tests follow the same shape: TestLoadAndValidateProfile covers every scope-policy and missing-PD scenario with just a policyBackend mock — no HTTP server, no wallet, no subject manager. The two orchestration- level scope-policy tests in TestRelyingParty_RequestServiceAccessToken_TwoVP are removed; the helper test stands in for them and runs in a fraction of the setup. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 14 ++- auth/client/iam/openid4vp_test.go | 3 + auth/client/iam/pd_resolver.go | 29 +---- auth/client/iam/profile_validation.go | 59 +++++++++++ auth/client/iam/profile_validation_test.go | 117 +++++++++++++++++++++ 5 files changed, 190 insertions(+), 32 deletions(-) create mode 100644 auth/client/iam/profile_validation.go create mode 100644 auth/client/iam/profile_validation_test.go diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 65a1c3fb10..c016655ba8 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -361,14 +361,12 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subjectID string, serviceProviderSubjectID string, authServerURL string, scopes string, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { - profile, err := c.policyBackend.FindCredentialProfile(ctx, scopes) + profile, resolvedScope, err := loadAndValidateProfile(ctx, c.policyBackend, scopes) if err != nil { - return nil, fmt.Errorf("local PD resolution failed: %w", err) - } - orgPD, hasOrg := profile.WalletOwnerMapping[pe.WalletOwnerOrganization] - if !hasOrg { - return nil, fmt.Errorf("no organization presentation definition for scope %q", profile.CredentialProfileScope) + return nil, err } + // loadAndValidateProfile guarantees the organization PD; the service_provider PD is two-VP-specific. + orgPD := profile.WalletOwnerMapping[pe.WalletOwnerOrganization] spPD, hasSP := profile.WalletOwnerMapping[pe.WalletOwnerServiceProvider] if !hasSP { return nil, fmt.Errorf("no service_provider presentation definition for scope %q", profile.CredentialProfileScope) @@ -405,9 +403,9 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje data.Set(oauth.AssertionParam, vp1.Raw()) data.Set(oauth.ClientAssertionTypeParam, oauth.JwtBearerClientAssertionType) data.Set(oauth.ClientAssertionParam, vp2.Raw()) - data.Set(oauth.ScopeParam, scopes) + data.Set(oauth.ScopeParam, resolvedScope) - log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n VP1: %s\n VP2: %s", metadata.TokenEndpoint, scopes, vp1.Raw(), vp2.Raw()) + log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n VP1: %s\n VP2: %s", metadata.TokenEndpoint, resolvedScope, vp1.Raw(), vp2.Raw()) token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, "") if err != nil { return nil, err diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index a021224e0b..38b5408aa4 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -455,6 +455,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { assert.ErrorContains(t, err, "no service_provider presentation definition") }) + // Scope-policy behaviour (profile-only rejection, passthrough forwarding, missing organization PD) + // is covered by TestLoadAndValidateProfile, which exercises the same helper this path delegates to. + t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") diff --git a/auth/client/iam/pd_resolver.go b/auth/client/iam/pd_resolver.go index edce53eb21..a5973ac29c 100644 --- a/auth/client/iam/pd_resolver.go +++ b/auth/client/iam/pd_resolver.go @@ -78,33 +78,14 @@ func (r *PresentationDefinitionResolver) resolveRemote(ctx context.Context, scop } func (r *PresentationDefinitionResolver) resolveLocal(ctx context.Context, scope string) (*ResolvedPresentationDefinition, error) { - if r.policyBackend == nil { - return nil, fmt.Errorf("local PD resolution requires a policy backend, but none is configured") - } - match, err := r.policyBackend.FindCredentialProfile(ctx, scope) + profile, resolvedScope, err := loadAndValidateProfile(ctx, r.policyBackend, scope) if err != nil { - return nil, fmt.Errorf("local PD resolution failed: %w", err) - } - if match.ScopePolicy == policy.ScopePolicyProfileOnly && len(match.OtherScopes) > 0 { - return nil, oauth.OAuth2Error{ - Code: oauth.InvalidScope, - Description: "scope policy 'profile-only' does not allow additional scopes", - } - } - // Select the organization PD (default for current single-VP flow). - // TODO: When #4080 adds two-VP support, this resolver will need to return multiple PDs. - pd, ok := match.WalletOwnerMapping[pe.WalletOwnerOrganization] - if !ok { - return nil, fmt.Errorf("no organization presentation definition for scope %q", match.CredentialProfileScope) - } - // For passthrough and dynamic, forward all scopes to the remote AS. - // The client does not evaluate dynamic scopes — the server handles PDP evaluation at token-grant time (PR #4179). - resolvedScope := scope - if match.ScopePolicy == policy.ScopePolicyProfileOnly { - resolvedScope = match.CredentialProfileScope + return nil, err } + // Select the organization PD (default for the single-VP flow). loadAndValidateProfile already verified + // the org PD is present, so the lookup is safe. return &ResolvedPresentationDefinition{ - PresentationDefinition: pd, + PresentationDefinition: profile.WalletOwnerMapping[pe.WalletOwnerOrganization], Scope: resolvedScope, }, nil } diff --git a/auth/client/iam/profile_validation.go b/auth/client/iam/profile_validation.go new file mode 100644 index 0000000000..36ea87c4eb --- /dev/null +++ b/auth/client/iam/profile_validation.go @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package iam + +import ( + "context" + "fmt" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) + +// loadAndValidateProfile fetches the credential profile for the requested scope, applies the scope policy, +// and verifies that the profile defines an organization PresentationDefinition (required by every local +// flow that fans out from VP1). +// +// Returns the credential profile and the resolved scope to forward to the AS. Returns an oauth.OAuth2Error +// (InvalidScope) when the profile is configured as profile-only but the request carries extra scopes; a +// plain error when the profile cannot be loaded or lacks the organization PD. +func loadAndValidateProfile(ctx context.Context, backend policy.PDPBackend, scope string) (*policy.CredentialProfileMatch, string, error) { + if backend == nil { + return nil, "", fmt.Errorf("local PD resolution requires a policy backend, but none is configured") + } + profile, err := backend.FindCredentialProfile(ctx, scope) + if err != nil { + return nil, "", fmt.Errorf("local PD resolution failed: %w", err) + } + if profile.ScopePolicy == policy.ScopePolicyProfileOnly && len(profile.OtherScopes) > 0 { + return nil, "", oauth.OAuth2Error{ + Code: oauth.InvalidScope, + Description: "scope policy 'profile-only' does not allow additional scopes", + } + } + if _, ok := profile.WalletOwnerMapping[pe.WalletOwnerOrganization]; !ok { + return nil, "", fmt.Errorf("no organization presentation definition for scope %q", profile.CredentialProfileScope) + } + resolvedScope := scope + if profile.ScopePolicy == policy.ScopePolicyProfileOnly { + resolvedScope = profile.CredentialProfileScope + } + return profile, resolvedScope, nil +} diff --git a/auth/client/iam/profile_validation_test.go b/auth/client/iam/profile_validation_test.go new file mode 100644 index 0000000000..ed53214138 --- /dev/null +++ b/auth/client/iam/profile_validation_test.go @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package iam + +import ( + "context" + "errors" + "testing" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestLoadAndValidateProfile(t *testing.T) { + const requested = "first second" + orgPD := pe.PresentationDefinition{Id: "org_pd"} + + t.Run("returns error when no policy backend is configured", func(t *testing.T) { + _, _, err := loadAndValidateProfile(context.Background(), nil, requested) + + assert.ErrorContains(t, err, "local PD resolution requires a policy backend") + }) + + t.Run("wraps the policy backend error", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(nil, errors.New("boom")) + + _, _, err := loadAndValidateProfile(context.Background(), backend, requested) + + assert.ErrorContains(t, err, "local PD resolution failed: boom") + }) + + t.Run("rejects extra scopes when policy is profile-only", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + OtherScopes: []string{"second"}, + ScopePolicy: policy.ScopePolicyProfileOnly, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: orgPD}, + }, nil) + + _, _, err := loadAndValidateProfile(context.Background(), backend, requested) + + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.InvalidScope, oauthErr.Code) + assert.Contains(t, oauthErr.Description, "scope policy 'profile-only' does not allow additional scopes") + }) + + t.Run("rejects when the organization PD is missing", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + ScopePolicy: policy.ScopePolicyPassthrough, + WalletOwnerMapping: pe.WalletOwnerMapping{}, // no organization + }, nil) + + _, _, err := loadAndValidateProfile(context.Background(), backend, requested) + + assert.ErrorContains(t, err, `no organization presentation definition for scope "first"`) + }) + + t.Run("collapses to credential profile scope when policy is profile-only and no extras", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), "first").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + ScopePolicy: policy.ScopePolicyProfileOnly, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: orgPD}, + }, nil) + + profile, resolved, err := loadAndValidateProfile(context.Background(), backend, "first") + + require.NoError(t, err) + assert.Equal(t, "first", resolved) + assert.Equal(t, "first", profile.CredentialProfileScope) + }) + + t.Run("forwards the full input scope when policy is passthrough", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + OtherScopes: []string{"second"}, + ScopePolicy: policy.ScopePolicyPassthrough, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: orgPD}, + }, nil) + + _, resolved, err := loadAndValidateProfile(context.Background(), backend, requested) + + require.NoError(t, err) + assert.Equal(t, requested, resolved) + }) +} From b2ba04522b9229ec0243c78af63da8937bd3b087 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Sat, 2 May 2026 21:23:08 +0200 Subject: [PATCH 54/91] Fold the single-VP build into buildSubmissionForSubject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestVPTokenAccessToken used to inline the ListDIDs → filterDIDsByMethods → expand additionalCredentials per DID → wallet.BuildSubmission sequence — the same logic that buildSubmissionForSubject already encapsulates and that requestJwtBearerAccessToken calls twice. Have single-VP call the same helper so both grant paths share the primitive and the inline copy goes away. The call shape into the wallet is byte-identical, so the existing single-VP test suite still passes without touch. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index c016655ba8..548d139d02 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -286,24 +286,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI Nonce: nutsCrypto.GenerateNonce(), } - subjectDIDs, err := c.subjectManager.ListDIDs(ctx, subjectID) - if err != nil { - return nil, err - } - - subjectDIDs, err = filterDIDsByMethods(subjectDIDs, params.DIDMethods) - if err != nil { - return nil, err - } - - // each additional credential can be used by each DID - additionalWalletCredentials := map[did.DID][]vc.VerifiableCredential{} - for _, subjectDID := range subjectDIDs { - for _, curr := range additionalCredentials { - additionalWalletCredentials[subjectDID] = append(additionalWalletCredentials[subjectDID], credential.AutoCorrectSelfAttestedCredential(curr, subjectDID)) - } - } - vp, submission, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, *presentationDefinition, credentialSelection, params) + vp, submission, err := c.buildSubmissionForSubject(ctx, subjectID, *presentationDefinition, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } From d7ed7b41504d842410f16811e50f41784f2933e6 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 11:39:31 +0200 Subject: [PATCH 55/91] Populate Envelope.raw in ResolveVP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveVP constructed an Envelope with an empty `raw []byte` field. Resolve never consults raw, so today nothing breaks — but any caller that inspected the wrapped envelope and called MarshalJSON would panic on `e.raw[0]`. Set raw from presentation.Raw() so the constructed envelope is a fully-formed value. Also tighten the doc comment to mention what the returned map is keyed by, so callers don't have to jump to Resolve to learn the contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- vcr/pe/presentation_submission.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go index 634004e5d5..1087bf41ba 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -165,7 +165,8 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis // ResolveVP is a convenience wrapper around Resolve for callers that already hold a single parsed // VerifiablePresentation in memory (e.g. a freshly built submission from the wallet) and would otherwise -// need to round-trip through Raw() + ParseEnvelope just to call Resolve. +// need to round-trip through Raw() + ParseEnvelope just to call Resolve. Returns a map keyed by +// InputDescriptor.Id; see Resolve for the underlying matching rules. func (s PresentationSubmission) ResolveVP(presentation vc.VerifiablePresentation) (map[string]vc.VerifiableCredential, error) { asInterface, err := vpAsInterface(presentation) if err != nil { @@ -174,6 +175,7 @@ func (s PresentationSubmission) ResolveVP(presentation vc.VerifiablePresentation return s.Resolve(Envelope{ Presentations: []vc.VerifiablePresentation{presentation}, asInterface: asInterface, + raw: []byte(presentation.Raw()), }) } From c68b7e932207280d5c85f603cb11d8e9a5e5d62b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 11:47:37 +0200 Subject: [PATCH 56/91] Wire DPoP through the jwt-bearer two-VP flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher accepted useDPoP and silently dropped it on the two-VP path. DPoP binds the issued access token to a key the holder controls — for the jwt-bearer flow that holder is the service provider (the OAuth client per RFC 7523). Sign the proof with the SP DID's key (taken from vp2.Holder) so callers that opt into DPoP get the same proof-of-possession on both grant types. Extracts signDPoPHeader as a small helper that returns ("","",nil) when useDPoP is false, so both grant paths can call it unconditionally and stop branching on the bool around the dpop() call. Also fixes the existing happy-path fixture: vp2.Holder must be set because the new DPoP-DID derivation reads it. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 56 +++++++++++++++++++++---------- auth/client/iam/openid4vp_test.go | 40 ++++++++++++++++++++-- 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 548d139d02..566842f0dd 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -260,7 +260,7 @@ func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientI if !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { return nil, errors.New("authorization server does not advertise jwt-bearer support") } - return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, additionalCredentials, credentialSelection, metadata) + return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) } return c.requestVPTokenAccessToken(ctx, clientID, subjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) } @@ -304,18 +304,9 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) data.Set(oauth.ScopeParam, resolved.Scope) - // create DPoP header - var dpopHeader string - var dpopKid string - if useDPoP { - request, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.TokenEndpoint, nil) - if err != nil { - return nil, err - } - dpopHeader, dpopKid, err = c.dpop(ctx, *subjectDID, *request) - if err != nil { - return nil, fmt.Errorf("failed to create DPoP header: %w", err) - } + dpopHeader, dpopKid, err := c.signDPoPHeader(ctx, useDPoP, *subjectDID, metadata.TokenEndpoint) + if err != nil { + return nil, err } log.Logger().Tracef("Requesting access token from '%s' for scope '%s'\n VP: %s\n Submission: %s", metadata.TokenEndpoint, scopes, assertion, string(presentationSubmission)) @@ -342,7 +333,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI // Per RFC 7521 §4.2 the client is authenticated by the client_assertion, so no OAuth client_id form // parameter is sent on this path. func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subjectID string, serviceProviderSubjectID string, - authServerURL string, scopes string, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, + authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { profile, resolvedScope, err := loadAndValidateProfile(ctx, c.policyBackend, scopes) if err != nil { @@ -381,6 +372,16 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje if err != nil { return nil, err } + // DPoP binds the issued access token to a key the service provider controls — the SP wallet will + // present and use the token, so the proof is signed with the SP DID's key. + spDID, err := did.ParseDID(vp2.Holder.String()) + if err != nil { + return nil, err + } + dpopHeader, dpopKid, err := c.signDPoPHeader(ctx, useDPoP, *spDID, metadata.TokenEndpoint) + if err != nil { + return nil, err + } data := url.Values{} data.Set(oauth.GrantTypeParam, oauth.JwtBearerGrantType) data.Set(oauth.AssertionParam, vp1.Raw()) @@ -389,16 +390,20 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje data.Set(oauth.ScopeParam, resolvedScope) log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n VP1: %s\n VP2: %s", metadata.TokenEndpoint, resolvedScope, vp1.Raw(), vp2.Raw()) - token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, "") + token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) if err != nil { return nil, err } - return &oauth.TokenResponse{ + tokenResponse := oauth.TokenResponse{ AccessToken: token.AccessToken, ExpiresIn: token.ExpiresIn, TokenType: token.TokenType, Scope: &scopes, - }, nil + } + if dpopKid != "" { + tokenResponse.DPoPKid = &dpopKid + } + return &tokenResponse, nil } // buildSubmissionForSubject lists DIDs for the given subject, filters them to those whose method is in @@ -482,6 +487,23 @@ func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialE return rsp, nil } +// signDPoPHeader signs a DPoP proof for a token-endpoint POST bound to signerDID's assertion key. +// Returns ("", "", nil) when useDPoP is false so callers can use the result unconditionally. +func (c *OpenID4VPClient) signDPoPHeader(ctx context.Context, useDPoP bool, signerDID did.DID, tokenEndpoint string) (string, string, error) { + if !useDPoP { + return "", "", nil + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, nil) + if err != nil { + return "", "", err + } + header, kid, err := c.dpop(ctx, signerDID, *request) + if err != nil { + return "", "", fmt.Errorf("failed to create DPoP header: %w", err) + } + return header, kid, nil +} + func (c *OpenID4VPClient) dpop(ctx context.Context, requester did.DID, request http.Request) (string, string, error) { // find the key to sign the DPoP token with keyID, _, err := c.keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 38b5408aa4..ecd86d4e99 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -462,9 +462,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") spDID := did.MustParseDID("did:test:sp") - vp1, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) require.NoError(t, err) - vp2, err := vc.ParseVerifiablePresentation(`{"proof":[{"verificationMethod":"did:test:sp#1"}]}`) + vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) ctx := createClientServerTestContext(t) @@ -506,6 +506,42 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { // Per RFC 7521 §4.2 client_id is optional when client_assertion is present and we omit it. assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) }) + + t.Run("ok with DPoPHeader", func(t *testing.T) { + // DPoP binds the issued access token to a key the SP wallet controls — the proof must be signed with + // the SP DID's key (vp2.Holder), not the HCP DID's key. + sp := spSubjectID + spDID := did.MustParseDID("did:test:sp") + spKID := "did:test:sp#1" + vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + ctx := createClientServerTestContext(t) + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{did.MustParseDID("did:test:hcp")}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(vp1, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(vp2, &pe.PresentationSubmission{}, nil) + // The DPoP signing key must be resolved against the SP DID, not the HCP DID — that's the assertion. + ctx.keyResolver.EXPECT().ResolveKey(spDID, nil, resolver.NutsSigningKeyType).Return(spKID, nil, nil) + ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), spKID).Return("dpop", nil) + + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil, &sp) + + require.NoError(t, err) + require.NotNil(t, response) + require.NotNil(t, response.DPoPKid) + assert.Equal(t, spKID, *response.DPoPKid) + }) } func TestApplyCapturedFieldsToSelection(t *testing.T) { From ae78489e3c2d82c6e63b6d56e3f36cd583bca615 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 11:55:38 +0200 Subject: [PATCH 57/91] End-to-end test for cross-VP field-id binding The happy-path test stubs both BuildSubmission calls with empty PresentationSubmission{}, so the cross-VP capture chain runs over an empty descriptor map and asserts nothing. Add a focused subtest that returns a real VP1 + submission shaped to capture a constraint field (`delegating_hcp` = `did:test:hcp`) and asserts that the second BuildSubmission receives the captured value in its credential_selection argument. Closes the e2e gap on the PRD's "cross-VP binding works without EHR involvement" acceptance criterion. The pure-function helper is still covered by TestApplyCapturedFieldsToSelection; this test guards the wiring around it. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp_test.go | 107 ++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index ecd86d4e99..2f1e86f569 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -507,6 +507,113 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) }) + t.Run("captured VP1 field-id values flow into VP2 credential_selection end-to-end", func(t *testing.T) { + // Cross-VP binding scenario, end to end: when the organization PD and the service_provider PD share + // the same constraint-field `id` (here: "delegating_hcp"), the value matched in VP1 must flow into + // VP2's credential_selection so the wallet building VP2 can pick a credential constrained by that + // value. This test follows the chain through requestJwtBearerAccessToken: + // + // VP1 + vp1Submission --ResolveVP--> credentialMap (which VC satisfied which input descriptor) + // credentialMap + orgPD --ResolveConstraintsFields--> {delegating_hcp: did:test:hcp} + // --applyCapturedFieldsToSelection--> credential_selection passed to VP2's BuildSubmission + // + // TestApplyCapturedFieldsToSelection covers the merge step in isolation; this test exists to guard + // the wiring from BuildSubmission's return values into VP2's BuildSubmission argument. + + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + + // VP1 is the healthcare provider's presentation. It must be a parseable JSON-LD VP that contains + // one credential whose $.issuer is the HCP DID — that value is what the binding will capture. + // `holder` is set so the DPoP code path (which derives a signing DID from vp.Holder) doesn't panic + // even though we don't enable DPoP in this test. + vp1, err := vc.ParseVerifiablePresentation(`{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiablePresentation"], + "holder": "did:test:hcp", + "verifiableCredential": [{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential"], + "issuer": "did:test:hcp", + "credentialSubject": {"id": "did:test:hcp"}, + "proof": {"type": "JsonWebSignature2020"} + }], + "proof": {"type": "JsonWebSignature2020"} + }`) + require.NoError(t, err) + // VP2's body is irrelevant for this test — we only assert what VP2's BuildSubmission was *called* + // with; we never inspect vp2 itself afterwards. The holder is set for the same DPoP-safety reason. + vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + + // vp1Submission tells the resolver "input descriptor id_org_cred was satisfied by the credential at + // $.verifiableCredential[0]". Without this, ResolveVP can't bridge from a descriptor id to a VC, and + // ResolveConstraintsFields has nothing to walk for $.issuer. + vp1Submission := &pe.PresentationSubmission{ + DescriptorMap: []pe.InputDescriptorMappingObject{ + {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, + }, + } + + // orgPD declares one constraint field with id "delegating_hcp" pointing at $.issuer of the matched + // VC. The id is what makes the value capturable; an unidentified field would be ignored. + fieldID := "delegating_hcp" + orgPD := pe.PresentationDefinition{ + Id: "org_pd", + InputDescriptors: []*pe.InputDescriptor{{ + Id: "id_org_cred", + Constraints: &pe.Constraints{ + Fields: []pe.Field{{Id: &fieldID, Path: []string{"$.issuer"}}}, + }, + }}, + } + // spPD shares the field.id "delegating_hcp" by convention. The sharing is what binds VP2 to a value + // matched in VP1. The PD itself carries no constraints in this test because the wallet mock never + // inspects it; we only care that the credential_selection it receives contains the captured value. + spPD := pe.PresentationDefinition{Id: "sp_pd"} + + ctx := createClientServerTestContext(t) + // Enable the experimental flag so the dispatcher routes us into the two-VP path. + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + // Advertise jwt-bearer so the dispatcher's "AS supports jwt-bearer" check passes. + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + // The two-VP path always resolves PDs from the local policy backend (no remote PD endpoint for the + // service_provider concept), so this is the single source of truth for both PDs in the request. + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: orgPD, + pe.WalletOwnerServiceProvider: spPD, + }, + }, nil) + // VP1 is built from the HCP wallet (subjectID), VP2 from the SP wallet (spSubjectID) — different + // subject IDs, different DID candidate slices. + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // First BuildSubmission call: build VP1 against the HCP DID using orgPD. We return the JSON-LD VP + // and its submission so the production code can resolve constraint fields against them. + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + orgPD, gomock.Any(), gomock.Any()).Return(vp1, vp1Submission, nil) + // Second BuildSubmission call: build VP2 against the SP DID using spPD. We capture the + // credential_selection argument so the assertion at the bottom can verify the merged field-id + // value made it through the orchestration. + var capturedSPSelection map[string]string + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), + spPD, gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, sel map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + capturedSPSelection = sel + return vp2, &pe.PresentationSubmission{}, nil + }) + + _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + // Payoff: the HCP DID matched against $.issuer in VP1's credential should be captured under + // "delegating_hcp" and forwarded to VP2's wallet — without the EHR caller having to set it. + assert.Equal(t, "did:test:hcp", capturedSPSelection["delegating_hcp"]) + }) + t.Run("ok with DPoPHeader", func(t *testing.T) { // DPoP binds the issued access token to a key the SP wallet controls — the proof must be signed with // the SP DID's key (vp2.Holder), not the HCP DID's key. From b9d3c98a08044bfbb0ca7479c245340519a53d74 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 11:56:50 +0200 Subject: [PATCH 58/91] Return the resolved scope on the TokenResponse Both grant paths sent `resolvedScope` (collapsed to the credential profile scope under profile-only, or the full input string under passthrough) on the wire to the AS, but echoed the raw `scopes` input back on the TokenResponse. A caller inspecting the response would see scopes the AS never granted. Return what was actually sent so the response matches the request. Pre-existing in the single-VP path; mirrored across to the new jwt-bearer path while we're here. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 566842f0dd..da20dee876 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -319,7 +319,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI AccessToken: token.AccessToken, ExpiresIn: token.ExpiresIn, TokenType: token.TokenType, - Scope: &scopes, + Scope: &resolved.Scope, } if dpopKid != "" { tokenResponse.DPoPKid = &dpopKid @@ -398,7 +398,7 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje AccessToken: token.AccessToken, ExpiresIn: token.ExpiresIn, TokenType: token.TokenType, - Scope: &scopes, + Scope: &resolvedScope, } if dpopKid != "" { tokenResponse.DPoPKid = &dpopKid From c4b817eea04e901724296b622fd01b15c6b1ed51 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 12:00:46 +0200 Subject: [PATCH 59/91] Doc polish for the jwt-bearer two-VP flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a release-notes entry for the experimental auth.experimental.jwt_bearer_client flag so operators discover the feature in the changelog rather than having to read the diff. - Spell out the HCP/SP abbreviations in requestJwtBearerAccessToken's function comment, and note that both PDs are resolved from the local policy backend (the AS's remote presentation_definition endpoint is not consulted in this flow — no standard exists yet for the AS to advertise a service_provider PD). - Document on the Client interface that additionalCredentials are offered to both wallets in the two-VP flow, that signed VCs flow through unchanged, and that unsigned self-attested credentials get per-holder-DID issuance via AutoCorrectSelfAttestedCredential. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/interface.go | 4 +++- auth/client/iam/openid4vp.go | 7 +++++-- docs/pages/release_notes.rst | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 280f6fc454..d26eb6004f 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -48,7 +48,9 @@ type Client interface { // When serviceProviderSubjectID is non-nil it identifies a service-provider Nuts subject and triggers the RFC 7523 // jwt-bearer two-VP flow; that flow is only honored when the experimental jwt-bearer client feature is enabled and // the AS advertises jwt-bearer. - // credentials are additional VCs to include alongside wallet-stored credentials. + // credentials are additional VCs to include alongside wallet-stored credentials. In the two-VP flow they are + // offered to both wallets; each PD selects what matches its input descriptors. Signed VCs flow through unchanged; + // unsigned self-attested credentials are auto-issued per holder DID by AutoCorrectSelfAttestedCredential. // credentialSelection maps PD field IDs to expected values to disambiguate when multiple credentials match an input descriptor. RequestServiceAccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index da20dee876..306fe9b16a 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -328,10 +328,13 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI } // requestJwtBearerAccessToken implements the RFC 7523 jwt-bearer two-VP token request flow. -// It builds VP1 from the HCP wallet (using the organization PD) and VP2 from the SP wallet (using the -// service_provider PD), assembles them as `assertion` and `client_assertion`, and POSTs the token request. +// It builds VP1 from the healthcare-provider (HCP) wallet using the organization PD, and VP2 from the +// service-provider (SP) wallet using the service_provider PD, assembles them as `assertion` and +// `client_assertion`, and POSTs the token request. // Per RFC 7521 §4.2 the client is authenticated by the client_assertion, so no OAuth client_id form // parameter is sent on this path. +// Both PDs are resolved from the local policy backend; the AS's remote presentation_definition endpoint +// is not consulted (no standardised mechanism exists today for the AS to advertise a service_provider PD). func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subjectID string, serviceProviderSubjectID string, authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index 7168d3f8e5..d5b3370645 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -9,6 +9,7 @@ Unreleased ## New features * #4063: Enable ``storage.debug`` flag to log go-leia performance issues (full table scans, suboptimal index usage) by @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4064 * #4078: Allow policy profiles to define a ``service_provider`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 +* #4078: Add the experimental RFC 7523 ``jwt-bearer`` two-VP token request flow, gated behind ``auth.experimental.jwt_bearer_client`` (default ``false``, subject to change) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4227 **************** Peanut (v6.2.1) From 3f8def8b4620a615477ffa19daefd61045220ef1 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 12:01:47 +0200 Subject: [PATCH 60/91] Move JwtBearerClientAssertionType into a dedicated const block JwtBearerClientAssertionType is a client_assertion_type value, not an OAuth grant_type. Living under the "// grant types" header was misleading for readers scanning the file by section. Promote it to its own "// client assertion types" block so it sits next to the ClientAssertionTypeParam param it parameterises. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/oauth/types.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/auth/oauth/types.go b/auth/oauth/types.go index e08b62f251..567b5459d1 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -211,7 +211,11 @@ const ( VpTokenGrantType = "vp_token-bearer" // JwtBearerGrantType is the grant_type for the RFC 7523 JWT bearer grant type. JwtBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" - // JwtBearerClientAssertionType is the client_assertion_type for the RFC 7523 JWT bearer client assertion. +) + +// client assertion types +const ( + // JwtBearerClientAssertionType is the canonical value of ClientAssertionTypeParam for the RFC 7523 JWT bearer client assertion. JwtBearerClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" ) From 603d4320dfa3c631a70db569e51e31f29b79ea8a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 12:03:13 +0200 Subject: [PATCH 61/91] Regenerate server_options.rst Catches up the deployment configuration table with two flags that were not regenerated when their commits landed: - auth.experimental.jwt_bearer_client (this PR) - policy.authzen.endpoint (#4144) Operators can now discover both from the docs without having to read the source. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/pages/deployment/server_options.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index a281ac14d6..5fa404be50 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -17,6 +17,7 @@ httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. **Auth** auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature. + auth.experimental.jwt_bearer_client false enables the experimental RFC 7523 jwt-bearer two-VP token request flow. While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice. **Crypto** crypto.storage Storage to use, 'fs' for file system (for development purposes), 'vaultkv' for HashiCorp Vault KV store, 'azure-keyvault' for Azure Key Vault, 'external' for an external backend (deprecated). crypto.azurekv.hsm false Whether to store the key in a hardware security module (HSM). If true, the Azure Key Vault must be configured for HSM usage. Default: false @@ -69,4 +70,5 @@ tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. + policy.authzen.endpoint Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ From c5bd0765944b3e47b1b0af5b86d3b5fbac2041e7 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 13:15:31 +0200 Subject: [PATCH 62/91] Wrap the jwt-bearer flag-flip in a tiny test helper Subtests previously did ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true which couples them to the concrete type behind the Client interface and bypasses the constructor. Wrap the field-poke in a small enableJwtBearerClient(t, ctx) helper so subtests can express intent without the type assertion noise. The "feature disabled" subtest now relies on the zero-value default explicitly (with a comment). Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp_test.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 2f1e86f569..f458df5c86 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -408,6 +408,14 @@ func TestRelyingParty_RequestServiceAccessToken(t *testing.T) { }) } +// enableJwtBearerClient flips the experimental jwt-bearer client feature flag on the test client. +// It exists so subtests don't have to type-assert to the concrete *OpenID4VPClient just to poke an +// unexported field; the intent ("opt this test into the two-VP flow") is also clearer at the call site. +func enableJwtBearerClient(t *testing.T, ctx *clientServerTestContext) { + t.Helper() + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true +} + func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { const subjectID = "subby" const subjectClientID = "https://example.com/oauth2/subby" @@ -415,9 +423,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { scopes := "first second" t.Run("rejects the request when the experimental jwt-bearer feature is disabled", func(t *testing.T) { + // The default zero value of experimentalJwtBearerClient is false; we leave it alone so the gate fires. sp := spSubjectID ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = false _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) @@ -428,7 +436,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { t.Run("rejects the request when the AS does not advertise jwt-bearer", func(t *testing.T) { sp := spSubjectID ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + enableJwtBearerClient(t, ctx) // The default AS metadata in the test setup does not include JwtBearerGrantType in grant_types_supported. _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) @@ -440,7 +448,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { t.Run("rejects the request when no service_provider PD is configured for the scope", func(t *testing.T) { sp := spSubjectID ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + enableJwtBearerClient(t, ctx) ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ CredentialProfileScope: "first", @@ -468,7 +476,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { require.NoError(t, err) ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + enableJwtBearerClient(t, ctx) ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ CredentialProfileScope: "first", @@ -575,7 +583,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { ctx := createClientServerTestContext(t) // Enable the experimental flag so the dispatcher routes us into the two-VP path. - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + enableJwtBearerClient(t, ctx) // Advertise jwt-bearer so the dispatcher's "AS supports jwt-bearer" check passes. ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} // The two-VP path always resolves PDs from the local policy backend (no remote PD endpoint for the @@ -625,7 +633,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) ctx := createClientServerTestContext(t) - ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true + enableJwtBearerClient(t, ctx) ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ CredentialProfileScope: "first", From fd621f46b59bc80657fde885591b5b6eed053091 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 13:17:36 +0200 Subject: [PATCH 63/91] applyCapturedFieldsToSelection allocates a fresh selection map Mutating the caller-supplied selection map risked leaking captured field values into anything that retained the original reference (request DTO, logs, retries, caches). Always allocate a fresh map so the function is a pure value-in / value-out helper. The contract is now unambiguous: callers must use the return value, and the input map is never touched. A new subtest pins the no-mutation guarantee. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 17 ++++++++++------- auth/client/iam/openid4vp_test.go | 10 ++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 306fe9b16a..f34b561d64 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -430,23 +430,26 @@ func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subject return c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) } -// applyCapturedFieldsToSelection adds string-valued entries from captured to selection without overwriting -// existing keys. Non-string captured values are skipped (selection is map[string]string). +// applyCapturedFieldsToSelection returns a fresh selection map containing every entry from selection +// plus any string-valued entry from captured whose key is not already present. Non-string captured values +// are skipped (selection is map[string]string). The caller's selection map is never mutated, so the +// captured values cannot leak back through any retained reference. func applyCapturedFieldsToSelection(selection map[string]string, captured map[string]any) map[string]string { - if selection == nil { - selection = map[string]string{} + merged := make(map[string]string, len(selection)+len(captured)) + for k, v := range selection { + merged[k] = v } for k, v := range captured { - if _, exists := selection[k]; exists { + if _, exists := merged[k]; exists { continue } s, ok := v.(string) if !ok { continue } - selection[k] = s + merged[k] = s } - return selection + return merged } // filterDIDsByMethods drops DIDs whose method is not in supportedMethods. Returns ErrPreconditionFailed when diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index f458df5c86..9756e3f0dd 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -679,6 +679,16 @@ func TestApplyCapturedFieldsToSelection(t *testing.T) { assert.Equal(t, map[string]string{"string_field": "ok"}, merged) }) + + t.Run("does not mutate the caller's selection map", func(t *testing.T) { + // The caller (HTTP handler) typically passes a request-derived map. Mutating it would risk leaking + // captured values into anything that retains a reference to that map (logging, caches, retries). + ehrSelection := map[string]string{"caller_key": "caller_value"} + + _ = applyCapturedFieldsToSelection(ehrSelection, map[string]any{"captured_key": "captured_value"}) + + assert.Equal(t, map[string]string{"caller_key": "caller_value"}, ehrSelection) + }) } func TestIAMClient_RequestObjectByGet(t *testing.T) { From 584425eb75d7d689577e20c0eb665e3e904d6cd4 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 13:21:31 +0200 Subject: [PATCH 64/91] Use typed OAuth2Errors for jwt-bearer rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rejection sites in the two-VP path used plain errors.New / fmt.Errorf, which the API layer cannot map cleanly to OAuth status codes — they fall through to a generic 500 instead of the appropriate 4xx. Switch them to oauth.OAuth2Error matching the existing pattern already used inside loadAndValidateProfile: - "experimental flag off" / "AS does not advertise jwt-bearer" → UnsupportedGrantType (RFC 6749 §5.2) - "no service_provider PD configured for scope" → InvalidScope (matches the existing profile-only-extras error from loadAndValidateProfile) The "AS doesn't advertise" test is tightened to assert the error type and code so the contract is locked in. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 15 ++++++++++++--- auth/client/iam/openid4vp_test.go | 5 ++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index f34b561d64..e03800231b 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -250,7 +250,10 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { if serviceProviderSubjectID != nil && !c.experimentalJwtBearerClient { - return nil, errors.New("jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true") + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedGrantType, + Description: "jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true", + } } metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { @@ -258,7 +261,10 @@ func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientI } if serviceProviderSubjectID != nil { if !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { - return nil, errors.New("authorization server does not advertise jwt-bearer support") + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedGrantType, + Description: "authorization server does not advertise jwt-bearer support", + } } return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) } @@ -346,7 +352,10 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje orgPD := profile.WalletOwnerMapping[pe.WalletOwnerOrganization] spPD, hasSP := profile.WalletOwnerMapping[pe.WalletOwnerServiceProvider] if !hasSP { - return nil, fmt.Errorf("no service_provider presentation definition for scope %q", profile.CredentialProfileScope) + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidScope, + Description: fmt.Sprintf("no service_provider presentation definition for scope %q", profile.CredentialProfileScope), + } } params := holder.BuildParams{ Audience: authServerURL, diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 9756e3f0dd..be437c9072 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -442,7 +442,10 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.Error(t, err) - assert.ErrorContains(t, err, "authorization server does not advertise jwt-bearer") + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.UnsupportedGrantType, oauthErr.Code) + assert.Contains(t, oauthErr.Description, "authorization server does not advertise jwt-bearer") }) t.Run("rejects the request when no service_provider PD is configured for the scope", func(t *testing.T) { From c80a2ee15ec6bfe3d3cbe640add38bb0a33ecb3a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:09:25 +0200 Subject: [PATCH 65/91] Replace PresentationSubmission.ResolveVP with pe.NewEnvelopeFromVP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @reinkrul flagged that adding a one-call syntactic-sugar method to PresentationSubmission widens the otherwise-clean API for marginal benefit. Move the conversion into a dedicated constructor on the Envelope side: pe.NewEnvelopeFromVP(vp) returns an *Envelope, callers hand it to the existing PresentationSubmission.Resolve. Same correctness — Envelope.raw is populated from vp.Raw() so MarshalJSON stays panic-free — without growing PresentationSubmission's surface. Caller in auth/client/iam picks up two lines (constructor + error check) instead of one. Test renamed and adjusted to exercise the new constructor + Resolve combination. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 6 +++++- vcr/pe/presentation_submission.go | 16 ---------------- vcr/pe/presentation_submission_test.go | 13 ++++++++----- vcr/pe/util.go | 15 +++++++++++++++ 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index e03800231b..4cfb1aa38d 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -371,7 +371,11 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje // Cross-VP binding: capture id-bearing constraint field values resolved against VP1 and additively merge // them into the credential_selection map for VP2. The submission tells us which credential satisfied each // input descriptor; we use that to walk the PD's id-bearing fields and extract their matched values. - credentialMap, err := vp1Submission.ResolveVP(*vp1) + envelope, err := pe.NewEnvelopeFromVP(*vp1) + if err != nil { + return nil, fmt.Errorf("build VP1 envelope for cross-VP binding: %w", err) + } + credentialMap, err := vp1Submission.Resolve(*envelope) if err != nil { return nil, fmt.Errorf("resolve VP1 submission for cross-VP binding: %w", err) } diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go index 1087bf41ba..fcd011152f 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -163,22 +163,6 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis return presentationSubmission, signInstruction, nil } -// ResolveVP is a convenience wrapper around Resolve for callers that already hold a single parsed -// VerifiablePresentation in memory (e.g. a freshly built submission from the wallet) and would otherwise -// need to round-trip through Raw() + ParseEnvelope just to call Resolve. Returns a map keyed by -// InputDescriptor.Id; see Resolve for the underlying matching rules. -func (s PresentationSubmission) ResolveVP(presentation vc.VerifiablePresentation) (map[string]vc.VerifiableCredential, error) { - asInterface, err := vpAsInterface(presentation) - if err != nil { - return nil, err - } - return s.Resolve(Envelope{ - Presentations: []vc.VerifiablePresentation{presentation}, - asInterface: asInterface, - raw: []byte(presentation.Raw()), - }) -} - // Resolve returns a map where each of the input descriptors is mapped to the corresponding VerifiableCredential. // If an input descriptor can't be mapped to a VC, an error is returned. // This function is specified by https://identity.foundation/presentation-exchange/#processing-of-submission-entries diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go index 962369fcf4..787e3169cf 100644 --- a/vcr/pe/presentation_submission_test.go +++ b/vcr/pe/presentation_submission_test.go @@ -277,10 +277,11 @@ func TestPresentationSubmissionBuilder_SetCredentialSelector(t *testing.T) { }) } -func TestPresentationSubmission_ResolveVP(t *testing.T) { - t.Run("resolves descriptors against a single parsed VP without round-tripping through Envelope", func(t *testing.T) { - // Caller already has a *VerifiablePresentation in memory (e.g. just built by the wallet); ResolveVP - // must produce the same descriptor → credential mapping that Resolve(envelope) would. +func TestNewEnvelopeFromVP(t *testing.T) { + t.Run("wraps a parsed VP into an Envelope that PresentationSubmission.Resolve accepts", func(t *testing.T) { + // Caller already has a *VerifiablePresentation in memory (e.g. just built by the wallet); + // NewEnvelopeFromVP must produce an Envelope equivalent to ParseEnvelope([]byte(vp.Raw())) so that + // Resolve sees the same descriptor → credential mapping. vpRaw := `{ "@context": ["https://www.w3.org/2018/credentials/v1"], "type": ["VerifiablePresentation"], @@ -302,7 +303,9 @@ func TestPresentationSubmission_ResolveVP(t *testing.T) { }, } - credentials, err := submission.ResolveVP(*presentation) + envelope, err := NewEnvelopeFromVP(*presentation) + require.NoError(t, err) + credentials, err := submission.Resolve(*envelope) require.NoError(t, err) require.Contains(t, credentials, "id_org_cred") diff --git a/vcr/pe/util.go b/vcr/pe/util.go index 024a1d57e3..2c8fd21ab1 100644 --- a/vcr/pe/util.go +++ b/vcr/pe/util.go @@ -93,6 +93,21 @@ func ParseEnvelope(envelopeBytes []byte) (*Envelope, error) { }, nil } +// NewEnvelopeFromVP wraps a single Verifiable Presentation in an Envelope without re-parsing the source +// bytes. Convenient for callers that already hold a parsed VP in memory and would otherwise round-trip +// through Raw() + ParseEnvelope just to feed PresentationSubmission.Resolve. +func NewEnvelopeFromVP(presentation vc.VerifiablePresentation) (*Envelope, error) { + asInterface, err := vpAsInterface(presentation) + if err != nil { + return nil, err + } + return &Envelope{ + Presentations: []vc.VerifiablePresentation{presentation}, + asInterface: asInterface, + raw: []byte(presentation.Raw()), + }, nil +} + // parseEnvelopeEntry parses a single Verifiable Presentation in a Presentation Exchange envelope. // It takes into account custom unmarshalling required for JWT VPs. func parseJSONArrayEnvelope(arr []interface{}) (interface{}, []vc.VerifiablePresentation, error) { From 4f592ca6cf606c0be3ed9fab93746f54215814e3 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:10:49 +0200 Subject: [PATCH 66/91] Spell out the jwt-bearer URN in the AS-doesn't-advertise error Per @reinkrul: include the spec'd grant-type URN in the error description so EHR developers can grep for it directly in the AS's metadata response. The new wording reads: authorization server does not advertise "urn:ietf:params:oauth:grant-type:jwt-bearer" in grant_types_supported Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 2 +- auth/client/iam/openid4vp_test.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 4cfb1aa38d..4aa8f78c4b 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -263,7 +263,7 @@ func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientI if !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { return nil, oauth.OAuth2Error{ Code: oauth.UnsupportedGrantType, - Description: "authorization server does not advertise jwt-bearer support", + Description: fmt.Sprintf("authorization server does not advertise %q in grant_types_supported", oauth.JwtBearerGrantType), } } return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index be437c9072..60d0815574 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -445,7 +445,8 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { var oauthErr oauth.OAuth2Error require.ErrorAs(t, err, &oauthErr) assert.Equal(t, oauth.UnsupportedGrantType, oauthErr.Code) - assert.Contains(t, oauthErr.Description, "authorization server does not advertise jwt-bearer") + assert.Contains(t, oauthErr.Description, oauth.JwtBearerGrantType) + assert.Contains(t, oauthErr.Description, "grant_types_supported") }) t.Run("rejects the request when no service_provider PD is configured for the scope", func(t *testing.T) { From d88dc8f90336ef7c46659ea9bc752ac864be4677 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:17:08 +0200 Subject: [PATCH 67/91] Hoist the VP-assertion lifetime into a named constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @reinkrul: "don't we have a constant for this?" — there wasn't one. Both grant paths used `time.Now().Add(time.Second * 5)` inline. Pull out vpAssertionLifetime so the policy is documented once and a future change touches one line instead of two. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 4aa8f78c4b..10d6c96eca 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -51,6 +51,10 @@ import ( // ErrPreconditionFailed is returned when a precondition is not met. var ErrPreconditionFailed = errors.New("precondition failed") +// vpAssertionLifetime is the validity window of a Verifiable Presentation built for a token request. +// Short by design: the VP is signed and posted within the same call. +const vpAssertionLifetime = 5 * time.Second + var _ Client = (*OpenID4VPClient)(nil) type OpenID4VPClient struct { @@ -287,7 +291,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI params := holder.BuildParams{ Audience: authServerURL, DIDMethods: metadata.DIDMethodsSupported, - Expires: time.Now().Add(time.Second * 5), + Expires: time.Now().Add(vpAssertionLifetime), Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } @@ -360,7 +364,7 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje params := holder.BuildParams{ Audience: authServerURL, DIDMethods: metadata.DIDMethodsSupported, - Expires: time.Now().Add(time.Second * 5), + Expires: time.Now().Add(vpAssertionLifetime), Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } From 262aade3ff3ce3408945d4b8c4d014fff5636f94 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:24:21 +0200 Subject: [PATCH 68/91] Generate a fresh nonce for VP2 in the jwt-bearer flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both wallet.BuildSubmission calls used the same params struct, so VP2 was signed with VP1's nonce. Per spec each Verifiable Presentation must carry its own nonce; reuse would let a verifier confuse the two assertions or accept a replayed pairing. Regenerate params.Nonce between the VP1 and VP2 builds. Expires is intentionally left untouched — the two calls run within milliseconds and the lifetime is already short. A focused subtest captures both BuildSubmission params and asserts the nonces differ. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 2 ++ auth/client/iam/openid4vp_test.go | 44 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 10d6c96eca..2c2353d142 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -388,6 +388,8 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje return nil, fmt.Errorf("resolve VP1 constraint fields for cross-VP binding: %w", err) } credentialSelection = applyCapturedFieldsToSelection(credentialSelection, captured) + // Each VP must carry its own nonce; reuse would let a verifier confuse the two assertions. + params.Nonce = nutsCrypto.GenerateNonce() vp2, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 60d0815574..c922e27245 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -519,6 +519,50 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) }) + t.Run("VP1 and VP2 carry distinct nonces", func(t *testing.T) { + // Each VP must be signed with a fresh nonce. Reusing the same nonce would let a verifier mistakenly + // treat the two assertions as a single signed payload, or accept a replayed pairing of VP1 with a + // stale VP2 (or vice versa). + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // Capture both BuildSubmission's params arguments so we can compare nonces directly. + var vp1Params, vp2Params holder.BuildParams + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, p holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + vp1Params = p + return vp1, &pe.PresentationSubmission{}, nil + }) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, p holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + vp2Params = p + return vp2, &pe.PresentationSubmission{}, nil + }) + + _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + require.NotEmpty(t, vp1Params.Nonce) + require.NotEmpty(t, vp2Params.Nonce) + assert.NotEqual(t, vp1Params.Nonce, vp2Params.Nonce, "VP2 must be signed with a fresh nonce") + }) + t.Run("captured VP1 field-id values flow into VP2 credential_selection end-to-end", func(t *testing.T) { // Cross-VP binding scenario, end to end: when the organization PD and the service_provider PD share // the same constraint-field `id` (here: "delegating_hcp"), the value matched in VP1 must flow into From 410e67aeeafc165112bd8d4a02cb7b052de0881f Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:33:23 +0200 Subject: [PATCH 69/91] Surface the JSONPath in cross-VP binding errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @reinkrul: "EHR developers will have no idea what they're doing wrong here." The cross-VP-binding error chain only carried the input descriptor ID and the credential ID; the failing JSONPath — the most useful piece for whoever is fixing the policy file — was inside the wrapped error, but unattributed. Two changes, one direction: - vcr/pe/matchField now wraps getValueAtPath / matchFilter errors with `path %q: %w`, so the path that failed is part of the chain for every caller (introspection, server-side, this PR's two-VP). - The three cross-VP error sites in requestJwtBearerAccessToken (envelope-build, submission-resolve, constraint-fields-resolve) return typed oauth.OAuth2Error{ServerError} with operator-facing descriptions naming the artefact ("organization presentation definition" for the constraint-fields case) so the API layer maps to a recognisable status code. Sample chain end to end: failed to extract cross-VP binding values from VP1 against the organization presentation definition: failed to match constraint for input descriptor 'id_org_cred' and credential 'urn:vc:42': path "$.issuer": Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 15 ++++++++++++--- vcr/pe/presentation_definition.go | 4 ++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 2c2353d142..eed07eb148 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -377,15 +377,24 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje // input descriptor; we use that to walk the PD's id-bearing fields and extract their matched values. envelope, err := pe.NewEnvelopeFromVP(*vp1) if err != nil { - return nil, fmt.Errorf("build VP1 envelope for cross-VP binding: %w", err) + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("failed to wrap VP1 in an envelope for cross-VP binding: %s", err), + } } credentialMap, err := vp1Submission.Resolve(*envelope) if err != nil { - return nil, fmt.Errorf("resolve VP1 submission for cross-VP binding: %w", err) + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("failed to resolve VP1 submission for cross-VP binding: %s", err), + } } captured, err := orgPD.ResolveConstraintsFields(credentialMap) if err != nil { - return nil, fmt.Errorf("resolve VP1 constraint fields for cross-VP binding: %w", err) + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("failed to extract cross-VP binding values from VP1 against the organization presentation definition: %s", err), + } } credentialSelection = applyCapturedFieldsToSelection(credentialSelection, captured) // Each VP must carry its own nonce; reuse would let a verifier confuse the two assertions. diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 1b982e4f43..f204463f13 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -446,7 +446,7 @@ func matchField(field Field, credential map[string]interface{}) (bool, interface // if path is not found continue value, err := getValueAtPath(path, credential) if err != nil { - return false, nil, err + return false, nil, fmt.Errorf("path %q: %w", path, err) } if value == nil { continue @@ -459,7 +459,7 @@ func matchField(field Field, credential map[string]interface{}) (bool, interface // if filter at path matches return true match, matchedValue, err := matchFilter(*field.Filter, value) if err != nil { - return false, nil, err + return false, nil, fmt.Errorf("path %q: %w", path, err) } if match { return true, matchedValue, nil From 7348a9c6f0f04ce54dc18327c09b273be92e2f19 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:46:13 +0200 Subject: [PATCH 70/91] Rename vp1/vp2 locals to organizationVP/serviceProviderVP @reinkrul: VP1/VP2 are non-descriptive. Rename the local variables in requestJwtBearerAccessToken and the TwoVP test fixtures to use the role of each VP at the call site: vp1 -> organizationVP vp1Submission -> organizationSubmission vp2 -> serviceProviderVP vp1Params -> organizationVPParams vp2Params -> serviceProviderVPParams The trace log labels and the cross-VP test's chain comment update to match. No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 29 ++++++------- auth/client/iam/openid4vp_test.go | 70 ++++++++++++++++--------------- 2 files changed, 51 insertions(+), 48 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index eed07eb148..fa91b28f8d 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -368,44 +368,45 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } - vp1, vp1Submission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params) + organizationVP, organizationSubmission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } - // Cross-VP binding: capture id-bearing constraint field values resolved against VP1 and additively merge - // them into the credential_selection map for VP2. The submission tells us which credential satisfied each - // input descriptor; we use that to walk the PD's id-bearing fields and extract their matched values. - envelope, err := pe.NewEnvelopeFromVP(*vp1) + // Cross-VP binding: capture id-bearing constraint field values resolved against the organization VP + // and additively merge them into the credential_selection map for the service-provider VP. The + // submission tells us which credential satisfied each input descriptor; we use that to walk the PD's + // id-bearing fields and extract their matched values. + envelope, err := pe.NewEnvelopeFromVP(*organizationVP) if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.ServerError, - Description: fmt.Sprintf("failed to wrap VP1 in an envelope for cross-VP binding: %s", err), + Description: fmt.Sprintf("failed to wrap the organization VP in an envelope for cross-VP binding: %s", err), } } - credentialMap, err := vp1Submission.Resolve(*envelope) + credentialMap, err := organizationSubmission.Resolve(*envelope) if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.ServerError, - Description: fmt.Sprintf("failed to resolve VP1 submission for cross-VP binding: %s", err), + Description: fmt.Sprintf("failed to resolve the organization VP submission for cross-VP binding: %s", err), } } captured, err := orgPD.ResolveConstraintsFields(credentialMap) if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.ServerError, - Description: fmt.Sprintf("failed to extract cross-VP binding values from VP1 against the organization presentation definition: %s", err), + Description: fmt.Sprintf("failed to extract cross-VP binding values from the organization VP against the organization presentation definition: %s", err), } } credentialSelection = applyCapturedFieldsToSelection(credentialSelection, captured) // Each VP must carry its own nonce; reuse would let a verifier confuse the two assertions. params.Nonce = nutsCrypto.GenerateNonce() - vp2, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params) + serviceProviderVP, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } // DPoP binds the issued access token to a key the service provider controls — the SP wallet will // present and use the token, so the proof is signed with the SP DID's key. - spDID, err := did.ParseDID(vp2.Holder.String()) + spDID, err := did.ParseDID(serviceProviderVP.Holder.String()) if err != nil { return nil, err } @@ -415,12 +416,12 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje } data := url.Values{} data.Set(oauth.GrantTypeParam, oauth.JwtBearerGrantType) - data.Set(oauth.AssertionParam, vp1.Raw()) + data.Set(oauth.AssertionParam, organizationVP.Raw()) data.Set(oauth.ClientAssertionTypeParam, oauth.JwtBearerClientAssertionType) - data.Set(oauth.ClientAssertionParam, vp2.Raw()) + data.Set(oauth.ClientAssertionParam, serviceProviderVP.Raw()) data.Set(oauth.ScopeParam, resolvedScope) - log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n VP1: %s\n VP2: %s", metadata.TokenEndpoint, resolvedScope, vp1.Raw(), vp2.Raw()) + log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n organization VP: %s\n service provider VP: %s", metadata.TokenEndpoint, resolvedScope, organizationVP.Raw(), serviceProviderVP.Raw()) token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) if err != nil { return nil, err diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index c922e27245..4c13395680 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -474,9 +474,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") spDID := did.MustParseDID("did:test:sp") - vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) require.NoError(t, err) - vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) ctx := createClientServerTestContext(t) @@ -493,9 +493,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) // VP1 is built from the HCP wallet using the organization PD; VP2 from the SP wallet using the service_provider PD. ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), - pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(vp1, &pe.PresentationSubmission{}, nil) + pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), - pe.PresentationDefinition{Id: "sp_pd"}, gomock.Any(), gomock.Any()).Return(vp2, &pe.PresentationSubmission{}, nil) + pe.PresentationDefinition{Id: "sp_pd"}, gomock.Any(), gomock.Any()).Return(serviceProviderVP, &pe.PresentationSubmission{}, nil) var capturedForm url.Values ctx.token = func(writer http.ResponseWriter, request *http.Request) { @@ -511,9 +511,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { require.NoError(t, err) require.NotNil(t, response) assert.Equal(t, oauth.JwtBearerGrantType, capturedForm.Get(oauth.GrantTypeParam)) - assert.Equal(t, vp1.Raw(), capturedForm.Get(oauth.AssertionParam)) + assert.Equal(t, organizationVP.Raw(), capturedForm.Get(oauth.AssertionParam)) assert.Equal(t, oauth.JwtBearerClientAssertionType, capturedForm.Get(oauth.ClientAssertionTypeParam)) - assert.Equal(t, vp2.Raw(), capturedForm.Get(oauth.ClientAssertionParam)) + assert.Equal(t, serviceProviderVP.Raw(), capturedForm.Get(oauth.ClientAssertionParam)) assert.Empty(t, capturedForm.Get(oauth.PresentationSubmissionParam)) // Per RFC 7521 §4.2 client_id is optional when client_assertion is present and we omit it. assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) @@ -526,9 +526,9 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") spDID := did.MustParseDID("did:test:sp") - vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) require.NoError(t, err) - vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) ctx := createClientServerTestContext(t) enableJwtBearerClient(t, ctx) @@ -543,38 +543,40 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) // Capture both BuildSubmission's params arguments so we can compare nonces directly. - var vp1Params, vp2Params holder.BuildParams + var organizationVPParams, serviceProviderVPParams holder.BuildParams ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, p holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { - vp1Params = p - return vp1, &pe.PresentationSubmission{}, nil + organizationVPParams = p + return organizationVP, &pe.PresentationSubmission{}, nil }) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, p holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { - vp2Params = p - return vp2, &pe.PresentationSubmission{}, nil + serviceProviderVPParams = p + return serviceProviderVP, &pe.PresentationSubmission{}, nil }) _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) require.NoError(t, err) - require.NotEmpty(t, vp1Params.Nonce) - require.NotEmpty(t, vp2Params.Nonce) - assert.NotEqual(t, vp1Params.Nonce, vp2Params.Nonce, "VP2 must be signed with a fresh nonce") + require.NotEmpty(t, organizationVPParams.Nonce) + require.NotEmpty(t, serviceProviderVPParams.Nonce) + assert.NotEqual(t, organizationVPParams.Nonce, serviceProviderVPParams.Nonce, "VP2 must be signed with a fresh nonce") }) t.Run("captured VP1 field-id values flow into VP2 credential_selection end-to-end", func(t *testing.T) { // Cross-VP binding scenario, end to end: when the organization PD and the service_provider PD share - // the same constraint-field `id` (here: "delegating_hcp"), the value matched in VP1 must flow into - // VP2's credential_selection so the wallet building VP2 can pick a credential constrained by that - // value. This test follows the chain through requestJwtBearerAccessToken: + // the same constraint-field `id` (here: "delegating_hcp"), the value matched in the organization VP + // must flow into the service-provider VP's credential_selection so the wallet building it can pick + // a credential constrained by that value. This test follows the chain through + // requestJwtBearerAccessToken: // - // VP1 + vp1Submission --ResolveVP--> credentialMap (which VC satisfied which input descriptor) + // organizationVP + organizationSubmission --NewEnvelopeFromVP+Resolve--> credentialMap // credentialMap + orgPD --ResolveConstraintsFields--> {delegating_hcp: did:test:hcp} - // --applyCapturedFieldsToSelection--> credential_selection passed to VP2's BuildSubmission + // --applyCapturedFieldsToSelection--> credential_selection passed to the service-provider + // wallet's BuildSubmission // // TestApplyCapturedFieldsToSelection covers the merge step in isolation; this test exists to guard - // the wiring from BuildSubmission's return values into VP2's BuildSubmission argument. + // the wiring from the first BuildSubmission's return values into the second BuildSubmission's args. sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") @@ -584,7 +586,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { // one credential whose $.issuer is the HCP DID — that value is what the binding will capture. // `holder` is set so the DPoP code path (which derives a signing DID from vp.Holder) doesn't panic // even though we don't enable DPoP in this test. - vp1, err := vc.ParseVerifiablePresentation(`{ + organizationVP, err := vc.ParseVerifiablePresentation(`{ "@context": ["https://www.w3.org/2018/credentials/v1"], "type": ["VerifiablePresentation"], "holder": "did:test:hcp", @@ -599,14 +601,14 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { }`) require.NoError(t, err) // VP2's body is irrelevant for this test — we only assert what VP2's BuildSubmission was *called* - // with; we never inspect vp2 itself afterwards. The holder is set for the same DPoP-safety reason. - vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + // with; we never inspect serviceProviderVP itself afterwards. The holder is set for the same DPoP-safety reason. + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) - // vp1Submission tells the resolver "input descriptor id_org_cred was satisfied by the credential at + // organizationSubmission tells the resolver "input descriptor id_org_cred was satisfied by the credential at // $.verifiableCredential[0]". Without this, ResolveVP can't bridge from a descriptor id to a VC, and // ResolveConstraintsFields has nothing to walk for $.issuer. - vp1Submission := &pe.PresentationSubmission{ + organizationSubmission := &pe.PresentationSubmission{ DescriptorMap: []pe.InputDescriptorMappingObject{ {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, }, @@ -650,7 +652,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { // First BuildSubmission call: build VP1 against the HCP DID using orgPD. We return the JSON-LD VP // and its submission so the production code can resolve constraint fields against them. ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), - orgPD, gomock.Any(), gomock.Any()).Return(vp1, vp1Submission, nil) + orgPD, gomock.Any(), gomock.Any()).Return(organizationVP, organizationSubmission, nil) // Second BuildSubmission call: build VP2 against the SP DID using spPD. We capture the // credential_selection argument so the assertion at the bottom can verify the merged field-id // value made it through the orchestration. @@ -659,7 +661,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { spPD, gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, sel map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { capturedSPSelection = sel - return vp2, &pe.PresentationSubmission{}, nil + return serviceProviderVP, &pe.PresentationSubmission{}, nil }) _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) @@ -672,13 +674,13 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { t.Run("ok with DPoPHeader", func(t *testing.T) { // DPoP binds the issued access token to a key the SP wallet controls — the proof must be signed with - // the SP DID's key (vp2.Holder), not the HCP DID's key. + // the SP DID's key (serviceProviderVP.Holder), not the HCP DID's key. sp := spSubjectID spDID := did.MustParseDID("did:test:sp") spKID := "did:test:sp#1" - vp1, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) require.NoError(t, err) - vp2, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) require.NoError(t, err) ctx := createClientServerTestContext(t) enableJwtBearerClient(t, ctx) @@ -692,8 +694,8 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { }, nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{did.MustParseDID("did:test:hcp")}, nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(vp1, &pe.PresentationSubmission{}, nil) - ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(vp2, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(serviceProviderVP, &pe.PresentationSubmission{}, nil) // The DPoP signing key must be resolved against the SP DID, not the HCP DID — that's the assertion. ctx.keyResolver.EXPECT().ResolveKey(spDID, nil, resolver.NutsSigningKeyType).Return(spKID, nil, nil) ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), spKID).Return("dpop", nil) From e898ff3cb042473bab27db5145a4a0cf2bb49ab5 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 15:56:27 +0200 Subject: [PATCH 71/91] Rename Client.RequestServiceAccessToken's subjectDID parameter to subjectID @reinkrul: the interface parameter was named subjectDID, but it actually receives a subject identifier (a string used to look up the subject's wallet DIDs), not a DID itself. The implementation has always called it subjectID. Match the names. Mock regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/interface.go | 2 +- auth/client/iam/mock.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index d26eb6004f..e91e1e1ddd 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -52,7 +52,7 @@ type Client interface { // offered to both wallets; each PD selects what matches its input descriptors. Signed VCs flow through unchanged; // unsigned self-attested credentials are auto-issued per holder DID by AutoCorrectSelfAttestedCredential. // credentialSelection maps PD field IDs to expected values to disambiguate when multiple credentials match an input descriptor. - RequestServiceAccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, + RequestServiceAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 3a734e2fd2..dc22ba0484 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -194,18 +194,18 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet } // RequestServiceAccessToken mocks base method. -func (m *MockClient) RequestServiceAccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { +func (m *MockClient) RequestServiceAccessToken(ctx context.Context, clientID, subjectID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestServiceAccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) + ret := m.ctrl.Call(m, "RequestServiceAccessToken", ctx, clientID, subjectID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // RequestServiceAccessToken indicates an expected call of RequestServiceAccessToken. -func (mr *MockClientMockRecorder) RequestServiceAccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID any) *gomock.Call { +func (mr *MockClientMockRecorder) RequestServiceAccessToken(ctx, clientID, subjectID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestServiceAccessToken", reflect.TypeOf((*MockClient)(nil).RequestServiceAccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestServiceAccessToken", reflect.TypeOf((*MockClient)(nil).RequestServiceAccessToken), ctx, clientID, subjectID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) } // VerifiableCredentials mocks base method. From 1c9322551d3aeb151a1e003ca03ccaf6052b07df Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 8 May 2026 07:27:30 +0200 Subject: [PATCH 72/91] =?UTF-8?q?Rename=20auth.experimental.jwt=5Fbearer?= =?UTF-8?q?=5Fclient=20=E2=86=92=20jwtbearerclient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original koanf tag had underscores (jwt_bearer_client) which conflict with koanf's env-var loader: NUTS_AUTH_EXPERIMENTAL_JWT_BEARER_CLIENT parses as auth.experimental.jwt.bearer.client (5 path components) and doesn't bind. The new name jwtbearerclient (single lowercase word) matches the rest of the codebase's koanf-tag convention (auth.contract validators, auth.accesstokenlifespan, network.enablediscovery, ...) and parses correctly via the env var. The Go field name JwtBearerClient is unchanged. Caught while writing the integration test (#4229) — env-var override of the flag silently failed. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 2 +- auth/cmd/cmd.go | 2 +- auth/config.go | 2 +- docs/pages/deployment/server_options.rst | 2 +- docs/pages/release_notes.rst | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index fa91b28f8d..15d51f2738 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -256,7 +256,7 @@ func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientI if serviceProviderSubjectID != nil && !c.experimentalJwtBearerClient { return nil, oauth.OAuth2Error{ Code: oauth.UnsupportedGrantType, - Description: "jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true", + Description: "jwt-bearer two-VP flow requires auth.experimental.jwtbearerclient = true", } } metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) diff --git a/auth/cmd/cmd.go b/auth/cmd/cmd.go index 137a019f9d..e0787c6f8c 100644 --- a/auth/cmd/cmd.go +++ b/auth/cmd/cmd.go @@ -48,7 +48,7 @@ const ConfAccessTokenLifeSpan = "auth.accesstokenlifespan" const ConfAuthEndpointEnabled = "auth.authorizationendpoint.enabled" // ConfExperimentalJwtBearerClient toggles the RFC 7523 jwt-bearer two-VP token request flow. -const ConfExperimentalJwtBearerClient = "auth.experimental.jwt_bearer_client" +const ConfExperimentalJwtBearerClient = "auth.experimental.jwtbearerclient" // FlagSet returns the configuration flags supported by this module. func FlagSet() *pflag.FlagSet { diff --git a/auth/config.go b/auth/config.go index 0b8387837d..78cf9ea95f 100644 --- a/auth/config.go +++ b/auth/config.go @@ -40,7 +40,7 @@ type Config struct { type ExperimentalConfig struct { // JwtBearerClient enables the RFC 7523 jwt-bearer two-VP token request flow. // While disabled (the default), requests carrying a service-provider subject identifier are rejected. - JwtBearerClient bool `koanf:"jwt_bearer_client"` + JwtBearerClient bool `koanf:"jwtbearerclient"` } type AuthorizationEndpointConfig struct { diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index 5fa404be50..e6c5f3d304 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -17,7 +17,7 @@ httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. **Auth** auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature. - auth.experimental.jwt_bearer_client false enables the experimental RFC 7523 jwt-bearer two-VP token request flow. While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice. + auth.experimental.jwtbearerclient false enables the experimental RFC 7523 jwt-bearer two-VP token request flow. While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice. **Crypto** crypto.storage Storage to use, 'fs' for file system (for development purposes), 'vaultkv' for HashiCorp Vault KV store, 'azure-keyvault' for Azure Key Vault, 'external' for an external backend (deprecated). crypto.azurekv.hsm false Whether to store the key in a hardware security module (HSM). If true, the Azure Key Vault must be configured for HSM usage. Default: false diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index d5b3370645..f9003f4aa6 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -9,7 +9,7 @@ Unreleased ## New features * #4063: Enable ``storage.debug`` flag to log go-leia performance issues (full table scans, suboptimal index usage) by @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4064 * #4078: Allow policy profiles to define a ``service_provider`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 -* #4078: Add the experimental RFC 7523 ``jwt-bearer`` two-VP token request flow, gated behind ``auth.experimental.jwt_bearer_client`` (default ``false``, subject to change) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4227 +* #4078: Add the experimental RFC 7523 ``jwt-bearer`` two-VP token request flow, gated behind ``auth.experimental.jwtbearerclient`` (default ``false``, subject to change) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4227 **************** Peanut (v6.2.1) From 5bd999409435a047619456504562e3bd7ade55f6 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:54:31 +0200 Subject: [PATCH 73/91] Initialize branch for PR From c20838641db05561247d25fdfafb3c4a34d62c2c Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:55:30 +0200 Subject: [PATCH 74/91] Initialize branch for PR From 0df279ad270c84e0c6dbbd8484386f4c1c644acc Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 19:09:39 +0200 Subject: [PATCH 75/91] Add service_provider_subject_id to ServiceAccessTokenRequest Threads the new optional OpenAPI field through the handler to the IAM client's existing serviceProviderSubjectID parameter (added in #4227). The two-VP dispatch and feature gating already live in the IAM client; the handler's job here is just to forward the value. Named service_provider_subject_id rather than client_id (which the PRD body suggested) to avoid overloading with the OAuth client_id form parameter and to match the convention adopted in #4226 (service_provider PD block) and the internal serviceProviderSubjectID parameter. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/api.go | 2 +- auth/api/iam/api_test.go | 17 +++++++++++++++++ auth/api/iam/generated.go | 22 +++++++++++++++++++--- docs/_static/auth/v2.yaml | 15 +++++++++++++++ e2e-tests/browser/client/iam/generated.go | 22 +++++++++++++++++++--- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 3f833d26b0..7289dd1cab 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -781,7 +781,7 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestServiceAccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, nil) + tokenResult, err := r.auth.IAMClient().RequestServiceAccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, request.Body.ServiceProviderSubjectId) if err != nil { // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials return nil, err diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index bfac8c1308..8a99eccab6 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1007,6 +1007,23 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { assert.NotEqual(t, token1, token2) }) + t.Run("ok - service_provider_subject_id threads through to IAM client", func(t *testing.T) { + // Verifies the wire-up of the new OpenAPI field. The dispatch and feature gating live in + // the IAM client (under #4227); the handler's job is just to forward the value verbatim. + ctx := newTestClient(t) + spSubject := "acme-service-provider" + bodyWithSP := &RequestServiceAccessTokenJSONRequestBody{ + AuthorizationServer: verifierURL.String(), + Scope: "first second", + ServiceProviderSubjectId: &spSubject, + } + response := &oauth.TokenResponse{AccessToken: "token", TokenType: "Bearer", ExpiresIn: to.Ptr(900)} + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, &spSubject).Return(response, nil) + + _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithSP}) + + require.NoError(t, err) + }) t.Run("self-asserted credentials", func(t *testing.T) { response := &oauth.TokenResponse{ AccessToken: "token", diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 859cff5efd..c48663bc20 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -106,7 +106,7 @@ type ExtendedTokenIntrospectionResponse struct { // PresentationSubmissions Mapping of Presentation Definition IDs that were fulfilled to Presentation Submissions. PresentationSubmissions *map[string]PresentationSubmission `json:"presentation_submissions,omitempty"` - // Scope granted scopes + // Scope Granted scopes, as a space-separated list. Scope *string `json:"scope,omitempty"` Vps *[]VerifiablePresentation `json:"vps,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` @@ -135,12 +135,15 @@ type ServiceAccessTokenRequest struct { AuthorizationServer string `json:"authorization_server"` // CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple - // credentials matching a single input descriptor. Each key must match a field id declared + // credentials matching a single input descriptor. Each key must match a field ID declared // in the Presentation Definition's input descriptor constraints. The value narrows the // match to credentials where that field equals the given value. // // The selection must narrow to exactly one credential per input descriptor. // Zero matches or multiple matches will result in an error. + // + // When omitted and multiple credentials match an input descriptor, + // the first matching credential is used. CredentialSelection *map[string]string `json:"credential_selection,omitempty"` // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. @@ -158,6 +161,19 @@ type ServiceAccessTokenRequest struct { // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` + // ServiceProviderSubjectId **Experimental.** Nuts subject identifier of the OAuth client (service provider). + // When present, the node uses the RFC 7523 jwt-bearer two-VP token request flow: + // VP1 is built from the wallet identified by the path-param `subjectID` (the healthcare + // provider) using the `organization` PD; VP2 is built from the wallet identified here + // using the `service_provider` PD. Requires `auth.experimental.jwt_bearer_client = true`, + // an authorization server that advertises `urn:ietf:params:oauth:grant-type:jwt-bearer`, + // and a `service_provider` PD configured for the requested credential profile. + // + // When omitted, the existing single-VP `vp_token-bearer` flow runs unchanged. + // + // Subject to change without notice. + ServiceProviderSubjectId *string `json:"service_provider_subject_id,omitempty"` + // TokenType The type of access token that is preferred, default: DPoP TokenType *ServiceAccessTokenRequestTokenType `json:"token_type,omitempty"` } @@ -197,7 +213,7 @@ type UserAccessTokenRequestTokenType string // UserDetails Claims about the authorized user. type UserDetails struct { - // Id Machine-readable identifier, uniquely identifying the user in the issuing system. + // Id Machine-readable identifier, uniquely identifying the user in the issuing system. The format is not specified; it could be a username, email address, employee number, etc. Id string `json:"id"` // Name Human-readable name of the user. diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 01cf6ba826..1dd5616331 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -515,6 +515,21 @@ components: description: "The type of access token that is preferred, default: DPoP" default: DPoP enum: [ Bearer, DPoP ] + service_provider_subject_id: + type: string + description: | + **Experimental.** Nuts subject identifier of the OAuth client (service provider). + When present, the node uses the RFC 7523 jwt-bearer two-VP token request flow: + VP1 is built from the wallet identified by the path-param `subjectID` (the healthcare + provider) using the `organization` PD; VP2 is built from the wallet identified here + using the `service_provider` PD. Requires `auth.experimental.jwt_bearer_client = true`, + an authorization server that advertises `urn:ietf:params:oauth:grant-type:jwt-bearer`, + and a `service_provider` PD configured for the requested credential profile. + + When omitted, the existing single-VP `vp_token-bearer` flow runs unchanged. + + Subject to change without notice. + example: acme-service-provider UserAccessTokenRequest: type: object description: Request for an access token for a user. diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 51c1f31772..c93cb3ddae 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -107,7 +107,7 @@ type ExtendedTokenIntrospectionResponse struct { // PresentationSubmissions Mapping of Presentation Definition IDs that were fulfilled to Presentation Submissions. PresentationSubmissions *map[string]PresentationSubmission `json:"presentation_submissions,omitempty"` - // Scope granted scopes + // Scope Granted scopes, as a space-separated list. Scope *string `json:"scope,omitempty"` Vps *[]VerifiablePresentation `json:"vps,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` @@ -129,12 +129,15 @@ type ServiceAccessTokenRequest struct { AuthorizationServer string `json:"authorization_server"` // CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple - // credentials matching a single input descriptor. Each key must match a field id declared + // credentials matching a single input descriptor. Each key must match a field ID declared // in the Presentation Definition's input descriptor constraints. The value narrows the // match to credentials where that field equals the given value. // // The selection must narrow to exactly one credential per input descriptor. // Zero matches or multiple matches will result in an error. + // + // When omitted and multiple credentials match an input descriptor, + // the first matching credential is used. CredentialSelection *map[string]string `json:"credential_selection,omitempty"` // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. @@ -152,6 +155,19 @@ type ServiceAccessTokenRequest struct { // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` + // ServiceProviderSubjectId **Experimental.** Nuts subject identifier of the OAuth client (service provider). + // When present, the node uses the RFC 7523 jwt-bearer two-VP token request flow: + // VP1 is built from the wallet identified by the path-param `subjectID` (the healthcare + // provider) using the `organization` PD; VP2 is built from the wallet identified here + // using the `service_provider` PD. Requires `auth.experimental.jwt_bearer_client = true`, + // an authorization server that advertises `urn:ietf:params:oauth:grant-type:jwt-bearer`, + // and a `service_provider` PD configured for the requested credential profile. + // + // When omitted, the existing single-VP `vp_token-bearer` flow runs unchanged. + // + // Subject to change without notice. + ServiceProviderSubjectId *string `json:"service_provider_subject_id,omitempty"` + // TokenType The type of access token that is preferred, default: DPoP TokenType *ServiceAccessTokenRequestTokenType `json:"token_type,omitempty"` } @@ -191,7 +207,7 @@ type UserAccessTokenRequestTokenType string // UserDetails Claims about the authorized user. type UserDetails struct { - // Id Machine-readable identifier, uniquely identifying the user in the issuing system. + // Id Machine-readable identifier, uniquely identifying the user in the issuing system. The format is not specified; it could be a username, email address, employee number, etc. Id string `json:"id"` // Name Human-readable name of the user. From b465d4637d265931e0ddb4f2a12da47758898b24 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 19:16:34 +0200 Subject: [PATCH 76/91] Rename subjectID to organizationSubjectID inside requestJwtBearerAccessToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @reinkrul: in the two-VP context, the parameter is specifically the organization (HCP) subject — distinguishing it from the service-provider subject in the same signature makes the call sites self-documenting. The dispatcher and the single-VP path keep their generic subjectID since they don't share a signature with the SP subject. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 15d51f2738..e692f16029 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -345,7 +345,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI // parameter is sent on this path. // Both PDs are resolved from the local policy backend; the AS's remote presentation_definition endpoint // is not consulted (no standardised mechanism exists today for the AS to advertise a service_provider PD). -func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subjectID string, serviceProviderSubjectID string, +func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, organizationSubjectID string, serviceProviderSubjectID string, authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { profile, resolvedScope, err := loadAndValidateProfile(ctx, c.policyBackend, scopes) @@ -368,7 +368,7 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } - organizationVP, organizationSubmission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params) + organizationVP, organizationSubmission, err := c.buildSubmissionForSubject(ctx, organizationSubjectID, orgPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } From 71e896e47edb6a2ae233a3a9334a73810370582e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 19:18:29 +0200 Subject: [PATCH 77/91] Refactor iam.NewClient to accept a ClientConfig struct The previous constructor took 9 positional arguments, including two booleans separated by a duration. A struct makes the call site self-documenting and means future config additions (e.g. the grant-types-enabled list tracked under #4231) become a one-line struct change instead of an N-call-site signature break. The only call site is auth/auth.go. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/auth.go | 12 +++++++- auth/client/iam/openid4vp.go | 58 ++++++++++++++++++++++-------------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index 57f7d72609..98e33c2118 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -129,7 +129,17 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty { func (auth *Auth) IAMClient() iam.Client { keyResolver := resolver.DIDKeyResolver{Resolver: auth.vdrInstance.Resolver()} - return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout, auth.config.Experimental.JwtBearerClient) + return iam.NewClient(iam.ClientConfig{ + Wallet: auth.vcr.Wallet(), + KeyResolver: keyResolver, + SubjectManager: auth.subjectManager, + JWTSigner: auth.keyStore, + LDDocumentLoader: auth.jsonldManager.DocumentLoader(), + PolicyBackend: auth.policyBackend, + StrictMode: auth.strictMode, + HTTPClientTimeout: auth.httpClientTimeout, + ExperimentalJwtBearerClient: auth.config.Experimental.JwtBearerClient, + }) } // Configure the Auth struct by creating a validator and create an Irma server diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index e692f16029..156f80c0d5 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -70,31 +70,45 @@ type OpenID4VPClient struct { experimentalJwtBearerClient bool } -// NewClient returns an implementation of Holder -func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectManager didsubject.Manager, jwtSigner nutsCrypto.JWTSigner, - ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration, - experimentalJwtBearerClient bool) *OpenID4VPClient { +// ClientConfig groups the dependencies and toggles needed to construct an OpenID4VPClient. +// All fields are required unless explicitly noted. +type ClientConfig struct { + Wallet holder.Wallet + KeyResolver resolver.KeyResolver + SubjectManager didsubject.Manager + JWTSigner nutsCrypto.JWTSigner + LDDocumentLoader ld.DocumentLoader + PolicyBackend policy.PDPBackend + StrictMode bool + HTTPClientTimeout time.Duration + // ExperimentalJwtBearerClient gates the RFC 7523 jwt-bearer two-VP token request flow. + // Tracked for replacement by a list-style grant-types config under issue #4231. + ExperimentalJwtBearerClient bool +} + +// NewClient returns an OpenID4VPClient configured with the given dependencies. +func NewClient(cfg ClientConfig) *OpenID4VPClient { httpClient := HTTPClient{ - strictMode: strictMode, - httpClient: client.NewWithCache(httpClientTimeout), - keyResolver: keyResolver, + strictMode: cfg.StrictMode, + httpClient: client.NewWithCache(cfg.HTTPClientTimeout), + keyResolver: cfg.KeyResolver, } - client := &OpenID4VPClient{ + c := &OpenID4VPClient{ httpClient: httpClient, - keyResolver: keyResolver, - jwtSigner: jwtSigner, - ldDocumentLoader: ldDocumentLoader, - subjectManager: subjectManager, - strictMode: strictMode, - wallet: wallet, - policyBackend: policyBackend, - experimentalJwtBearerClient: experimentalJwtBearerClient, - } - client.pdResolver = PresentationDefinitionResolver{ - pdFetcher: client, - policyBackend: policyBackend, - } - return client + keyResolver: cfg.KeyResolver, + jwtSigner: cfg.JWTSigner, + ldDocumentLoader: cfg.LDDocumentLoader, + subjectManager: cfg.SubjectManager, + strictMode: cfg.StrictMode, + wallet: cfg.Wallet, + policyBackend: cfg.PolicyBackend, + experimentalJwtBearerClient: cfg.ExperimentalJwtBearerClient, + } + c.pdResolver = PresentationDefinitionResolver{ + pdFetcher: c, + policyBackend: cfg.PolicyBackend, + } + return c } func (c *OpenID4VPClient) ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) { From c19105f05a40199214d71f7e93e26b857c37466b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 19:21:22 +0200 Subject: [PATCH 78/91] Use lightweight test context for the feature-disabled gate test The experimental-flag gate in RequestServiceAccessToken returns synchronously before any HTTP call, so the TLS server fixture set up by createClientServerTestContext was dead weight for this one test. createClientTestContext gives just the client and the mocks, no TLS server, no JSON metadata fixtures. The other two failure-mode tests (AS-doesn't-advertise, no service_provider PD) genuinely need the AS metadata HTTP fixture and correctly stay on createClientServerTestContext. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 4c13395680..db6f5038d2 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -423,11 +423,13 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { scopes := "first second" t.Run("rejects the request when the experimental jwt-bearer feature is disabled", func(t *testing.T) { - // The default zero value of experimentalJwtBearerClient is false; we leave it alone so the gate fires. + // The gate fires synchronously before any HTTP call, so the lightweight test context (no TLS + // server, no metadata fixtures) is sufficient. The default zero value of + // experimentalJwtBearerClient is false; we leave it alone so the gate fires. sp := spSubjectID - ctx := createClientServerTestContext(t) + ctx := createClientTestContext(t, nil) - _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, "https://example.com/oauth2/verifier", scopes, false, nil, nil, &sp) require.Error(t, err) assert.ErrorContains(t, err, "jwt-bearer") From c28d8031dffb96ba4d8f8cac11e59e0b52665f1f Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 5 May 2026 11:09:37 +0200 Subject: [PATCH 79/91] Add tests for SP-side wallet failures in the two-VP flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing single-VP error tests (BuildSubmission returning pe.ErrNoCredentials, DID-method mismatch) for the second wallet trip in the jwt-bearer flow: - SP wallet returns DIDs whose methods are not in the AS's DIDMethodsSupported list — filterDIDsByMethods returns ErrPreconditionFailed. - SP wallet has matching DIDs but BuildSubmission cannot satisfy the service_provider PD — pe.ErrNoCredentials must propagate so the API layer can map it to 412 Precondition Failed. Carry-over from #4227 self-review. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp_test.go | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index db6f5038d2..0b33ed45d3 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -472,6 +472,70 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { // Scope-policy behaviour (profile-only rejection, passthrough forwarding, missing organization PD) // is covered by TestLoadAndValidateProfile, which exercises the same helper this path delegates to. + t.Run("rejects the request when the SP wallet has no DIDs matching the AS's supported methods", func(t *testing.T) { + // VP1 succeeds; the second buildSubmissionForSubject for the SP wallet then runs ListDIDs + // followed by filterDIDsByMethods, which returns ErrPreconditionFailed when no DID's method + // is in the AS's DIDMethodsSupported list. The default test fixture advertises method "test", + // so returning a did:web from the SP wallet exercises the filter rejection. + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDIDWrongMethod := did.MustParseDID("did:web:example.com:sp") + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDIDWrongMethod}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil) + + _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.ErrorIs(t, err, ErrPreconditionFailed) + assert.ErrorContains(t, err, "did method mismatch") + }) + + t.Run("rejects the request when the SP wallet lacks credentials matching the service_provider PD", func(t *testing.T) { + // VP1 succeeds; the SP wallet has DIDs but its BuildSubmission cannot satisfy sp_pd, so it + // returns pe.ErrNoCredentials. The error must propagate to the caller unchanged so the API + // layer can map it to a 412 Precondition Failed. + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), + pe.PresentationDefinition{Id: "sp_pd"}, gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) + + _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + assert.ErrorIs(t, err, pe.ErrNoCredentials) + }) + t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) { sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") From e01498dc2723d3d59405df8b6ca5433f3e6d9d05 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 5 May 2026 11:32:35 +0200 Subject: [PATCH 80/91] Validate service_provider_subject_id in the handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAPI field is *string; an explicit empty string would have routed into the two-VP flow with a meaningless subject and surfaced as a misleading 412 'did method mismatch' from a downstream ListDIDs("") call. Reject empty up front with a clear InvalidInputError, and check that a non-nil subject actually exists locally — same treatment as the path-param subjectID. Adds three sub-tests under "service_provider_subject_id": - ok - threads through to IAM client (also tightened to assert the response body, not just NoError) - empty string is rejected up front - unknown subject returns 400 Carry-over from #4228 self-review. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/api.go | 11 +++++++ auth/api/iam/api_test.go | 69 ++++++++++++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 7289dd1cab..101c9dec92 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -728,6 +728,17 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS if err != nil { return nil, err } + // service_provider_subject_id is optional. When absent the single-VP flow runs; when present it + // selects the two-VP jwt-bearer flow. An explicit empty string is ambiguous, reject it here so + // the flow doesn't route on an unset value. + if request.Body.ServiceProviderSubjectId != nil { + if *request.Body.ServiceProviderSubjectId == "" { + return nil, core.InvalidInputError("service_provider_subject_id is optional and cannot be empty: omit the field or set a non-empty value") + } + if err := r.subjectExists(ctx, *request.Body.ServiceProviderSubjectId); err != nil { + return nil, err + } + } tokenCache := r.accessTokenCache() cacheKey := accessTokenRequestCacheKey(request) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 8a99eccab6..3ae48a2b26 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1007,22 +1007,63 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { assert.NotEqual(t, token1, token2) }) - t.Run("ok - service_provider_subject_id threads through to IAM client", func(t *testing.T) { - // Verifies the wire-up of the new OpenAPI field. The dispatch and feature gating live in - // the IAM client (under #4227); the handler's job is just to forward the value verbatim. - ctx := newTestClient(t) - spSubject := "acme-service-provider" - bodyWithSP := &RequestServiceAccessTokenJSONRequestBody{ - AuthorizationServer: verifierURL.String(), - Scope: "first second", - ServiceProviderSubjectId: &spSubject, - } - response := &oauth.TokenResponse{AccessToken: "token", TokenType: "Bearer", ExpiresIn: to.Ptr(900)} - ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, &spSubject).Return(response, nil) + t.Run("service_provider_subject_id", func(t *testing.T) { + t.Run("ok - threads through to IAM client", func(t *testing.T) { + // Verifies the wire-up of the new OpenAPI field. The dispatch and feature gating live in + // the IAM client (under #4227); the handler's job is to validate and forward the value. + ctx := newTestClient(t) + spSubject := verifierSubject // re-use the global fixture's existing Exists(true) mock + bodyWithSP := &RequestServiceAccessTokenJSONRequestBody{ + AuthorizationServer: verifierURL.String(), + Scope: "first second", + ServiceProviderSubjectId: &spSubject, + } + response := &oauth.TokenResponse{AccessToken: "sp-token", TokenType: "Bearer", ExpiresIn: to.Ptr(900)} + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, &spSubject).Return(response, nil) - _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithSP}) + tokenResponse, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithSP}) - require.NoError(t, err) + require.NoError(t, err) + // Assert the response actually round-trips, not just that the call didn't error. + assert.Equal(t, "sp-token", tokenResponse.(RequestServiceAccessToken200JSONResponse).AccessToken) + }) + t.Run("empty string is rejected up front", func(t *testing.T) { + // service_provider_subject_id is optional; an explicit "" must not silently dispatch into + // the two-VP flow with a meaningless subject. + ctx := newTestClient(t) + emptySP := "" + bodyWithEmptySP := &RequestServiceAccessTokenJSONRequestBody{ + AuthorizationServer: verifierURL.String(), + Scope: "first second", + ServiceProviderSubjectId: &emptySP, + } + + _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithEmptySP}) + + require.Error(t, err) + assert.ErrorContains(t, err, "service_provider_subject_id") + assert.ErrorContains(t, err, "cannot be empty") + }) + t.Run("unknown subject returns 400", func(t *testing.T) { + // Mirrors the existing path-param check: a non-existent service-provider subject yields a + // clear OAuth invalid_request, not the misleading 412 "did method mismatch" that would come + // from a downstream ListDIDs("") result. + ctx := newTestClient(t) + unknownSP := unknownSubjectID + bodyWithUnknownSP := &RequestServiceAccessTokenJSONRequestBody{ + AuthorizationServer: verifierURL.String(), + Scope: "first second", + ServiceProviderSubjectId: &unknownSP, + } + + _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithUnknownSP}) + + require.Error(t, err) + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.InvalidRequest, oauthErr.Code) + assert.Contains(t, oauthErr.Description, "subject not found") + }) }) t.Run("self-asserted credentials", func(t *testing.T) { response := &oauth.TokenResponse{ From dbfd43593271299818841de7bf244af0a3bef064 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 5 May 2026 11:35:15 +0200 Subject: [PATCH 81/91] Address self-review nits: docs and test pinning - Rephrase ClientConfig doc: 'all required unless noted' was inaccurate for bool/duration zero-valued scalars. Now distinguishes interface fields (required) from scalars (zero value is valid). - Add release-notes entry for the new service_provider_subject_id API field, parallel to the existing #4226 / #4227 lines. - Pin DIDMethodsSupported explicitly in the SP method-mismatch test so the assertion doesn't silently flip if the default fixture changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 5 ++++- auth/client/iam/openid4vp_test.go | 5 +++-- docs/pages/release_notes.rst | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 156f80c0d5..8abe8e463f 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -71,7 +71,10 @@ type OpenID4VPClient struct { } // ClientConfig groups the dependencies and toggles needed to construct an OpenID4VPClient. -// All fields are required unless explicitly noted. +// The interface fields (Wallet, KeyResolver, SubjectManager, JWTSigner, LDDocumentLoader, +// PolicyBackend) are required; NewClient does not validate them and missing fields will surface as +// nil-pointer panics on first use. Scalar fields default to their zero value (StrictMode=false, +// HTTPClientTimeout=0, ExperimentalJwtBearerClient=false) and the zero values are valid. type ClientConfig struct { Wallet holder.Wallet KeyResolver resolver.KeyResolver diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 0b33ed45d3..025eaebc28 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -475,8 +475,8 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { t.Run("rejects the request when the SP wallet has no DIDs matching the AS's supported methods", func(t *testing.T) { // VP1 succeeds; the second buildSubmissionForSubject for the SP wallet then runs ListDIDs // followed by filterDIDsByMethods, which returns ErrPreconditionFailed when no DID's method - // is in the AS's DIDMethodsSupported list. The default test fixture advertises method "test", - // so returning a did:web from the SP wallet exercises the filter rejection. + // is in the AS's DIDMethodsSupported list. The test pins DIDMethodsSupported explicitly so + // the assertion does not silently flip when the fixture default changes. sp := spSubjectID hcpDID := did.MustParseDID("did:test:hcp") spDIDWrongMethod := did.MustParseDID("did:web:example.com:sp") @@ -486,6 +486,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { ctx := createClientServerTestContext(t) enableJwtBearerClient(t, ctx) ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.authzServerMetadata.DIDMethodsSupported = []string{"test"} ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ CredentialProfileScope: "first", WalletOwnerMapping: pe.WalletOwnerMapping{ diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index f9003f4aa6..60dcae549f 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -10,6 +10,7 @@ Unreleased * #4063: Enable ``storage.debug`` flag to log go-leia performance issues (full table scans, suboptimal index usage) by @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4064 * #4078: Allow policy profiles to define a ``service_provider`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 * #4078: Add the experimental RFC 7523 ``jwt-bearer`` two-VP token request flow, gated behind ``auth.experimental.jwtbearerclient`` (default ``false``, subject to change) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4227 +* #4078: Expose the experimental two-VP flow on ``POST /internal/auth/v2/{subjectID}/request-service-access-token`` via the optional ``service_provider_subject_id`` body field by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4228 **************** Peanut (v6.2.1) From c9cd139a448f8497bb320b7ff21014c0d30db26d Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 5 May 2026 13:16:43 +0200 Subject: [PATCH 82/91] Document the experimental two-VP flow and cross-VP binding in policy docs Adds a new top-level section to docs/pages/deployment/policy.rst that explains, for policy authors and node operators: - when the two-VP RFC 7523 jwt-bearer flow triggers (all four conditions: experimental flag, service_provider_subject_id body field, AS metadata advertises jwt-bearer, profile has a service_provider PD) - how VP1 (organization) and VP2 (service_provider) are built and which subject's wallet/PD each uses - cross-VP binding via shared field.id, with a worked example showing a delegating_hcp constraint in both PDs that ties VP2's delegation credential to VP1's HCP issuer - the credential_selection map and how server-captured entries are additively merged with EHR-supplied ones - the four-step required configuration The section is prefaced by a warning that the entire mechanism is experimental and subject to change while the underlying OAuth profile stabilises. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/pages/deployment/policy.rst | 97 +++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index 7285963463..453619370c 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -130,4 +130,99 @@ You can define the following field in the input descriptor constraint, to have t } } -Only 1 capture group is supported in regular expressions. If multiple capture groups are defined, an error will be returned. \ No newline at end of file +Only 1 capture group is supported in regular expressions. If multiple capture groups are defined, an error will be returned. + +Two-VP flow and cross-VP binding (experimental) +*********************************************** + +.. warning:: + The two-VP flow is **experimental** and gated behind ``auth.experimental.jwt_bearer_client = true`` (default ``false``). + The ``service_provider`` PD block, the ``service_provider_subject_id`` API field, and the cross-VP binding mechanism described below are subject to change without notice while the underlying OAuth profile stabilises. + +When the two-VP flow runs +^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default the node uses a single-VP token request (RFC 021 ``vp_token-bearer``). The two-VP RFC 7523 ``jwt-bearer`` flow runs only when **all** of the following hold: + +1. The experimental flag ``auth.experimental.jwt_bearer_client`` is ``true`` on the EHR-side node. +2. The EHR caller passes ``service_provider_subject_id`` in the body of ``POST /internal/auth/v2/{subjectID}/request-service-access-token``. +3. The remote authorization server advertises ``urn:ietf:params:oauth:grant-type:jwt-bearer`` in its metadata's ``grant_types_supported``. +4. The credential profile referenced by the request scope has a ``service_provider`` PD configured. + +If conditions (2)-(4) are not all met when ``service_provider_subject_id`` is supplied, the request fails with a clear error rather than silently falling back to the single-VP flow. + +How the two VPs are built +^^^^^^^^^^^^^^^^^^^^^^^^^ + +- **VP1 (organization)** — built from the wallet of the path-param ``subjectID`` (the healthcare provider, HCP), using the credential profile's ``organization`` PD. Sent as the ``assertion`` form parameter (RFC 7521 §4.1, the authorization grant). +- **VP2 (service_provider)** — built from the wallet of ``service_provider_subject_id`` (the OAuth client, the service provider acting on behalf of the HCP), using the credential profile's ``service_provider`` PD. Sent as the ``client_assertion`` form parameter (RFC 7521 §4.2, authenticating the client). + +Each VP is signed with the holder DID's keys from the respective wallet. + +Cross-VP binding via shared ``field.id`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Constraint fields with the same ``id`` across the two PDs implicitly bind a value captured from VP1 into the credential selection for VP2. This lets policy authors express delegation requirements (e.g. *"VP2's delegation credential must be issued by the DID that signed VP1"*) without writing custom matcher code; the binding is realised through standard Presentation Exchange constructs. + +Example: a profile that requires VP2 to include a ``ServiceProviderDelegationCredential`` issued by the same DID as VP1's ``HealthcareProviderCredential``: + +.. code-block:: json + + { + "example_delegated_scope": { + "organization": { + "id": "org_pd", + "input_descriptors": [{ + "id": "hcp_credential", + "constraints": { + "fields": [ + { "path": ["$.type"], "filter": { "type": "string", "const": "HealthcareProviderCredential" } }, + { "id": "delegating_hcp", "path": ["$.issuer"] } + ] + } + }] + }, + "service_provider": { + "id": "sp_pd", + "input_descriptors": [{ + "id": "delegation_credential", + "constraints": { + "fields": [ + { "path": ["$.type"], "filter": { "type": "string", "const": "ServiceProviderDelegationCredential" } }, + { "id": "delegating_hcp", "path": ["$.issuer"] } + ] + } + }] + } + } + } + +How it works at request time: + +1. VP1 is built. The matcher records the value at ``$.issuer`` of the credential that satisfied ``hcp_credential`` and labels it with the field id ``delegating_hcp``. +2. Before VP2 is built, that captured value is additively merged into the ``credential_selection`` map (see below). +3. When VP2 is selected against ``sp_pd``, the wallet only considers ``ServiceProviderDelegationCredential`` candidates whose ``$.issuer`` equals the captured value — i.e. credentials delegated by the exact HCP that signed VP1. + +If the SP wallet has no delegation credential issued by VP1's HCP, the request returns ``pe.ErrNoCredentials`` (HTTP 412 Precondition Failed) and the EHR can show a clear "no delegation on file" error to the user. + +The ``credential_selection`` map +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``credential_selection`` is a key-value map (string keys → string values) used to disambiguate between credentials when multiple satisfy a single input descriptor. Keys must match a constraint field ``id``; values are the literal string the field should equal for selection. + +There are two sources of entries: + +- **EHR-supplied** — the ``credential_selection`` field on the request body. EHRs typically use this for runtime context like a patient or encounter identifier (e.g. ``patient_id``). +- **Server-captured (two-VP only)** — entries populated automatically from VP1's matched constraint fields, as described above. The capture is *additive*: any EHR-supplied key always wins, and only string-typed captured values are merged in. + +The same map is consulted by both single-VP and two-VP flows; the only difference is that the two-VP flow may add entries between VP1 and VP2. + +Required configuration +^^^^^^^^^^^^^^^^^^^^^^ + +To enable the two-VP flow on a node: + +1. Set ``auth.experimental.jwt_bearer_client: true`` in the node config (off by default). +2. Provision the service-provider Nuts subject and its wallet via the existing wallet APIs. Its wallet must hold credentials matching the ``service_provider`` PD for any profile that should support the flow. +3. Add a ``service_provider`` PD block to each credential profile that should support the flow. +4. Have the EHR pass ``service_provider_subject_id`` on the access-token request body. \ No newline at end of file From 7db8c21aec54604dbd2d0acafdc5222c9301c04c Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 8 May 2026 07:30:38 +0200 Subject: [PATCH 83/91] Update doc references to the renamed jwtbearerclient flag Follow-up to the rename in 1c932255 (#4227): the OpenAPI description for service_provider_subject_id and the new policy.rst section both still referenced the old auth.experimental.jwt_bearer_client name. Regenerated bindings. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/generated.go | 2 +- docs/_static/auth/v2.yaml | 2 +- docs/pages/deployment/policy.rst | 6 +++--- e2e-tests/browser/client/iam/generated.go | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index c48663bc20..d1c258a9bd 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -165,7 +165,7 @@ type ServiceAccessTokenRequest struct { // When present, the node uses the RFC 7523 jwt-bearer two-VP token request flow: // VP1 is built from the wallet identified by the path-param `subjectID` (the healthcare // provider) using the `organization` PD; VP2 is built from the wallet identified here - // using the `service_provider` PD. Requires `auth.experimental.jwt_bearer_client = true`, + // using the `service_provider` PD. Requires `auth.experimental.jwtbearerclient = true`, // an authorization server that advertises `urn:ietf:params:oauth:grant-type:jwt-bearer`, // and a `service_provider` PD configured for the requested credential profile. // diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 1dd5616331..c2d2d878e3 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -522,7 +522,7 @@ components: When present, the node uses the RFC 7523 jwt-bearer two-VP token request flow: VP1 is built from the wallet identified by the path-param `subjectID` (the healthcare provider) using the `organization` PD; VP2 is built from the wallet identified here - using the `service_provider` PD. Requires `auth.experimental.jwt_bearer_client = true`, + using the `service_provider` PD. Requires `auth.experimental.jwtbearerclient = true`, an authorization server that advertises `urn:ietf:params:oauth:grant-type:jwt-bearer`, and a `service_provider` PD configured for the requested credential profile. diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index 453619370c..a50875a0bd 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -136,7 +136,7 @@ Two-VP flow and cross-VP binding (experimental) *********************************************** .. warning:: - The two-VP flow is **experimental** and gated behind ``auth.experimental.jwt_bearer_client = true`` (default ``false``). + The two-VP flow is **experimental** and gated behind ``auth.experimental.jwtbearerclient = true`` (default ``false``). The ``service_provider`` PD block, the ``service_provider_subject_id`` API field, and the cross-VP binding mechanism described below are subject to change without notice while the underlying OAuth profile stabilises. When the two-VP flow runs @@ -144,7 +144,7 @@ When the two-VP flow runs By default the node uses a single-VP token request (RFC 021 ``vp_token-bearer``). The two-VP RFC 7523 ``jwt-bearer`` flow runs only when **all** of the following hold: -1. The experimental flag ``auth.experimental.jwt_bearer_client`` is ``true`` on the EHR-side node. +1. The experimental flag ``auth.experimental.jwtbearerclient`` is ``true`` on the EHR-side node. 2. The EHR caller passes ``service_provider_subject_id`` in the body of ``POST /internal/auth/v2/{subjectID}/request-service-access-token``. 3. The remote authorization server advertises ``urn:ietf:params:oauth:grant-type:jwt-bearer`` in its metadata's ``grant_types_supported``. 4. The credential profile referenced by the request scope has a ``service_provider`` PD configured. @@ -222,7 +222,7 @@ Required configuration To enable the two-VP flow on a node: -1. Set ``auth.experimental.jwt_bearer_client: true`` in the node config (off by default). +1. Set ``auth.experimental.jwtbearerclient: true`` in the node config (off by default). 2. Provision the service-provider Nuts subject and its wallet via the existing wallet APIs. Its wallet must hold credentials matching the ``service_provider`` PD for any profile that should support the flow. 3. Add a ``service_provider`` PD block to each credential profile that should support the flow. 4. Have the EHR pass ``service_provider_subject_id`` on the access-token request body. \ No newline at end of file diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index c93cb3ddae..ebd70e9893 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -159,7 +159,7 @@ type ServiceAccessTokenRequest struct { // When present, the node uses the RFC 7523 jwt-bearer two-VP token request flow: // VP1 is built from the wallet identified by the path-param `subjectID` (the healthcare // provider) using the `organization` PD; VP2 is built from the wallet identified here - // using the `service_provider` PD. Requires `auth.experimental.jwt_bearer_client = true`, + // using the `service_provider` PD. Requires `auth.experimental.jwtbearerclient = true`, // an authorization server that advertises `urn:ietf:params:oauth:grant-type:jwt-bearer`, // and a `service_provider` PD configured for the requested credential profile. // From c6d75920b02a7adadbbff0849d59d37685c1e585 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:54:31 +0200 Subject: [PATCH 84/91] Initialize branch for PR From 83afb0a16853d8e8ad3a8af99ffe5b6bde26a1ab Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:55:30 +0200 Subject: [PATCH 85/91] Initialize branch for PR From 6b711e9305a87bff6efc1b2d4ea54cd563eb127f Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:54:31 +0200 Subject: [PATCH 86/91] Initialize branch for PR From e88e79c6712a6b7ae0c69482fbc4ba52276ea59d Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:55:30 +0200 Subject: [PATCH 87/91] Initialize branch for PR From de07e6a896d11858f7f30e8f4d4373e2d9f8206e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:55:55 +0200 Subject: [PATCH 88/91] Initialize branch for PR From 72023b82b0937ff2b6fa7c8540b85fdfe46a67be Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 8 May 2026 09:51:52 +0200 Subject: [PATCH 89/91] Add jwt-bearer two-VP happy-path integration test Boots a real Nuts node via test/node/StartServer (with the experimental flag on, did:web, and a custom policy directory), provisions four subjects (CIBG / Twiin issuers + HCP organization + service provider) over the internal HTTP API, issues the three credentials needed (HealthcareProviderCredential, ServiceProviderCredential, ServiceProviderDelegationCredential), stands up a mock authorization server via httptest that advertises jwt-bearer and captures the form POST, drives the request-service-access-token endpoint, and asserts: - form body matches the RFC 7523 wire shape (grant_type, client_assertion_type, scope; assertion+client_assertion non-empty; no presentation_submission, no client_id) - both captured VPs round-trip through the same node's /internal/vcr/v2/verifier/vp with validity=true - the cross-VP binding survives end-to-end: the delegation credential's issuer equals VP1's signer DID, and its delegatedBy URA equals VP1's HCP URA Negative paths (feature flag off, AS doesn't advertise jwt-bearer, missing service_provider PD, SP wallet has no matching credentials) are covered by unit tests in auth/client/iam/openid4vp_test.go and the handler tests in auth/api/iam/api_test.go; this integration test focuses on the happy-path round trip that those mock-based tests cannot cover (real cryptographic signing, real DID resolution, real verifyVP). Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/jwtbearer_integration_test.go | 477 +++++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 auth/api/iam/jwtbearer_integration_test.go diff --git a/auth/api/iam/jwtbearer_integration_test.go b/auth/api/iam/jwtbearer_integration_test.go new file mode 100644 index 0000000000..8453cca873 --- /dev/null +++ b/auth/api/iam/jwtbearer_integration_test.go @@ -0,0 +1,477 @@ +/* + * 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_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/test/node" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_JwtBearer_TwoVPHappyPath boots a real Nuts node, provisions four subjects +// (CIBG issuer, Twiin issuer, HCP organization, service provider), issues the three credentials +// needed to satisfy the medication-overview profile (HealthcareProviderCredential, +// ServiceProviderCredential, ServiceProviderDelegationCredential), drives the API endpoint, and +// asserts the captured token-request form body matches the RFC 7523 jwt-bearer wire format and +// both VPs verify cleanly through the same node's /internal/vcr/v2/verifier/vp endpoint. +// +// Negative paths (feature flag off, AS doesn't advertise jwt-bearer, missing service_provider PD, +// SP wallet has no matching credentials) are covered by the unit tests in +// auth/client/iam/openid4vp_test.go and the handler tests in auth/api/iam/api_test.go; this +// integration test focuses on the happy-path round trip that those mock-based tests cannot +// cover: real cryptographic signing, real DID resolution, and real verifyVP. +func TestIntegration_JwtBearer_TwoVPHappyPath(t *testing.T) { + policyDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(policyDir, "medication-overview.json"), []byte(medicationOverviewPolicy), 0o644)) + + // Mock authorization server: serves AS metadata advertising jwt-bearer support and captures + // the form body POSTed to /token. Set up before the node so we can include its URL in the + // node config (not strictly required since metadata fetch is from the AS itself, but keeps + // the wiring obvious). + asMock := newMockAS(t) + defer asMock.server.Close() + + internalURL, _, _ := node.StartServer(t, func(_, _ string) { + t.Setenv("NUTS_AUTH_EXPERIMENTAL_JWTBEARERCLIENT", "true") + t.Setenv("NUTS_DIDMETHODS", "web") + t.Setenv("NUTS_POLICY_DIRECTORY", policyDir) + }) + + const ( + cibgSubject = "cibg-issuer" + twiinSubject = "twiin-issuer" + orgSubject = "org1" + spSubject = "sp1" + ura = "78551223" + ) + + cibgDID := provisionSubject(t, internalURL, cibgSubject) + twiinDID := provisionSubject(t, internalURL, twiinSubject) + orgDID := provisionSubject(t, internalURL, orgSubject) + spDID := provisionSubject(t, internalURL, spSubject) + + // HCP credential: CIBG → org. Asserts the org is a healthcare provider with the given URA. + issueAndLoad(t, internalURL, orgSubject, buildHCPCredential(cibgDID, orgDID, ura)) + // SP credential: Twiin → sp. Required by the service_provider PD's first input descriptor. + issueAndLoad(t, internalURL, spSubject, buildSPCredential(twiinDID, spDID)) + // Delegation credential: org → sp. The cross-VP binding ties this credential's `issuer` + // (orgDID) and its delegatedBy URA back to VP1's HCP credential. + issueAndLoad(t, internalURL, spSubject, buildDelegationCredential(orgDID, spDID, ura)) + + // Drive the API. + body := map[string]any{ + "authorization_server": asMock.server.URL + "/oauth2/" + spSubject, + "scope": "medication-overview", + "service_provider_subject_id": spSubject, + "token_type": "Bearer", + } + bodyBytes, _ := json.Marshal(body) + resp, err := http.Post(internalURL+"/internal/auth/v2/"+orgSubject+"/request-service-access-token", + "application/json", bytes.NewReader(bodyBytes)) + require.NoError(t, err) + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode, "request-service-access-token failed: %s", respBody) + + // Wire-format assertions on the captured form body. + form := asMock.lastForm() + assert.Equal(t, oauth.JwtBearerGrantType, form.Get("grant_type"), "grant_type") + assert.Equal(t, oauth.JwtBearerClientAssertionType, form.Get("client_assertion_type"), "client_assertion_type") + assert.Equal(t, "medication-overview", form.Get("scope"), "scope") + assert.NotEmpty(t, form.Get("assertion"), "assertion (VP1)") + assert.NotEmpty(t, form.Get("client_assertion"), "client_assertion (VP2)") + assert.Empty(t, form.Get("presentation_submission"), "presentation_submission must not be set per RFC 7523") + assert.Empty(t, form.Get("client_id"), "client_id must not be set on jwt-bearer (RFC 7521 §4.2)") + + // Round-trip both VPs through the same node's verifier. + hcpVP := verifyVP(t, internalURL, form.Get("assertion")) + delegationVP := verifyVP(t, internalURL, form.Get("client_assertion")) + + // Cross-VP binding survived end-to-end: the delegation credential's issuer equals the + // organization's DID (the same DID that signed VP1), and its delegatedBy URA equals the + // HCP credential's URA. + delegationCred := pluckCredentialByType(t, delegationVP, "ServiceProviderDelegationCredential") + assert.Equal(t, orgDID, jsonString(t, delegationCred, "issuer"), + "delegation credential issuer must equal VP1 signer (cross-VP binding on $.issuer == $.credentialSubject.id)") + assert.Equal(t, ura, deepString(t, delegationCred, "credentialSubject", 0, "hasDelegation", "delegatedBy", "identifier", 0, "value"), + "delegation delegatedBy URA must equal VP1 HCP URA (cross-VP binding on URA)") + + // Sanity: VP1 (organization VP) carried the HCP credential. + hcpCred := pluckCredentialByType(t, hcpVP, "HealthcareProviderCredential") + assert.Equal(t, ura, deepString(t, hcpCred, "credentialSubject", 0, "identifier", 0, "value"), + "HCP credential carries the expected URA") +} + +// medicationOverviewPolicy defines an organization PD on HealthcareProviderCredential and a +// service_provider PD with two input descriptors (ServiceProviderCredential and +// ServiceProviderDelegationCredential). The two PDs share two field IDs that realise the +// cross-VP binding: +// +// - delegating_hcp: HCP cred's $.credentialSubject.id ↔ Delegation cred's $.issuer +// - delegating_hcp_ura: HCP cred's URA value ↔ Delegation cred's delegatedBy URA +// +// Both must equal across the two VPs for the SP wallet's submission to satisfy the PD. +const medicationOverviewPolicy = `{ + "medication-overview": { + "organization": { + "id": "pd_org", + "format": { + "jwt_vc": {"alg": ["ES256", "PS256", "RS256"]}, + "jwt_vp": {"alg": ["ES256", "PS256", "RS256"]} + }, + "input_descriptors": [{ + "id": "id_hcp", + "constraints": { + "fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "HealthcareProviderCredential"}}, + {"id": "delegating_hcp", "path": ["$.credentialSubject[0].id", "$.credentialSubject.id"], "filter": {"type": "string"}}, + {"id": "delegating_hcp_ura", "path": ["$.credentialSubject[0].identifier[*].value", "$.credentialSubject.identifier[*].value"], "filter": {"type": "string"}} + ] + } + }] + }, + "service_provider": { + "id": "pd_sp", + "format": { + "jwt_vc": {"alg": ["ES256", "PS256", "RS256"]}, + "jwt_vp": {"alg": ["ES256", "PS256", "RS256"]} + }, + "input_descriptors": [ + { + "id": "id_sp", + "constraints": { + "fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ServiceProviderCredential"}} + ] + } + }, + { + "id": "id_sp_delegation", + "constraints": { + "fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ServiceProviderDelegationCredential"}}, + {"id": "delegating_hcp", "path": ["$.issuer"], "filter": {"type": "string"}}, + {"id": "delegating_hcp_ura", "path": ["$.credentialSubject[0].hasDelegation.delegatedBy.identifier[*].value", "$.credentialSubject.hasDelegation.delegatedBy.identifier[*].value"], "filter": {"type": "string"}} + ] + } + } + ] + } + } +}` + +// provisionSubject creates a Nuts subject if it doesn't already exist and returns its first +// did:web. Idempotent across the test run. +func provisionSubject(t *testing.T, internalURL, subjectID string) string { + t.Helper() + resp, err := http.Get(internalURL + "/internal/vdr/v2/subject/" + subjectID) + require.NoError(t, err) + if resp.StatusCode == http.StatusOK { + var dids []string + require.NoError(t, json.NewDecoder(resp.Body).Decode(&dids)) + resp.Body.Close() + for _, d := range dids { + if strings.HasPrefix(d, "did:web:") { + return d + } + } + t.Fatalf("subject %s exists but has no did:web", subjectID) + } + resp.Body.Close() + body, _ := json.Marshal(map[string]string{"subject": subjectID}) + resp, err = http.Post(internalURL+"/internal/vdr/v2/subject", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "create subject %s", subjectID) + var created struct { + Documents []struct { + ID string `json:"id"` + } `json:"documents"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) + require.NotEmpty(t, created.Documents, "subject creation returned no DID documents") + return created.Documents[0].ID +} + +// issueAndLoad issues a credential against /internal/vcr/v2/issuer/vc and loads it into the +// holder wallet under the given subject. Both calls are routed through the same node. +func issueAndLoad(t *testing.T, internalURL, holderSubject string, body []byte) { + t.Helper() + resp, err := http.Post(internalURL+"/internal/vcr/v2/issuer/vc", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode, "issuer/vc failed: %s", respBody) + loadResp, err := http.Post(internalURL+"/internal/vcr/v2/holder/"+holderSubject+"/vc", "application/json", bytes.NewReader(respBody)) + require.NoError(t, err) + defer loadResp.Body.Close() + loadBody, _ := io.ReadAll(loadResp.Body) + require.True(t, loadResp.StatusCode >= 200 && loadResp.StatusCode < 300, + "holder/%s/vc returned %d: %s", holderSubject, loadResp.StatusCode, loadBody) +} + +func buildHCPCredential(issuerDID, subjectDID, ura string) []byte { + body, _ := json.Marshal(map[string]any{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential", "HealthcareProviderCredential"}, + "issuer": issuerDID, + // expirationDate is required when withStatusList2021Revocation is not set; the + // statuslist machinery would add network round-trips we don't need. + "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + "credentialSubject": map[string]any{ + "id": subjectDID, + "type": "HealthcareProvider", + "identifier": []map[string]any{ + {"system": "http://fhir.nl/fhir/NamingSystem/ura", "value": ura}, + }, + "name": "Test HCP", + }, + "format": "jwt_vc", + }) + return body +} + +func buildSPCredential(issuerDID, subjectDID string) []byte { + body, _ := json.Marshal(map[string]any{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential", "ServiceProviderCredential"}, + "issuer": issuerDID, + // expirationDate is required when withStatusList2021Revocation is not set; the + // statuslist machinery would add network round-trips we don't need. + "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + "credentialSubject": map[string]any{ + "id": subjectDID, + "type": "ServiceProvider", + "name": "Test SP", + }, + "format": "jwt_vc", + }) + return body +} + +func buildDelegationCredential(issuerDID, subjectDID, ura string) []byte { + body, _ := json.Marshal(map[string]any{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential", "ServiceProviderDelegationCredential"}, + "issuer": issuerDID, + // expirationDate is required when withStatusList2021Revocation is not set; the + // statuslist machinery would add network round-trips we don't need. + "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + "credentialSubject": map[string]any{ + "id": subjectDID, + "type": "ServiceProvider", + "hasDelegation": map[string]any{ + "type": "Delegation", + "delegatedBy": map[string]any{ + "type": "HealthcareProvider", + "identifier": []map[string]any{ + {"system": "http://fhir.nl/fhir/NamingSystem/ura", "value": ura}, + }, + }, + }, + }, + "format": "jwt_vc", + }) + return body +} + +// mockAS is the httptest authorization server. It advertises jwt-bearer support and did:web in +// its metadata, captures the form body posted to /token, and returns a canned token response. +type mockAS struct { + server *httptest.Server + captured atomic.Pointer[url.Values] +} + +func newMockAS(t *testing.T) *mockAS { + t.Helper() + m := &mockAS{} + mux := http.NewServeMux() + // RFC 8414: for issuer `/oauth2/`, the discovery URL is + // `/.well-known/oauth-authorization-server/oauth2/` (well-known is right after the host, + // not at the end of the path). + mux.HandleFunc("/.well-known/oauth-authorization-server/", func(w http.ResponseWriter, r *http.Request) { + issuerPath := strings.TrimPrefix(r.URL.Path, "/.well-known/oauth-authorization-server") + issuer := m.server.URL + issuerPath + meta := map[string]any{ + "issuer": issuer, + "token_endpoint": issuer + "/token", + "grant_types_supported": []string{oauth.JwtBearerGrantType}, + "did_methods_supported": []string{"web"}, + "vp_formats_supported": map[string]any{"jwt_vp_json": map[string]any{"alg_values_supported": []string{"ES256"}}, "jwt_vc_json": map[string]any{"alg_values_supported": []string{"ES256"}}}, + "response_types_supported": []string{"code"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meta) + }) + mux.HandleFunc("/oauth2/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/token") { + require.NoError(t, r.ParseForm()) + form := r.PostForm + m.captured.Store(&form) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "Bearer", "expires_in": 900}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + m.server = httptest.NewServer(mux) + return m +} + +func (m *mockAS) lastForm() url.Values { + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if f := m.captured.Load(); f != nil { + return *f + } + time.Sleep(20 * time.Millisecond) + } + return nil +} + +// verifyVP submits a JWT-VP to /internal/vcr/v2/verifier/vp and returns the parsed envelope. The +// validity field must be true; any failure aborts the test. +func verifyVP(t *testing.T, internalURL, vpJWT string) map[string]any { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "verifiablePresentation": vpJWT, + }) + resp, err := http.Post(internalURL+"/internal/vcr/v2/verifier/vp", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode, "verifier/vp returned %d: %s", resp.StatusCode, respBody) + var result map[string]any + require.NoError(t, json.Unmarshal(respBody, &result)) + require.True(t, jsonBool(t, result, "validity"), "verifier/vp returned validity=false: %s", respBody) + return result +} + +// pluckCredentialByType returns the first verifiable credential in the verifier response whose +// type list contains the requested type. The verifier's "credentials" array contains either +// parsed JSON objects (ldp_vc) or JWT strings (jwt_vc); for the latter we decode the payload to +// inspect the vc claim. Returns the parsed credential payload (the inner object for JWT VCs, or +// the entry itself for JSON-LD VCs). +func pluckCredentialByType(t *testing.T, vp map[string]any, credType string) map[string]any { + t.Helper() + creds, ok := vp["credentials"].([]any) + require.True(t, ok, "verifier/vp response missing 'credentials' array") + for _, c := range creds { + var cm map[string]any + switch v := c.(type) { + case map[string]any: + cm = v + case string: + cm = decodeJWTVCPayload(t, v) + default: + t.Fatalf("unsupported credential element type %T", c) + } + types, _ := cm["type"].([]any) + for _, ty := range types { + if s, _ := ty.(string); s == credType { + return cm + } + } + } + t.Fatalf("no credential of type %q in verifier response", credType) + return nil +} + +// decodeJWTVCPayload decodes a JWT-encoded VerifiableCredential and returns the inner `vc` claim +// merged with the outer `iss` (-> issuer) and `sub` (-> credentialSubject.id) claims, matching the +// shape that ldp_vc credentials would return directly. Used to make assertions credential-format +// agnostic in the integration test. +func decodeJWTVCPayload(t *testing.T, jwtStr string) map[string]any { + t.Helper() + parts := strings.Split(jwtStr, ".") + require.Len(t, parts, 3, "expected JWT with 3 parts, got %d", len(parts)) + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err, "decode JWT payload") + var claims map[string]any + require.NoError(t, json.Unmarshal(payloadBytes, &claims), "unmarshal JWT claims") + vc, ok := claims["vc"].(map[string]any) + require.True(t, ok, "JWT VC payload missing 'vc' claim") + // Lift the JWT's iss into the VC's issuer field so callers can assert on it without caring + // whether the credential is JWT-encoded or JSON-LD. + if iss, ok := claims["iss"].(string); ok { + if _, set := vc["issuer"]; !set { + vc["issuer"] = iss + } + } + return vc +} + +func jsonBool(t *testing.T, m map[string]any, key string) bool { + t.Helper() + v, ok := m[key] + require.True(t, ok, "key %q missing from response", key) + b, ok := v.(bool) + require.True(t, ok, "key %q is not bool: %T", key, v) + return b +} + +func jsonString(t *testing.T, m map[string]any, key string) string { + t.Helper() + v, ok := m[key] + require.True(t, ok, "key %q missing", key) + s, ok := v.(string) + require.True(t, ok, "key %q is not string: %T", key, v) + return s +} + +// deepString walks the JSON value through the given keys (string for object keys, int for array +// indices) and returns the leaf as a string. Useful for asserting on nested credential subject +// structures returned by the verifier. +func deepString(t *testing.T, m any, keys ...any) string { + t.Helper() + for i, k := range keys { + switch key := k.(type) { + case string: + obj, ok := m.(map[string]any) + require.True(t, ok, "step %d (%v): expected object, got %T", i, key, m) + m, ok = obj[key] + require.True(t, ok, "step %d (%v): key not found", i, key) + case int: + arr, ok := m.([]any) + require.True(t, ok, "step %d (%v): expected array, got %T", i, key, m) + require.Less(t, key, len(arr), "step %d (%v): index out of range (len=%d)", i, key, len(arr)) + m = arr[key] + default: + t.Fatalf("step %d: unsupported key type %T", i, key) + } + } + s, ok := m.(string) + require.True(t, ok, "leaf is not string: %T (%v)", m, m) + return s +} From d4223936ff98020e94909b4335beddced84a65d6 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 12 May 2026 10:59:39 +0200 Subject: [PATCH 90/91] test(iam): tighten jwt-bearer two-VP integration test Drop the polling lastForm() helper: the AS /token POST completes synchronously inside request-service-access-token, so the captured form is set by the time the API returns 200. capturedForm now fails the test loudly if /token was never hit, instead of returning an empty url.Values that would make wire-format asserts misleading. Propagate ParseForm errors via an atomic so a malformed token-request form fails the test instead of silently 400-ing the handler. Replace the generic deepString walker with typed firstSubject and firstIdentifierValue helpers that encode the actual credential shape. Drop the idempotent GET-then-POST in provisionSubject; the test runs against a fresh tempdir node, so the lookup branch was dead code. Assisted-by: AI --- auth/api/iam/jwtbearer_integration_test.go | 138 ++++++++++----------- 1 file changed, 66 insertions(+), 72 deletions(-) diff --git a/auth/api/iam/jwtbearer_integration_test.go b/auth/api/iam/jwtbearer_integration_test.go index 8453cca873..e0c89ff15a 100644 --- a/auth/api/iam/jwtbearer_integration_test.go +++ b/auth/api/iam/jwtbearer_integration_test.go @@ -60,7 +60,6 @@ func TestIntegration_JwtBearer_TwoVPHappyPath(t *testing.T) { // node config (not strictly required since metadata fetch is from the AS itself, but keeps // the wiring obvious). asMock := newMockAS(t) - defer asMock.server.Close() internalURL, _, _ := node.StartServer(t, func(_, _ string) { t.Setenv("NUTS_AUTH_EXPERIMENTAL_JWTBEARERCLIENT", "true") @@ -105,7 +104,7 @@ func TestIntegration_JwtBearer_TwoVPHappyPath(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode, "request-service-access-token failed: %s", respBody) // Wire-format assertions on the captured form body. - form := asMock.lastForm() + form := asMock.capturedForm(t) assert.Equal(t, oauth.JwtBearerGrantType, form.Get("grant_type"), "grant_type") assert.Equal(t, oauth.JwtBearerClientAssertionType, form.Get("client_assertion_type"), "client_assertion_type") assert.Equal(t, "medication-overview", form.Get("scope"), "scope") @@ -124,12 +123,13 @@ func TestIntegration_JwtBearer_TwoVPHappyPath(t *testing.T) { delegationCred := pluckCredentialByType(t, delegationVP, "ServiceProviderDelegationCredential") assert.Equal(t, orgDID, jsonString(t, delegationCred, "issuer"), "delegation credential issuer must equal VP1 signer (cross-VP binding on $.issuer == $.credentialSubject.id)") - assert.Equal(t, ura, deepString(t, delegationCred, "credentialSubject", 0, "hasDelegation", "delegatedBy", "identifier", 0, "value"), + delegatedBy, _ := firstSubject(t, delegationCred)["hasDelegation"].(map[string]any)["delegatedBy"].(map[string]any) + assert.Equal(t, ura, firstIdentifierValue(t, delegatedBy), "delegation delegatedBy URA must equal VP1 HCP URA (cross-VP binding on URA)") // Sanity: VP1 (organization VP) carried the HCP credential. hcpCred := pluckCredentialByType(t, hcpVP, "HealthcareProviderCredential") - assert.Equal(t, ura, deepString(t, hcpCred, "credentialSubject", 0, "identifier", 0, "value"), + assert.Equal(t, ura, firstIdentifierValue(t, firstSubject(t, hcpCred)), "HCP credential carries the expected URA") } @@ -191,26 +191,12 @@ const medicationOverviewPolicy = `{ } }` -// provisionSubject creates a Nuts subject if it doesn't already exist and returns its first -// did:web. Idempotent across the test run. +// provisionSubject creates a Nuts subject and returns its first did:web. The test runs against a +// fresh tempdir-backed node, so this is single-shot creation rather than idempotent lookup. func provisionSubject(t *testing.T, internalURL, subjectID string) string { t.Helper() - resp, err := http.Get(internalURL + "/internal/vdr/v2/subject/" + subjectID) - require.NoError(t, err) - if resp.StatusCode == http.StatusOK { - var dids []string - require.NoError(t, json.NewDecoder(resp.Body).Decode(&dids)) - resp.Body.Close() - for _, d := range dids { - if strings.HasPrefix(d, "did:web:") { - return d - } - } - t.Fatalf("subject %s exists but has no did:web", subjectID) - } - resp.Body.Close() body, _ := json.Marshal(map[string]string{"subject": subjectID}) - resp, err = http.Post(internalURL+"/internal/vdr/v2/subject", "application/json", bytes.NewReader(body)) + resp, err := http.Post(internalURL+"/internal/vdr/v2/subject", "application/json", bytes.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode, "create subject %s", subjectID) @@ -226,6 +212,15 @@ func provisionSubject(t *testing.T, internalURL, subjectID string) string { // issueAndLoad issues a credential against /internal/vcr/v2/issuer/vc and loads it into the // holder wallet under the given subject. Both calls are routed through the same node. +// +// expirationDate on the credential body is required when status-list revocation is not set; +// the statuslist machinery would add network round-trips we don't need in tests. +// +// Format key conventions in this file (three different specs in play): +// - issuer-API request body uses "format": "jwt_vc" (vcr v2 issuer API) +// - PE policy "format" object uses "jwt_vc" / "jwt_vp" (Presentation Exchange schema) +// - AS metadata "vp_formats_supported" uses "jwt_vc_json" +// and "jwt_vp_json" (OAuth 2.0 metadata) func issueAndLoad(t *testing.T, internalURL, holderSubject string, body []byte) { t.Helper() resp, err := http.Post(internalURL+"/internal/vcr/v2/issuer/vc", "application/json", bytes.NewReader(body)) @@ -246,8 +241,6 @@ func buildHCPCredential(issuerDID, subjectDID, ura string) []byte { "@context": []string{"https://www.w3.org/2018/credentials/v1"}, "type": []string{"VerifiableCredential", "HealthcareProviderCredential"}, "issuer": issuerDID, - // expirationDate is required when withStatusList2021Revocation is not set; the - // statuslist machinery would add network round-trips we don't need. "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "credentialSubject": map[string]any{ "id": subjectDID, @@ -267,8 +260,6 @@ func buildSPCredential(issuerDID, subjectDID string) []byte { "@context": []string{"https://www.w3.org/2018/credentials/v1"}, "type": []string{"VerifiableCredential", "ServiceProviderCredential"}, "issuer": issuerDID, - // expirationDate is required when withStatusList2021Revocation is not set; the - // statuslist machinery would add network round-trips we don't need. "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "credentialSubject": map[string]any{ "id": subjectDID, @@ -285,8 +276,6 @@ func buildDelegationCredential(issuerDID, subjectDID, ura string) []byte { "@context": []string{"https://www.w3.org/2018/credentials/v1"}, "type": []string{"VerifiableCredential", "ServiceProviderDelegationCredential"}, "issuer": issuerDID, - // expirationDate is required when withStatusList2021Revocation is not set; the - // statuslist machinery would add network round-trips we don't need. "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "credentialSubject": map[string]any{ "id": subjectDID, @@ -308,9 +297,17 @@ func buildDelegationCredential(issuerDID, subjectDID, ura string) []byte { // mockAS is the httptest authorization server. It advertises jwt-bearer support and did:web in // its metadata, captures the form body posted to /token, and returns a canned token response. +// +// The token-request path is fully synchronous: the IAMClient's POST to /token completes inside +// the request-service-access-token call. The captured form is therefore guaranteed to be set +// once the API returns 200, so no polling is needed. type mockAS struct { server *httptest.Server captured atomic.Pointer[url.Values] + // parseErr propagates a form-parsing failure from the token handler goroutine to the main + // test goroutine; require.NoError from inside the handler would only halt the handler, not + // the test. + parseErr atomic.Pointer[error] } func newMockAS(t *testing.T) *mockAS { @@ -336,7 +333,11 @@ func newMockAS(t *testing.T) *mockAS { }) mux.HandleFunc("/oauth2/", func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/token") { - require.NoError(t, r.ParseForm()) + if err := r.ParseForm(); err != nil { + m.parseErr.Store(&err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } form := r.PostForm m.captured.Store(&form) w.Header().Set("Content-Type", "application/json") @@ -346,18 +347,22 @@ func newMockAS(t *testing.T) *mockAS { w.WriteHeader(http.StatusNotFound) }) m.server = httptest.NewServer(mux) + t.Cleanup(m.server.Close) return m } -func (m *mockAS) lastForm() url.Values { - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if f := m.captured.Load(); f != nil { - return *f - } - time.Sleep(20 * time.Millisecond) +// capturedForm returns the form posted to /token. The handler runs synchronously inside the +// request-service-access-token call, so by the time the test reads this the form is set. +// Fails the test loudly (rather than returning an empty url.Values that makes wire-format +// asserts misleading) if the token endpoint was never hit or form parsing failed. +func (m *mockAS) capturedForm(t *testing.T) url.Values { + t.Helper() + if errPtr := m.parseErr.Load(); errPtr != nil { + t.Fatalf("mock AS failed to parse token-request form: %v", *errPtr) } - return nil + form := m.captured.Load() + require.NotNil(t, form, "mock AS /token endpoint was never called — request-service-access-token must have failed before reaching the AS") + return *form } // verifyVP submits a JWT-VP to /internal/vcr/v2/verifier/vp and returns the parsed envelope. The @@ -379,24 +384,16 @@ func verifyVP(t *testing.T, internalURL, vpJWT string) map[string]any { } // pluckCredentialByType returns the first verifiable credential in the verifier response whose -// type list contains the requested type. The verifier's "credentials" array contains either -// parsed JSON objects (ldp_vc) or JWT strings (jwt_vc); for the latter we decode the payload to -// inspect the vc claim. Returns the parsed credential payload (the inner object for JWT VCs, or -// the entry itself for JSON-LD VCs). +// type list contains the requested type. The verifier returns JWT-encoded credentials for the +// jwt_vc format used in this test; we decode the payload to inspect the vc claim. func pluckCredentialByType(t *testing.T, vp map[string]any, credType string) map[string]any { t.Helper() creds, ok := vp["credentials"].([]any) require.True(t, ok, "verifier/vp response missing 'credentials' array") for _, c := range creds { - var cm map[string]any - switch v := c.(type) { - case map[string]any: - cm = v - case string: - cm = decodeJWTVCPayload(t, v) - default: - t.Fatalf("unsupported credential element type %T", c) - } + jwtStr, ok := c.(string) + require.True(t, ok, "expected JWT-encoded credential string, got %T", c) + cm := decodeJWTVCPayload(t, jwtStr) types, _ := cm["type"].([]any) for _, ty := range types { if s, _ := ty.(string); s == credType { @@ -450,28 +447,25 @@ func jsonString(t *testing.T, m map[string]any, key string) string { return s } -// deepString walks the JSON value through the given keys (string for object keys, int for array -// indices) and returns the leaf as a string. Useful for asserting on nested credential subject -// structures returned by the verifier. -func deepString(t *testing.T, m any, keys ...any) string { +// firstSubject returns credentialSubject[0] of the given credential. The verifier returns +// credentialSubject as an array of objects; the test credentials always have a single subject. +func firstSubject(t *testing.T, cred map[string]any) map[string]any { t.Helper() - for i, k := range keys { - switch key := k.(type) { - case string: - obj, ok := m.(map[string]any) - require.True(t, ok, "step %d (%v): expected object, got %T", i, key, m) - m, ok = obj[key] - require.True(t, ok, "step %d (%v): key not found", i, key) - case int: - arr, ok := m.([]any) - require.True(t, ok, "step %d (%v): expected array, got %T", i, key, m) - require.Less(t, key, len(arr), "step %d (%v): index out of range (len=%d)", i, key, len(arr)) - m = arr[key] - default: - t.Fatalf("step %d: unsupported key type %T", i, key) - } - } - s, ok := m.(string) - require.True(t, ok, "leaf is not string: %T (%v)", m, m) - return s + subjects, ok := cred["credentialSubject"].([]any) + require.True(t, ok && len(subjects) > 0, "credentialSubject is not a non-empty array: %v", cred["credentialSubject"]) + subject, ok := subjects[0].(map[string]any) + require.True(t, ok, "credentialSubject[0] is not an object: %T", subjects[0]) + return subject +} + +// firstIdentifierValue returns identifier[0].value from the given object. The HCP credential's +// subject and the delegation credential's delegatedBy both follow the +// `identifier: [{system, value}]` shape from the gis-nl context. +func firstIdentifierValue(t *testing.T, m map[string]any) string { + t.Helper() + idents, ok := m["identifier"].([]any) + require.True(t, ok && len(idents) > 0, "identifier is not a non-empty array: %v", m["identifier"]) + ident, ok := idents[0].(map[string]any) + require.True(t, ok, "identifier[0] is not an object: %T", idents[0]) + return jsonString(t, ident, "value") } From 18c55118875fd55391e338936c37213d64075dfa Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 22 May 2026 17:06:12 +0200 Subject: [PATCH 91/91] refactor(auth/client/iam): extract postTokenRequest helper, apply gofmt - Factor the DPoP signing + token POST + TokenResponse assembly out of requestVPTokenAccessToken and requestJwtBearerAccessToken into a shared postTokenRequest method. Removes the qlty-flagged duplication (48 identical lines across two locations). - Run gofmt on imports and map literal alignment to clear the two remaining qlty blocking findings. Assisted-by: AI --- auth/api/auth/v1/api_test.go | 2 +- auth/api/iam/jwtbearer_integration_test.go | 18 +++++------ auth/client/iam/openid4vp.go | 36 ++++++++-------------- auth/client/iam/openid4vp_test.go | 1 - auth/openid4vci/client.go | 2 +- 5 files changed, 23 insertions(+), 36 deletions(-) diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index c4c309869c..a4970f89ad 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -28,8 +28,8 @@ import ( pkg2 "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/contract" - "github.com/nuts-foundation/nuts-node/auth/openid4vci" oauth2 "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/dummy" "github.com/nuts-foundation/nuts-node/auth/services/oauth" diff --git a/auth/api/iam/jwtbearer_integration_test.go b/auth/api/iam/jwtbearer_integration_test.go index e0c89ff15a..51b4774da2 100644 --- a/auth/api/iam/jwtbearer_integration_test.go +++ b/auth/api/iam/jwtbearer_integration_test.go @@ -238,9 +238,9 @@ func issueAndLoad(t *testing.T, internalURL, holderSubject string, body []byte) func buildHCPCredential(issuerDID, subjectDID, ura string) []byte { body, _ := json.Marshal(map[string]any{ - "@context": []string{"https://www.w3.org/2018/credentials/v1"}, - "type": []string{"VerifiableCredential", "HealthcareProviderCredential"}, - "issuer": issuerDID, + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential", "HealthcareProviderCredential"}, + "issuer": issuerDID, "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "credentialSubject": map[string]any{ "id": subjectDID, @@ -257,9 +257,9 @@ func buildHCPCredential(issuerDID, subjectDID, ura string) []byte { func buildSPCredential(issuerDID, subjectDID string) []byte { body, _ := json.Marshal(map[string]any{ - "@context": []string{"https://www.w3.org/2018/credentials/v1"}, - "type": []string{"VerifiableCredential", "ServiceProviderCredential"}, - "issuer": issuerDID, + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential", "ServiceProviderCredential"}, + "issuer": issuerDID, "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "credentialSubject": map[string]any{ "id": subjectDID, @@ -273,9 +273,9 @@ func buildSPCredential(issuerDID, subjectDID string) []byte { func buildDelegationCredential(issuerDID, subjectDID, ura string) []byte { body, _ := json.Marshal(map[string]any{ - "@context": []string{"https://www.w3.org/2018/credentials/v1"}, - "type": []string{"VerifiableCredential", "ServiceProviderDelegationCredential"}, - "issuer": issuerDID, + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential", "ServiceProviderDelegationCredential"}, + "issuer": issuerDID, "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "credentialSubject": map[string]any{ "id": subjectDID, diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 97d11dd18a..34522f3191 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -332,27 +332,8 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) data.Set(oauth.ScopeParam, resolved.Scope) - dpopHeader, dpopKid, err := c.signDPoPHeader(ctx, useDPoP, *subjectDID, metadata.TokenEndpoint) - if err != nil { - return nil, err - } - log.Logger().Tracef("Requesting access token from '%s' for scope '%s'\n VP: %s\n Submission: %s", metadata.TokenEndpoint, scopes, assertion, string(presentationSubmission)) - token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) - if err != nil { - // the error could be a http error, we just relay it here to make use of any 400 status codes. - return nil, err - } - tokenResponse := oauth.TokenResponse{ - AccessToken: token.AccessToken, - ExpiresIn: token.ExpiresIn, - TokenType: token.TokenType, - Scope: &resolved.Scope, - } - if dpopKid != "" { - tokenResponse.DPoPKid = &dpopKid - } - return &tokenResponse, nil + return c.postTokenRequest(ctx, *subjectDID, useDPoP, data, resolved.Scope, metadata) } // requestJwtBearerAccessToken implements the RFC 7523 jwt-bearer two-VP token request flow. @@ -428,10 +409,6 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, organ if err != nil { return nil, err } - dpopHeader, dpopKid, err := c.signDPoPHeader(ctx, useDPoP, *spDID, metadata.TokenEndpoint) - if err != nil { - return nil, err - } data := url.Values{} data.Set(oauth.GrantTypeParam, oauth.JwtBearerGrantType) data.Set(oauth.AssertionParam, organizationVP.Raw()) @@ -440,8 +417,19 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, organ data.Set(oauth.ScopeParam, resolvedScope) log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n organization VP: %s\n service provider VP: %s", metadata.TokenEndpoint, resolvedScope, organizationVP.Raw(), serviceProviderVP.Raw()) + return c.postTokenRequest(ctx, *spDID, useDPoP, data, resolvedScope, metadata) +} + +// postTokenRequest signs a DPoP header for the signerDID (when useDPoP), POSTs the token request to the +// AS, and assembles the oauth.TokenResponse. Shared tail of the single-VP and jwt-bearer two-VP flows. +func (c *OpenID4VPClient) postTokenRequest(ctx context.Context, signerDID did.DID, useDPoP bool, data url.Values, resolvedScope string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { + dpopHeader, dpopKid, err := c.signDPoPHeader(ctx, useDPoP, signerDID, metadata.TokenEndpoint) + if err != nil { + return nil, err + } token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) if err != nil { + // the error could be a http error, we just relay it here to make use of any 400 status codes. return nil, err } tokenResponse := oauth.TokenResponse{ diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 956a7b4c6d..081617e082 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -1079,4 +1079,3 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { return ctx } - diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index f00124c82f..9bef49ba9e 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -56,7 +56,7 @@ type RequestCredentialOpts struct { CredentialConfigurationID string CredentialIdentifier string ProofJWT string - CredentialRequestParams map[string]any + CredentialRequestParams map[string]any } // Client is the OpenID4VCI 1.0 HTTP client interface.