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" + } +}