diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go
index 6ef8405d44..739e24169f 100644
--- a/auth/api/iam/api.go
+++ b/auth/api/iam/api.go
@@ -704,7 +704,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,
@@ -716,7 +716,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 57fe97570b..5295e724e7 100644
--- a/auth/api/iam/api_test.go
+++ b/auth/api/iam/api_test.go
@@ -191,7 +191,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{CredentialProfileScope: "example-scope", WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil)
response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope"}})
@@ -216,7 +216,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{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}})
@@ -228,7 +228,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{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}})
@@ -239,7 +239,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"}})
@@ -290,7 +290,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{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)
@@ -1572,23 +1572,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
- 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
+ scopeEvaluator *policy.MockScopeEvaluator
+ 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
openid4vciClient *openid4vci.MockClient
}
@@ -1602,6 +1603,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b
storageEngine := storage.NewTestStorageEngine(t)
authnServices := auth.NewMockAuthenticationServices(ctrl)
policyInstance := policy.NewMockPDPBackend(ctrl)
+ scopeEvaluator := policy.NewMockScopeEvaluator(ctrl)
mockResolver := resolver.NewMockDIDResolver(ctrl)
relyingPary := oauthServices.NewMockRelyingParty(ctrl)
vcIssuer := issuer.NewMockIssuer(ctrl)
@@ -1645,22 +1647,23 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b
jar: mockJAR,
}
return &testCtx{
- ctrl: ctrl,
- authnServices: authnServices,
- policy: policyInstance,
- 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,
+ ctrl: ctrl,
+ authnServices: authnServices,
+ policy: policyInstance,
+ scopeEvaluator: scopeEvaluator,
+ 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,
openid4vciClient: openid4vciClient,
}
}
diff --git a/auth/api/iam/integration_test.go b/auth/api/iam/integration_test.go
new file mode 100644
index 0000000000..4d7282b4c5
--- /dev/null
+++ b/auth/api/iam/integration_test.go
@@ -0,0 +1,176 @@
+/*
+ * 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, 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) {
+ 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"
+
+ // 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
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/access/v1/evaluations", r.URL.Path)
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&receivedRequest))
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(authzen.EvaluationsResponse{Evaluations: decisions})
+ }))
+ 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)
+ 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().ScopeEvaluator().Return(policy.NewAuthZenScopeEvaluator(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)
+
+ // 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, "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, _ := startPDP(t, []authzen.EvaluationResult{
+ {Decision: true},
+ {Decision: false, Context: &authzen.EvaluationResultContext{Reason: "not permitted"}},
+ })
+ 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().ScopeEvaluator().Return(policy.NewAuthZenScopeEvaluator(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 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)
+ 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().ScopeEvaluator().Return(policy.NewAuthZenScopeEvaluator(realAuthzenClient))
+
+ 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)
+ })
+}
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/openid4vp_test.go b/auth/api/iam/openid4vp_test.go
index 1f0135363c..86fa35e4c4 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{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().PresentationDefinitions(gomock.Any(), "test").Return(pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, 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 c215ea4269..34212badbf 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"
@@ -73,11 +74,15 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin
return nil, err
}
}
- walletOwnerMapping, err := r.presentationDefinitionForScope(ctx, scope)
+ credentialProfile, err := r.findCredentialProfile(ctx, scope)
if err != nil {
return nil, err
}
- pexConsumer := newPEXConsumer(walletOwnerMapping)
+ granter, err := policy.NewScopeGranter(credentialProfile, r.policyBackend.ScopeEvaluator)
+ if err != nil {
+ return nil, err
+ }
+ pexConsumer := newPEXConsumer(credentialProfile.WalletOwnerMapping)
if err := pexConsumer.fulfill(*submission, *pexEnvelope); err != nil {
return nil, oauthError(oauth.InvalidRequest, err.Error())
}
@@ -107,9 +112,31 @@ 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.
+ credentialMap, err := pexConsumer.credentialMap()
+ if err != nil {
+ return nil, oauth.OAuth2Error{
+ Code: oauth.ServerError,
+ Description: "failed to extract credentials for scope evaluation",
+ InternalError: err,
+ }
+ }
+ claims, err := resolveInputDescriptorValues(pexConsumer.RequiredPresentationDefinitions, credentialMap)
+ if err != nil {
+ return nil, err
+ }
+ grantedScope, err := granter.Grant(ctx, policy.GrantInput{
+ SubjectDID: credentialSubjectID,
+ PresentationClaims: claims,
+ })
+ 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
}
diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go
index 7cc4504b7c..bb07c86ae4 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{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().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, 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().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, 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().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, 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().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, 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().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, 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().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, 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().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, 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().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, 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())
@@ -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{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,13 +375,133 @@ 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{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil)
resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
_ = 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)
+ })
+ t.Run("passthrough scope policy grants all requested 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.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)
+ })
+ 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().ScopeEvaluator().Return(ctx.scopeEvaluator)
+ // Verify the handler forwards the right ScopeEvaluationInput to the evaluator.
+ // AuthZen wire-shape assertions live in TestAuthZenScopeEvaluator (unit) and
+ // integration_test.go (end-to-end against an httptest PDP).
+ ctx.scopeEvaluator.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).DoAndReturn(
+ func(_ context.Context, in policy.ScopeEvaluationInput) (map[string]bool, error) {
+ assert.Equal(t, "example-scope", in.CredentialProfileScope)
+ assert.Equal(t, []string{"example-scope", "extra-scope"}, in.Scopes)
+ return map[string]bool{
+ "example-scope": true,
+ "extra-scope": true,
+ }, 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)
+ })
+ 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().ScopeEvaluator().Return(ctx.scopeEvaluator)
+ ctx.scopeEvaluator.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return(map[string]bool{
+ "example-scope": true,
+ "extra-scope": true,
+ "other-scope": false,
+ }, 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().ScopeEvaluator().Return(ctx.scopeEvaluator)
+ ctx.scopeEvaluator.EXPECT().EvaluateScopes(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().ScopeEvaluator().Return(ctx.scopeEvaluator)
+ ctx.scopeEvaluator.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return(nil, errors.New("PDP unreachable"))
+
+ resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw())
+
+ _ = assertOAuthErrorWithCode(t, err, oauth.ServerError, "policy decision point unavailable")
+ assert.Nil(t, resp)
+ })
}
func TestWrapper_createAccessToken(t *testing.T) {
diff --git a/auth/api/iam/validation.go b/auth/api/iam/validation.go
index 7809a3ab4f..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,10 +77,10 @@ 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)
+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) {
+ if errors.Is(err, policy.ErrNotFound) || errors.Is(err, policy.ErrAmbiguousScope) {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidScope,
InternalError: err,
@@ -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 mapping, err
+ return match, nil
}
diff --git a/auth/auth.go b/auth/auth.go
index 52528e4f0e..e09e367fe1 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -23,6 +23,7 @@ import (
"errors"
"github.com/nuts-foundation/nuts-node/auth/client/iam"
"github.com/nuts-foundation/nuts-node/auth/openid4vci"
+ "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"
@@ -70,6 +71,7 @@ type Auth struct {
httpClientTimeout time.Duration
tlsConfig *tls.Config
subjectManager didsubject.Manager
+ policyBackend policy.PDPBackend
openID4VCIClient openid4vci.Client
// configuredDIDMethods contains the DID methods that are configured in the Nuts node,
// of which VDR will create DIDs.
@@ -103,7 +105,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,
@@ -113,6 +115,7 @@ func NewAuthInstance(config Config, vdrInstance vdr.VDR, subjectManager didsubje
vcr: vcr,
pkiProvider: pkiProvider,
serviceResolver: serviceResolver,
+ policyBackend: policyBackend,
shutdownFunc: func() {},
}
}
@@ -129,7 +132,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)
}
// OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client.
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 a3ae622d62..3d4b4d4b19 100644
--- a/auth/client/iam/openid4vp.go
+++ b/auth/client/iam/openid4vp.go
@@ -31,6 +31,7 @@ import (
"time"
"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"
@@ -61,17 +62,19 @@ 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 {
- return &OpenID4VPClient{
- httpClient: HTTPClient{
- strictMode: strictMode,
- httpClient: client.NewWithCache(httpClientTimeout),
- keyResolver: keyResolver,
- },
+ ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient {
+ httpClient := HTTPClient{
+ strictMode: strictMode,
+ httpClient: client.NewWithCache(httpClientTimeout),
+ keyResolver: keyResolver,
+ }
+ client := &OpenID4VPClient{
+ httpClient: httpClient,
keyResolver: keyResolver,
jwtSigner: jwtSigner,
ldDocumentLoader: ldDocumentLoader,
@@ -79,6 +82,11 @@ func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectMa
strictMode: strictMode,
wallet: wallet,
}
+ client.pdResolver = PresentationDefinitionResolver{
+ pdFetcher: client,
+ policyBackend: policyBackend,
+ }
+ return client
}
func (c *OpenID4VPClient) ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) {
@@ -243,18 +251,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,
@@ -313,7 +315,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 99cc6f305d..85851418ac 100644
--- a/auth/client/iam/openid4vp_test.go
+++ b/auth/client/iam/openid4vp_test.go
@@ -480,19 +480,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,
+ 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
new file mode 100644
index 0000000000..edce53eb21
--- /dev/null
+++ b/auth/client/iam/pd_resolver.go
@@ -0,0 +1,110 @@
+/*
+ * 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"
+ "net/url"
+
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
+ 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
+}
+
+// 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 {
+ pdFetcher pdFetcher
+ policyBackend policy.PDPBackend
+}
+
+// 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) {
+ baseURL, err := url.Parse(metadata.PresentationDefinitionEndpoint)
+ if err != nil {
+ return nil, fmt.Errorf("invalid presentation definition endpoint: %w", err)
+ }
+ pdURL := nutsHttp.AddQueryParams(*baseURL, map[string]string{"scope": scope})
+ pd, err := r.pdFetcher.PresentationDefinition(ctx, pdURL.String())
+ if err != nil {
+ return nil, err
+ }
+ return &ResolvedPresentationDefinition{
+ PresentationDefinition: *pd,
+ Scope: scope,
+ }, nil
+}
+
+func (r *PresentationDefinitionResolver) resolveLocal(ctx context.Context, scope string) (*ResolvedPresentationDefinition, error) {
+ if r.policyBackend == nil {
+ return nil, fmt.Errorf("local PD resolution requires a policy backend, but none is configured")
+ }
+ match, err := r.policyBackend.FindCredentialProfile(ctx, scope)
+ if err != nil {
+ return nil, fmt.Errorf("local PD resolution failed: %w", err)
+ }
+ if match.ScopePolicy == policy.ScopePolicyProfileOnly && len(match.OtherScopes) > 0 {
+ return nil, oauth.OAuth2Error{
+ Code: oauth.InvalidScope,
+ Description: "scope policy 'profile-only' does not allow additional scopes",
+ }
+ }
+ // Select the organization PD (default for current single-VP flow).
+ // TODO: When #4080 adds two-VP support, this resolver will need to return multiple PDs.
+ pd, ok := match.WalletOwnerMapping[pe.WalletOwnerOrganization]
+ if !ok {
+ return nil, fmt.Errorf("no organization presentation definition for scope %q", match.CredentialProfileScope)
+ }
+ // For passthrough and dynamic, forward all scopes to the remote AS.
+ // The client does not evaluate dynamic scopes — the server handles PDP evaluation at token-grant time (PR #4179).
+ resolvedScope := scope
+ if match.ScopePolicy == policy.ScopePolicyProfileOnly {
+ resolvedScope = match.CredentialProfileScope
+ }
+ return &ResolvedPresentationDefinition{
+ PresentationDefinition: pd,
+ Scope: resolvedScope,
+ }, nil
+}
diff --git a/auth/client/iam/pd_resolver_test.go b/auth/client/iam/pd_resolver_test.go
new file mode 100644
index 0000000000..c714bcc843
--- /dev/null
+++ b/auth/client/iam/pd_resolver_test.go
@@ -0,0 +1,176 @@
+/*
+ * 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"
+)
+
+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{
+ {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) {
+ fetcher := &fakePDFetcher{pd: &testPD}
+ resolver := &PresentationDefinitionResolver{pdFetcher: fetcher}
+ metadata := oauth.AuthorizationServerMetadata{
+ PresentationDefinitionEndpoint: "https://as.example.com/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)
+ 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
+
+ t.Run("single scope, profile-only", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ mockPolicy := policy.NewMockPDPBackend(ctrl)
+ mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "profile-scope").Return(&policy.CredentialProfileMatch{
+ CredentialProfileScope: "profile-scope",
+ WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: testPD},
+ ScopePolicy: policy.ScopePolicyProfileOnly,
+ }, nil)
+
+ resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy}
+ result, err := resolver.Resolve(context.Background(), "profile-scope", metadata)
+
+ require.NoError(t, err)
+ assert.Equal(t, "test-pd", result.PresentationDefinition.Id)
+ assert.Equal(t, "profile-scope", result.Scope)
+ })
+ t.Run("multi-scope, profile-only rejects", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ mockPolicy := policy.NewMockPDPBackend(ctrl)
+ mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "profile-scope extra-scope").Return(&policy.CredentialProfileMatch{
+ CredentialProfileScope: "profile-scope",
+ OtherScopes: []string{"extra-scope"},
+ WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: testPD},
+ ScopePolicy: policy.ScopePolicyProfileOnly,
+ }, nil)
+
+ resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy}
+ _, err := resolver.Resolve(context.Background(), "profile-scope extra-scope", metadata)
+
+ assert.ErrorContains(t, err, "does not allow additional scopes")
+ })
+ t.Run("multi-scope, passthrough forwards all scopes", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ mockPolicy := policy.NewMockPDPBackend(ctrl)
+ mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "profile-scope extra-scope").Return(&policy.CredentialProfileMatch{
+ CredentialProfileScope: "profile-scope",
+ OtherScopes: []string{"extra-scope"},
+ WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: testPD},
+ ScopePolicy: policy.ScopePolicyPassthrough,
+ }, nil)
+
+ resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy}
+ result, err := resolver.Resolve(context.Background(), "profile-scope extra-scope", metadata)
+
+ require.NoError(t, err)
+ assert.Equal(t, "test-pd", result.PresentationDefinition.Id)
+ assert.Equal(t, "profile-scope extra-scope", result.Scope)
+ })
+ t.Run("multi-scope, dynamic forwards all scopes", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ mockPolicy := policy.NewMockPDPBackend(ctrl)
+ mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "profile-scope extra-scope").Return(&policy.CredentialProfileMatch{
+ CredentialProfileScope: "profile-scope",
+ OtherScopes: []string{"extra-scope"},
+ WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: testPD},
+ ScopePolicy: policy.ScopePolicyDynamic,
+ }, nil)
+
+ resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy}
+ result, err := resolver.Resolve(context.Background(), "profile-scope extra-scope", metadata)
+
+ require.NoError(t, err)
+ assert.Equal(t, "profile-scope extra-scope", result.Scope)
+ })
+ t.Run("unknown scope returns error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ mockPolicy := policy.NewMockPDPBackend(ctrl)
+ mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "unknown").Return(nil, policy.ErrNotFound)
+
+ resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy}
+ _, err := resolver.Resolve(context.Background(), "unknown", metadata)
+
+ assert.ErrorIs(t, err, policy.ErrNotFound)
+ })
+ t.Run("no organization PD in wallet owner mapping returns error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ mockPolicy := policy.NewMockPDPBackend(ctrl)
+ mockPolicy.EXPECT().FindCredentialProfile(gomock.Any(), "user-only-scope").Return(&policy.CredentialProfileMatch{
+ CredentialProfileScope: "user-only-scope",
+ WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerUser: testPD},
+ ScopePolicy: policy.ScopePolicyProfileOnly,
+ }, nil)
+
+ resolver := &PresentationDefinitionResolver{policyBackend: mockPolicy}
+ _, err := resolver.Resolve(context.Background(), "user-only-scope", metadata)
+
+ assert.ErrorContains(t, err, "no organization presentation definition")
+ })
+ t.Run("nil policy backend returns error", func(t *testing.T) {
+ resolver := &PresentationDefinitionResolver{policyBackend: nil}
+ _, err := resolver.Resolve(context.Background(), "any-scope", metadata)
+
+ assert.ErrorContains(t, err, "policy backend")
+ })
+ })
+ t.Run("remote PD endpoint returns error", func(t *testing.T) {
+ fetcher := &fakePDFetcher{err: errors.New("PDP call failed")}
+ resolver := &PresentationDefinitionResolver{pdFetcher: fetcher}
+ metadata := oauth.AuthorizationServerMetadata{
+ PresentationDefinitionEndpoint: "https://as.example.com/presentation_definition",
+ }
+
+ _, err := resolver.Resolve(context.Background(), "scope", metadata)
+
+ assert.ErrorContains(t, err, "PDP call failed")
+ })
+}
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()}
diff --git a/policy/authzen/client.go b/policy/authzen/client.go
new file mode 100644
index 0000000000..f367510a66
--- /dev/null
+++ b/policy/authzen/client.go
@@ -0,0 +1,93 @@
+/*
+ * 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 implements an HTTP client for the AuthZen Access Evaluations API.
+package authzen
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "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 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,
+ 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")
+ httpReq.Header.Set("Accept", "application/json")
+
+ // 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] {
+ 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 {
+ return nil, fmt.Errorf("authzen: execute request: %w", err)
+ }
+ defer httpResp.Body.Close()
+
+ if err := core.TestResponseCode(http.StatusOK, httpResp); err != nil {
+ return nil, fmt.Errorf("authzen: PDP call failed: %w", err)
+ }
+
+ 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..2f2dee5e21
--- /dev/null
+++ b/policy/authzen/client_test.go
@@ -0,0 +1,193 @@
+/*
+ * 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) {
+ 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"))
+ assert.Equal(t, "application/json", r.Header.Get("Accept"))
+ json.NewDecoder(r.Body).Decode(&receivedReq)
+
+ 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, "organization", receivedReq.Subject.Type)
+ assert.Len(t, receivedReq.Evaluations, 2)
+ assert.Equal(t, map[string]bool{
+ "scope-a": true,
+ "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, "authzen: PDP call failed: server 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("cancelled context returns error", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ <-r.Context().Done()
+ }))
+ defer server.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ 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")
+ })
+ 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")
+ })
+}
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"`
+}
diff --git a/policy/cmd.go b/policy/cmd.go
index 9110d48ac0..808edbb530 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'; the node refuses to start if such a profile is configured but this flag is empty.")
return flagSet
}
diff --git a/policy/config.go b/policy/config.go
index 6cf98eb173..21baa12b68 100644
--- a/policy/config.go
+++ b/policy/config.go
@@ -18,10 +18,19 @@
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
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..8a805a236c 100644
--- a/policy/interface.go
+++ b/policy/interface.go
@@ -21,18 +21,85 @@ package policy
import (
"context"
"errors"
+
+ "github.com/nuts-foundation/go-did/did"
+ "github.com/nuts-foundation/nuts-node/policy/authzen"
"github.com/nuts-foundation/nuts-node/vcr/pe"
)
-// 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")
-// PDPBackend is the interface for the policy backend
-// Both the remote and local policy backend implement this interface
+// 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
+
+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
+}
+
+// ScopeEvaluationInput is the request passed to a ScopeEvaluator. It carries the data
+// a PDP needs to make per-scope decisions for a single access token request.
+type ScopeEvaluationInput struct {
+ // CredentialProfileScope identifies which credential profile drives this request.
+ // PDPs typically route to per-profile rule sets using this value.
+ CredentialProfileScope string
+ // Scopes is the full list of scopes to evaluate, starting with CredentialProfileScope.
+ Scopes []string
+ // SubjectDID is the DID of the entity whose credentials backed the request.
+ SubjectDID did.DID
+ // PresentationClaims are the role-grouped claims extracted from the validated
+ // presentation(s). Currently only the organization role is populated.
+ PresentationClaims map[string]any
+}
+
+// ScopeEvaluator evaluates the scopes of an access token request against an external
+// policy decision point. It returns a per-scope decision map. The concrete backend
+// (AuthZen, Rego, etc.) is an implementation detail behind this interface.
+type ScopeEvaluator interface {
+ EvaluateScopes(ctx context.Context, in ScopeEvaluationInput) (map[string]bool, error)
+}
+
+// AuthZenEvaluator is the low-level seam to an AuthZen-compatible PDP. It is satisfied
+// by *authzen.Client. Most code should depend on ScopeEvaluator, which abstracts over
+// the choice of PDP backend; AuthZenEvaluator stays exported so the AuthZen-backed
+// ScopeEvaluator adapter can be wired up from outside this package (e.g. tests).
+type AuthZenEvaluator interface {
+ Evaluate(ctx context.Context, req authzen.EvaluationsRequest) (map[string]bool, error)
+}
+
+// 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)
+ // ScopeEvaluator returns the configured PDP evaluator for dynamic scope policy evaluation.
+ // Returns nil when no PDP endpoint is configured.
+ ScopeEvaluator() ScopeEvaluator
}
diff --git a/policy/local.go b/policy/local.go
index 81719eb7d8..5c75ac4d67 100644
--- a/policy/local.go
+++ b/policy/local.go
@@ -23,13 +23,28 @@ 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"
"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:
+ return true
+ default:
+ return false
+ }
+}
+
var _ PDPBackend = (*LocalPDP)(nil)
// New creates a new local policy backend
@@ -37,13 +52,15 @@ 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
+ // scopeEvaluator is the configured ScopeEvaluator used for dynamic scope policy
+ // evaluation. It is nil when no PDP endpoint is configured.
+ scopeEvaluator ScopeEvaluator
}
func (b *LocalPDP) Name() string {
@@ -67,24 +84,53 @@ 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)
+ }
+ }
+ } else {
+ // Use StrictHTTPClient: enforces TLS, bounds response body size, applies timeout.
+ b.scopeEvaluator = NewAuthZenScopeEvaluator(authzen.NewClient(b.config.AuthZen.Endpoint, httpClient.New(authzenTimeout)))
+ }
return nil
}
+// ScopeEvaluator returns the configured ScopeEvaluator, or nil when no PDP endpoint is configured.
+func (b *LocalPDP) ScopeEvaluator() ScopeEvaluator {
+ return b.scopeEvaluator
+}
+
func (b *LocalPDP) Config() interface{} {
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
+// 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.Fields(scope) {
+ if p, exists := b.mapping[s]; exists {
+ if profileScope != "" {
+ return nil, ErrAmbiguousScope
+ }
+ 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 +164,67 @@ 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.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)
+ }
+ 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..b25e931436 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"
@@ -54,24 +55,140 @@ 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)
+ })
+ 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, 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")
+ require.NoError(t, err)
+
+ _, err = store.FindCredentialProfile(context.Background(), "1 2")
+
+ assert.ErrorIs(t, err, ErrAmbiguousScope)
+ })
+ 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 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{}
+ err := store.loadFromFile("test/scope_policy/dynamic.json")
+ require.NoError(t, err)
+
+ 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{
+ AuthZen: AuthZenConfig{Endpoint: "http://localhost:8080"},
+ }}
+ err := store.loadFromFile("test/scope_policy/dynamic.json")
+ require.NoError(t, err)
+
+ 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)
})
}
@@ -88,8 +205,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 +215,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..88a06382d6 100644
--- a/policy/mock.go
+++ b/policy/mock.go
@@ -13,10 +13,88 @@ import (
context "context"
reflect "reflect"
- pe "github.com/nuts-foundation/nuts-node/vcr/pe"
+ authzen "github.com/nuts-foundation/nuts-node/policy/authzen"
gomock "go.uber.org/mock/gomock"
)
+// MockScopeEvaluator is a mock of ScopeEvaluator interface.
+type MockScopeEvaluator struct {
+ ctrl *gomock.Controller
+ recorder *MockScopeEvaluatorMockRecorder
+ isgomock struct{}
+}
+
+// MockScopeEvaluatorMockRecorder is the mock recorder for MockScopeEvaluator.
+type MockScopeEvaluatorMockRecorder struct {
+ mock *MockScopeEvaluator
+}
+
+// NewMockScopeEvaluator creates a new mock instance.
+func NewMockScopeEvaluator(ctrl *gomock.Controller) *MockScopeEvaluator {
+ mock := &MockScopeEvaluator{ctrl: ctrl}
+ mock.recorder = &MockScopeEvaluatorMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockScopeEvaluator) EXPECT() *MockScopeEvaluatorMockRecorder {
+ return m.recorder
+}
+
+// EvaluateScopes mocks base method.
+func (m *MockScopeEvaluator) EvaluateScopes(ctx context.Context, in ScopeEvaluationInput) (map[string]bool, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "EvaluateScopes", ctx, in)
+ ret0, _ := ret[0].(map[string]bool)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// EvaluateScopes indicates an expected call of EvaluateScopes.
+func (mr *MockScopeEvaluatorMockRecorder) EvaluateScopes(ctx, in any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EvaluateScopes", reflect.TypeOf((*MockScopeEvaluator)(nil).EvaluateScopes), ctx, in)
+}
+
+// MockAuthZenEvaluator is a mock of AuthZenEvaluator interface.
+type MockAuthZenEvaluator struct {
+ ctrl *gomock.Controller
+ 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
@@ -41,17 +119,31 @@ 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, "FindCredentialProfile", reflect.TypeOf((*MockPDPBackend)(nil).FindCredentialProfile), ctx, scope)
+}
+
+// ScopeEvaluator mocks base method.
+func (m *MockPDPBackend) ScopeEvaluator() ScopeEvaluator {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ScopeEvaluator")
+ ret0, _ := ret[0].(ScopeEvaluator)
+ return ret0
+}
+
+// ScopeEvaluator indicates an expected call of ScopeEvaluator.
+func (mr *MockPDPBackendMockRecorder) ScopeEvaluator() *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinitions", reflect.TypeOf((*MockPDPBackend)(nil).PresentationDefinitions), ctx, scope)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScopeEvaluator", reflect.TypeOf((*MockPDPBackend)(nil).ScopeEvaluator))
}
diff --git a/policy/scope_granter.go b/policy/scope_granter.go
new file mode 100644
index 0000000000..9f141c8a7d
--- /dev/null
+++ b/policy/scope_granter.go
@@ -0,0 +1,166 @@
+/*
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package policy
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/nuts-foundation/go-did/did"
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/nuts-foundation/nuts-node/policy/authzen"
+)
+
+// ScopeGranter computes the scopes to grant for an access token request, based on a
+// credential profile match. There is one implementation per ScopePolicy mode.
+type ScopeGranter interface {
+ Grant(ctx context.Context, in GrantInput) (string, error)
+}
+
+// GrantInput carries the per-request data that ScopeGranter implementations may need.
+// SubjectDID and PresentationClaims are only consumed by the dynamic granter; the
+// other modes ignore them.
+type GrantInput struct {
+ SubjectDID did.DID
+ PresentationClaims map[string]any
+}
+
+// NewScopeGranter returns a ScopeGranter that implements credentialProfile.ScopePolicy.
+// resolveEvaluator is invoked only when the scope policy is ScopePolicyDynamic; the
+// other modes do not need a ScopeEvaluator and the function is not called.
+func NewScopeGranter(credentialProfile *CredentialProfileMatch, resolveEvaluator func() ScopeEvaluator) (ScopeGranter, error) {
+ if credentialProfile == nil {
+ return nil, fmt.Errorf("credential profile is required")
+ }
+ switch credentialProfile.ScopePolicy {
+ case ScopePolicyProfileOnly:
+ // Fail fast: reject extra scopes at construction time so the caller can
+ // short-circuit before doing expensive VP verification work.
+ if len(credentialProfile.OtherScopes) > 0 {
+ return nil, oauth.OAuth2Error{
+ Code: oauth.InvalidScope,
+ Description: "scope policy 'profile-only' does not allow additional scopes",
+ }
+ }
+ return profileOnlyGranter{credentialProfile: credentialProfile}, nil
+ case ScopePolicyPassthrough:
+ return passthroughGranter{credentialProfile: credentialProfile}, nil
+ case ScopePolicyDynamic:
+ evaluator := resolveEvaluator()
+ if evaluator == nil {
+ // Should be caught at startup by LocalPDP.Configure, but guard here defensively.
+ return nil, oauth.OAuth2Error{
+ Code: oauth.ServerError,
+ Description: "dynamic scope policy configured but no ScopeEvaluator available",
+ }
+ }
+ return dynamicGranter{credentialProfile: credentialProfile, evaluator: evaluator}, nil
+ default:
+ return nil, oauth.OAuth2Error{
+ Code: oauth.ServerError,
+ Description: fmt.Sprintf("unsupported scope policy: %s", credentialProfile.ScopePolicy),
+ }
+ }
+}
+
+type profileOnlyGranter struct {
+ credentialProfile *CredentialProfileMatch
+}
+
+func (g profileOnlyGranter) Grant(_ context.Context, _ GrantInput) (string, error) {
+ return g.credentialProfile.CredentialProfileScope, nil
+}
+
+type passthroughGranter struct {
+ credentialProfile *CredentialProfileMatch
+}
+
+func (g passthroughGranter) Grant(_ context.Context, _ GrantInput) (string, error) {
+ scopes := append([]string{g.credentialProfile.CredentialProfileScope}, g.credentialProfile.OtherScopes...)
+ return strings.Join(scopes, " "), nil
+}
+
+type dynamicGranter struct {
+ credentialProfile *CredentialProfileMatch
+ evaluator ScopeEvaluator
+}
+
+func (g dynamicGranter) Grant(ctx context.Context, in GrantInput) (string, error) {
+ allScopes := append([]string{g.credentialProfile.CredentialProfileScope}, g.credentialProfile.OtherScopes...)
+ decisions, err := g.evaluator.EvaluateScopes(ctx, ScopeEvaluationInput{
+ CredentialProfileScope: g.credentialProfile.CredentialProfileScope,
+ Scopes: allScopes,
+ SubjectDID: in.SubjectDID,
+ PresentationClaims: in.PresentationClaims,
+ })
+ if err != nil {
+ // Keep Description generic to avoid leaking PDP internals to the OAuth2 client.
+ // Details remain available in InternalError for server-side logging.
+ return "", oauth.OAuth2Error{
+ Code: oauth.ServerError,
+ Description: "policy decision point unavailable",
+ InternalError: err,
+ }
+ }
+ if !decisions[g.credentialProfile.CredentialProfileScope] {
+ return "", oauth.OAuth2Error{
+ Code: oauth.AccessDenied,
+ Description: fmt.Sprintf("PDP denied credential profile scope %q", g.credentialProfile.CredentialProfileScope),
+ }
+ }
+ granted := []string{g.credentialProfile.CredentialProfileScope}
+ for _, s := range g.credentialProfile.OtherScopes {
+ if decisions[s] {
+ granted = append(granted, s)
+ }
+ }
+ return strings.Join(granted, " "), nil
+}
+
+// NewAuthZenScopeEvaluator returns a ScopeEvaluator backed by an AuthZen-compatible PDP.
+// The underlying AuthZenEvaluator is typically an *authzen.Client. The adapter is the
+// single place that knows the AuthZen wire format; callers work in terms of the generic
+// ScopeEvaluationInput shape.
+func NewAuthZenScopeEvaluator(client AuthZenEvaluator) ScopeEvaluator {
+ return authzenScopeEvaluator{client: client}
+}
+
+type authzenScopeEvaluator struct {
+ client AuthZenEvaluator
+}
+
+func (a authzenScopeEvaluator) EvaluateScopes(ctx context.Context, in ScopeEvaluationInput) (map[string]bool, error) {
+ request := authzen.EvaluationsRequest{
+ Subject: authzen.Subject{
+ Type: "organization",
+ ID: in.SubjectDID.String(),
+ Properties: authzen.SubjectProperties{
+ Organization: in.PresentationClaims,
+ },
+ },
+ Action: authzen.Action{Name: "request_scope"},
+ Context: authzen.EvaluationContext{Policy: in.CredentialProfileScope},
+ Evaluations: make([]authzen.Evaluation, len(in.Scopes)),
+ }
+ for i, s := range in.Scopes {
+ request.Evaluations[i] = authzen.Evaluation{Resource: authzen.Resource{Type: "scope", ID: s}}
+ }
+ return a.client.Evaluate(ctx, request)
+}
diff --git a/policy/scope_granter_test.go b/policy/scope_granter_test.go
new file mode 100644
index 0000000000..9201aa850d
--- /dev/null
+++ b/policy/scope_granter_test.go
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package policy
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/nuts-foundation/go-did/did"
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/nuts-foundation/nuts-node/policy/authzen"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+)
+
+func TestNewScopeGranter(t *testing.T) {
+ profile := func(p ScopePolicy, others ...string) *CredentialProfileMatch {
+ return &CredentialProfileMatch{
+ CredentialProfileScope: "profile-scope",
+ ScopePolicy: p,
+ OtherScopes: others,
+ }
+ }
+ noEvaluator := func() ScopeEvaluator { return nil }
+
+ t.Run("nil credential profile returns error", func(t *testing.T) {
+ _, err := NewScopeGranter(nil, noEvaluator)
+ require.Error(t, err)
+ })
+ t.Run("unsupported scope policy returns server_error", func(t *testing.T) {
+ _, err := NewScopeGranter(profile("bogus"), noEvaluator)
+ var oauthErr oauth.OAuth2Error
+ require.ErrorAs(t, err, &oauthErr)
+ assert.Equal(t, oauth.ServerError, oauthErr.Code)
+ })
+ t.Run("dynamic without evaluator returns server_error", func(t *testing.T) {
+ _, err := NewScopeGranter(profile(ScopePolicyDynamic), noEvaluator)
+ var oauthErr oauth.OAuth2Error
+ require.ErrorAs(t, err, &oauthErr)
+ assert.Equal(t, oauth.ServerError, oauthErr.Code)
+ })
+ t.Run("non-dynamic policies do not invoke resolveEvaluator", func(t *testing.T) {
+ called := false
+ resolve := func() ScopeEvaluator {
+ called = true
+ return nil
+ }
+ _, err := NewScopeGranter(profile(ScopePolicyProfileOnly), resolve)
+ require.NoError(t, err)
+ _, err = NewScopeGranter(profile(ScopePolicyPassthrough), resolve)
+ require.NoError(t, err)
+ assert.False(t, called, "resolveEvaluator should not be called for non-dynamic policies")
+ })
+}
+
+func TestProfileOnlyGranter(t *testing.T) {
+ t.Run("grants the credential profile scope when no other scopes are present", func(t *testing.T) {
+ g, err := NewScopeGranter(&CredentialProfileMatch{
+ CredentialProfileScope: "profile-scope",
+ ScopePolicy: ScopePolicyProfileOnly,
+ }, nil)
+ require.NoError(t, err)
+ granted, err := g.Grant(context.Background(), GrantInput{})
+ require.NoError(t, err)
+ assert.Equal(t, "profile-scope", granted)
+ })
+ t.Run("constructor rejects extra scopes with invalid_scope (fail-fast before VP work)", func(t *testing.T) {
+ _, err := NewScopeGranter(&CredentialProfileMatch{
+ CredentialProfileScope: "profile-scope",
+ ScopePolicy: ScopePolicyProfileOnly,
+ OtherScopes: []string{"extra"},
+ }, nil)
+ var oauthErr oauth.OAuth2Error
+ require.ErrorAs(t, err, &oauthErr)
+ assert.Equal(t, oauth.InvalidScope, oauthErr.Code)
+ })
+}
+
+func TestPassthroughGranter(t *testing.T) {
+ g, err := NewScopeGranter(&CredentialProfileMatch{
+ CredentialProfileScope: "profile-scope",
+ ScopePolicy: ScopePolicyPassthrough,
+ OtherScopes: []string{"extra-a", "extra-b"},
+ }, nil)
+ require.NoError(t, err)
+ granted, err := g.Grant(context.Background(), GrantInput{})
+ require.NoError(t, err)
+ assert.Equal(t, "profile-scope extra-a extra-b", granted)
+}
+
+func TestDynamicGranter(t *testing.T) {
+ subjectDID := did.MustParseDID("did:web:example.com")
+ match := &CredentialProfileMatch{
+ CredentialProfileScope: "profile-scope",
+ ScopePolicy: ScopePolicyDynamic,
+ OtherScopes: []string{"extra-a", "extra-b"},
+ }
+
+ t.Run("forwards subject, profile, claims and full scope list to the evaluator", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ ev := NewMockScopeEvaluator(ctrl)
+ ev.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).DoAndReturn(
+ func(_ context.Context, in ScopeEvaluationInput) (map[string]bool, error) {
+ assert.Equal(t, "profile-scope", in.CredentialProfileScope)
+ assert.Equal(t, []string{"profile-scope", "extra-a", "extra-b"}, in.Scopes)
+ assert.Equal(t, subjectDID, in.SubjectDID)
+ assert.Equal(t, map[string]any{"name": "Hospital"}, in.PresentationClaims)
+ return map[string]bool{"profile-scope": true, "extra-a": true, "extra-b": true}, nil
+ })
+ g, err := NewScopeGranter(match, func() ScopeEvaluator { return ev })
+ require.NoError(t, err)
+ granted, err := g.Grant(context.Background(), GrantInput{
+ SubjectDID: subjectDID,
+ PresentationClaims: map[string]any{"name": "Hospital"},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "profile-scope extra-a extra-b", granted)
+ })
+ t.Run("excludes denied other scopes from the granted set", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ ev := NewMockScopeEvaluator(ctrl)
+ ev.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return(
+ map[string]bool{"profile-scope": true, "extra-a": true, "extra-b": false}, nil,
+ )
+ g, _ := NewScopeGranter(match, func() ScopeEvaluator { return ev })
+ granted, err := g.Grant(context.Background(), GrantInput{SubjectDID: subjectDID})
+ require.NoError(t, err)
+ assert.Equal(t, "profile-scope extra-a", granted)
+ })
+ t.Run("returns access_denied when the credential profile scope is denied", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ ev := NewMockScopeEvaluator(ctrl)
+ ev.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return(
+ map[string]bool{"profile-scope": false, "extra-a": true}, nil,
+ )
+ g, _ := NewScopeGranter(match, func() ScopeEvaluator { return ev })
+ _, err := g.Grant(context.Background(), GrantInput{SubjectDID: subjectDID})
+ var oauthErr oauth.OAuth2Error
+ require.ErrorAs(t, err, &oauthErr)
+ assert.Equal(t, oauth.AccessDenied, oauthErr.Code)
+ })
+ t.Run("returns server_error when the evaluator fails", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ ev := NewMockScopeEvaluator(ctrl)
+ ev.EXPECT().EvaluateScopes(gomock.Any(), gomock.Any()).Return(nil, errors.New("pdp boom"))
+ g, _ := NewScopeGranter(match, func() ScopeEvaluator { return ev })
+ _, err := g.Grant(context.Background(), GrantInput{SubjectDID: subjectDID})
+ var oauthErr oauth.OAuth2Error
+ require.ErrorAs(t, err, &oauthErr)
+ assert.Equal(t, oauth.ServerError, oauthErr.Code)
+ assert.Equal(t, "policy decision point unavailable", oauthErr.Description)
+ })
+}
+
+// TestAuthZenScopeEvaluator covers the AuthZen-specific wire shape produced by the
+// adapter. This is the boundary where the generic ScopeEvaluationInput is translated
+// into the AuthZen Access Evaluations request format documented in PRD #4144.
+func TestAuthZenScopeEvaluator(t *testing.T) {
+ subjectDID := did.MustParseDID("did:web:hospital.example.com")
+ claims := map[string]any{"name": "Hospital B.V.", "ura": "12345678"}
+ in := ScopeEvaluationInput{
+ CredentialProfileScope: "urn:nuts:medication-overview",
+ Scopes: []string{"urn:nuts:medication-overview", "patient/Observation.read"},
+ SubjectDID: subjectDID,
+ PresentationClaims: claims,
+ }
+
+ t.Run("translates input to AuthZen request shape per PRD contract", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ client := NewMockAuthZenEvaluator(ctrl)
+ client.EXPECT().Evaluate(gomock.Any(), gomock.Any()).DoAndReturn(
+ func(_ context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) {
+ assert.Equal(t, "organization", req.Subject.Type)
+ assert.Equal(t, subjectDID.String(), req.Subject.ID)
+ assert.Equal(t, claims, req.Subject.Properties.Organization)
+ assert.Equal(t, "request_scope", req.Action.Name)
+ assert.Equal(t, "urn:nuts:medication-overview", req.Context.Policy)
+ require.Len(t, req.Evaluations, 2)
+ assert.Equal(t, "scope", req.Evaluations[0].Resource.Type)
+ assert.Equal(t, "urn:nuts:medication-overview", req.Evaluations[0].Resource.ID)
+ assert.Equal(t, "scope", req.Evaluations[1].Resource.Type)
+ assert.Equal(t, "patient/Observation.read", req.Evaluations[1].Resource.ID)
+ return map[string]bool{
+ "urn:nuts:medication-overview": true,
+ "patient/Observation.read": false,
+ }, nil
+ })
+
+ evaluator := NewAuthZenScopeEvaluator(client)
+ decisions, err := evaluator.EvaluateScopes(context.Background(), in)
+
+ require.NoError(t, err)
+ assert.Equal(t, map[string]bool{
+ "urn:nuts:medication-overview": true,
+ "patient/Observation.read": false,
+ }, decisions)
+ })
+ t.Run("propagates client error", func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ client := NewMockAuthZenEvaluator(ctrl)
+ client.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(nil, errors.New("boom"))
+
+ evaluator := NewAuthZenScopeEvaluator(client)
+ _, err := evaluator.EvaluateScopes(context.Background(), in)
+
+ require.EqualError(t, err, "boom")
+ })
+}
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"
+ }
+}