From 0a236af09de8500d2e522e53286f46398e2666fa Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:21 +0200 Subject: [PATCH 1/7] Initialize branch for PR From 358de0729870ddf30f50e43337052706b26d2524 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:43 +0200 Subject: [PATCH 2/7] Initialize branch for PR From 50d47ff063329f981f5a664171dc8a07b232a08a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 13 Apr 2026 12:37:54 +0200 Subject: [PATCH 3/7] Initialize branch for PR From 85a3e06aa239dacfa8e4c90523d41f0a6f47295b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 14:54:00 +0200 Subject: [PATCH 4/7] Add end-to-end integration tests for dynamic scope policy Exercises the server-side token handler with a real AuthZen HTTP client talking to an httptest server. Unlike unit tests that mock the evaluator, this validates the full HTTP roundtrip: request serialization, response parsing, and error propagation. Tests cover: PDP approves all, partial denial, HTTP 500 error. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/integration_test.go | 180 +++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 auth/api/iam/integration_test.go diff --git a/auth/api/iam/integration_test.go b/auth/api/iam/integration_test.go new file mode 100644 index 0000000000..fdaea0b9c0 --- /dev/null +++ b/auth/api/iam/integration_test.go @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package iam + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/policy/authzen" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" + "github.com/nuts-foundation/nuts-node/vcr/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// TestIntegration_DynamicScopePolicy_AuthZenEndToEnd exercises the server-side token handler +// with a real AuthZen HTTP client talking to an httptest server. Unlike the unit tests in +// s2s_vptoken_test.go which mock the AuthZen evaluator directly, this test validates the +// full HTTP roundtrip: request serialization, response parsing, and error propagation. +func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { + // Shared fixtures + var presentationDefinition pe.PresentationDefinition + require.NoError(t, json.Unmarshal([]byte(`{ + "format": { + "ldp_vc": {"proof_type": ["JsonWebSignature2020"]} + }, + "input_descriptors": [{ + "id": "1", + "constraints": { + "fields": [{ + "path": ["$.type"], + "filter": {"type": "string", "const": "NutsOrganizationCredential"} + }] + } + }] + }`), &presentationDefinition)) + walletOwnerMapping := pe.WalletOwnerMapping{pe.WalletOwnerOrganization: presentationDefinition} + + var submission pe.PresentationSubmission + require.NoError(t, json.Unmarshal([]byte(`{ + "descriptor_map": [{"id": "1", "path": "$.verifiableCredential", "format": "ldp_vc"}] + }`), &submission)) + submissionJSONBytes, _ := json.Marshal(submission) + submissionJSON := string(submissionJSONBytes) + + verifiableCredential := test.ValidNutsOrganizationCredential(t) + subjectDID, _ := verifiableCredential.SubjectDID() + proofVisitor := test.LDProofVisitor(func(p *proof.LDProof) { + p.Domain = &issuerClientID + }) + presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential) + + dpopHeader, _, _ := newSignedTestDPoP() + httpRequest := &http.Request{Header: http.Header{"Dpop": []string{dpopHeader.String()}}} + contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) + clientID := "https://example.com/oauth2/holder" + + t.Run("PDP approves all scopes - token issued with all scopes", func(t *testing.T) { + var receivedRequest authzen.EvaluationsRequest + pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/access/v1/evaluations", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.NoError(t, json.NewDecoder(r.Body).Decode(&receivedRequest)) + + resp := authzen.EvaluationsResponse{ + Evaluations: []authzen.EvaluationResult{ + {Decision: true}, + {Decision: true}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer pdpServer.Close() + + realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) + + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(realAuthzenClient) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + require.NoError(t, err) + tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) + assert.Equal(t, "example-scope extra-scope", *tokenResponse.Scope) + + // Verify the PDP received a well-formed request via actual HTTP roundtrip + assert.Equal(t, "organization", receivedRequest.Subject.Type) + assert.Equal(t, "request_scope", receivedRequest.Action.Name) + assert.Equal(t, "example-scope", receivedRequest.Context.Policy) + require.Len(t, receivedRequest.Evaluations, 2) + assert.Equal(t, "scope", receivedRequest.Evaluations[0].Resource.Type) + assert.Equal(t, "example-scope", receivedRequest.Evaluations[0].Resource.ID) + assert.Equal(t, "extra-scope", receivedRequest.Evaluations[1].Resource.ID) + }) + + t.Run("PDP partial denial - denied scopes excluded from token", func(t *testing.T) { + pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := authzen.EvaluationsResponse{ + Evaluations: []authzen.EvaluationResult{ + {Decision: true}, + {Decision: false, Context: &authzen.EvaluationResultContext{Reason: "not permitted"}}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer pdpServer.Close() + realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) + + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(realAuthzenClient) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + require.NoError(t, err) + tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) + assert.Equal(t, "example-scope", *tokenResponse.Scope) + }) + + t.Run("PDP returns HTTP 500 - server_error", func(t *testing.T) { + pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer pdpServer.Close() + realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) + + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(realAuthzenClient) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + _ = assertOAuthErrorWithCode(t, err, oauth.ServerError, "policy decision point unavailable") + assert.Nil(t, resp) + }) +} From 28f442bff8b74195dbeee632564e1ea9b58ec90a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 16:00:47 +0200 Subject: [PATCH 5/7] Add introspection tests for multi-scope tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify that tokens with space-delimited scope strings (from multi-scope requests) are returned unchanged via both IntrospectAccessToken and IntrospectAccessTokenExtended. Also cover backwards compatibility for single-scope legacy tokens. No production code changes needed — the existing introspection passes AccessToken.Scope through as-is, which correctly handles the OAuth2 space-delimited scope format. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api_test.go | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 5424ab8e1d..949e6a4d98 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -726,6 +726,54 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { require.True(t, ok) assert.Equal(t, "Doe", tokenResponse.AdditionalProperties["family_name"]) }) + t.Run("multi-scope token returns space-delimited scope", func(t *testing.T) { + token := AccessToken{ + Expiration: time.Now().Add(time.Hour), + Scope: "urn:nuts:medication-overview patient/Observation.read launch/patient", + } + require.NoError(t, ctx.client.accessTokenServerStore().Put("multi-scope-token", token)) + + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "multi-scope-token"}}) + + require.NoError(t, err) + tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) + require.True(t, ok) + assert.True(t, tokenResponse.Active) + require.NotNil(t, tokenResponse.Scope) + assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read launch/patient", *tokenResponse.Scope) + }) + t.Run("multi-scope token via extended introspection returns space-delimited scope", func(t *testing.T) { + token := AccessToken{ + Expiration: time.Now().Add(time.Hour), + Scope: "urn:nuts:medication-overview patient/Observation.read", + } + require.NoError(t, ctx.client.accessTokenServerStore().Put("multi-scope-ext-token", token)) + + res, err := ctx.client.IntrospectAccessTokenExtended(reqCtx, IntrospectAccessTokenExtendedRequestObject{Body: &TokenIntrospectionRequest{Token: "multi-scope-ext-token"}}) + + require.NoError(t, err) + tokenResponse, ok := res.(IntrospectAccessTokenExtended200JSONResponse) + require.True(t, ok) + assert.True(t, tokenResponse.Active) + require.NotNil(t, tokenResponse.Scope) + assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read", *tokenResponse.Scope) + }) + t.Run("single-scope token (backwards compatibility)", func(t *testing.T) { + // A token issued before the multi-scope feature — only a single scope, no OtherScopes tracked. + token := AccessToken{ + Expiration: time.Now().Add(time.Hour), + Scope: "legacy-single-scope", + } + require.NoError(t, ctx.client.accessTokenServerStore().Put("legacy-token", token)) + + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "legacy-token"}}) + + require.NoError(t, err) + tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) + require.True(t, ok) + assert.True(t, tokenResponse.Active) + assert.Equal(t, "legacy-single-scope", *tokenResponse.Scope) + }) t.Run("InputDescriptorConstraintIdMap contains reserved claim", func(t *testing.T) { token := AccessToken{ Expiration: time.Now().Add(time.Second), From 08c398117b6812236540e3e9ec8530141297e298 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 16:01:59 +0200 Subject: [PATCH 6/7] Add introspection test for claims on multi-scope dynamic tokens Verifies that tokens issued via dynamic scope policy carry their validated credential claims through to the introspection response as AdditionalProperties, enabling resource servers to make authorization decisions without re-processing VPs. Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 949e6a4d98..eca7f41592 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -758,6 +758,30 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { require.NotNil(t, tokenResponse.Scope) assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read", *tokenResponse.Scope) }) + t.Run("multi-scope token carries claims via AdditionalProperties", func(t *testing.T) { + // Tokens issued via dynamic scope policy populate InputDescriptorConstraintIdMap + // from the validated credentials. These claims flow through to the introspection + // response as AdditionalProperties, enabling PDPs/resource servers to make + // authorization decisions without re-processing VPs. + token := AccessToken{ + Expiration: time.Now().Add(time.Hour), + Scope: "urn:nuts:medication-overview patient/Observation.read", + InputDescriptorConstraintIdMap: map[string]any{ + "organization_name": "Hospital B.V.", + "organization_ura": "12345678", + }, + } + require.NoError(t, ctx.client.accessTokenServerStore().Put("dynamic-token", token)) + + res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "dynamic-token"}}) + + require.NoError(t, err) + tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) + require.True(t, ok) + assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read", *tokenResponse.Scope) + assert.Equal(t, "Hospital B.V.", tokenResponse.AdditionalProperties["organization_name"]) + assert.Equal(t, "12345678", tokenResponse.AdditionalProperties["organization_ura"]) + }) t.Run("single-scope token (backwards compatibility)", func(t *testing.T) { // A token issued before the multi-scope feature — only a single scope, no OtherScopes tracked. token := AccessToken{ From 828a1c1fdc12df1c8907adec0371ecc886360b4a Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Thu, 16 Apr 2026 16:16:03 +0200 Subject: [PATCH 7/7] Apply self-review fixes: consolidate integration and introspection tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop tests that duplicated existing unit-test coverage or tested trivial pointer pass-through: - Remove HTTP-500 integration test (covered by authzen client tests) - Remove multi-scope introspection tests (introspection is a pointer pass-through; no multi-scope-specific code exists) - Remove backwards-compat introspection test (no compat code exists) - Remove multi-scope claims introspection test (duplicates existing InputDescriptorConstraintIdMap test) Add the security-critical path: - PDP denies credential profile scope over real HTTP → access_denied Use t.Cleanup for httptest server cleanup (proper subtest scoping). Co-Authored-By: Claude Opus 4.6 (1M context) --- auth/api/iam/api_test.go | 72 -------------------------------- auth/api/iam/integration_test.go | 64 +++++++++++++--------------- 2 files changed, 30 insertions(+), 106 deletions(-) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index eca7f41592..5424ab8e1d 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -726,78 +726,6 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { require.True(t, ok) assert.Equal(t, "Doe", tokenResponse.AdditionalProperties["family_name"]) }) - t.Run("multi-scope token returns space-delimited scope", func(t *testing.T) { - token := AccessToken{ - Expiration: time.Now().Add(time.Hour), - Scope: "urn:nuts:medication-overview patient/Observation.read launch/patient", - } - require.NoError(t, ctx.client.accessTokenServerStore().Put("multi-scope-token", token)) - - res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "multi-scope-token"}}) - - require.NoError(t, err) - tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) - require.True(t, ok) - assert.True(t, tokenResponse.Active) - require.NotNil(t, tokenResponse.Scope) - assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read launch/patient", *tokenResponse.Scope) - }) - t.Run("multi-scope token via extended introspection returns space-delimited scope", func(t *testing.T) { - token := AccessToken{ - Expiration: time.Now().Add(time.Hour), - Scope: "urn:nuts:medication-overview patient/Observation.read", - } - require.NoError(t, ctx.client.accessTokenServerStore().Put("multi-scope-ext-token", token)) - - res, err := ctx.client.IntrospectAccessTokenExtended(reqCtx, IntrospectAccessTokenExtendedRequestObject{Body: &TokenIntrospectionRequest{Token: "multi-scope-ext-token"}}) - - require.NoError(t, err) - tokenResponse, ok := res.(IntrospectAccessTokenExtended200JSONResponse) - require.True(t, ok) - assert.True(t, tokenResponse.Active) - require.NotNil(t, tokenResponse.Scope) - assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read", *tokenResponse.Scope) - }) - t.Run("multi-scope token carries claims via AdditionalProperties", func(t *testing.T) { - // Tokens issued via dynamic scope policy populate InputDescriptorConstraintIdMap - // from the validated credentials. These claims flow through to the introspection - // response as AdditionalProperties, enabling PDPs/resource servers to make - // authorization decisions without re-processing VPs. - token := AccessToken{ - Expiration: time.Now().Add(time.Hour), - Scope: "urn:nuts:medication-overview patient/Observation.read", - InputDescriptorConstraintIdMap: map[string]any{ - "organization_name": "Hospital B.V.", - "organization_ura": "12345678", - }, - } - require.NoError(t, ctx.client.accessTokenServerStore().Put("dynamic-token", token)) - - res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "dynamic-token"}}) - - require.NoError(t, err) - tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) - require.True(t, ok) - assert.Equal(t, "urn:nuts:medication-overview patient/Observation.read", *tokenResponse.Scope) - assert.Equal(t, "Hospital B.V.", tokenResponse.AdditionalProperties["organization_name"]) - assert.Equal(t, "12345678", tokenResponse.AdditionalProperties["organization_ura"]) - }) - t.Run("single-scope token (backwards compatibility)", func(t *testing.T) { - // A token issued before the multi-scope feature — only a single scope, no OtherScopes tracked. - token := AccessToken{ - Expiration: time.Now().Add(time.Hour), - Scope: "legacy-single-scope", - } - require.NoError(t, ctx.client.accessTokenServerStore().Put("legacy-token", token)) - - res, err := ctx.client.IntrospectAccessToken(reqCtx, IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "legacy-token"}}) - - require.NoError(t, err) - tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) - require.True(t, ok) - assert.True(t, tokenResponse.Active) - assert.Equal(t, "legacy-single-scope", *tokenResponse.Scope) - }) t.Run("InputDescriptorConstraintIdMap contains reserved claim", func(t *testing.T) { token := AccessToken{ Expiration: time.Now().Add(time.Second), diff --git a/auth/api/iam/integration_test.go b/auth/api/iam/integration_test.go index fdaea0b9c0..3f0c4be2e2 100644 --- a/auth/api/iam/integration_test.go +++ b/auth/api/iam/integration_test.go @@ -38,10 +38,16 @@ import ( // TestIntegration_DynamicScopePolicy_AuthZenEndToEnd exercises the server-side token handler // with a real AuthZen HTTP client talking to an httptest server. Unlike the unit tests in -// s2s_vptoken_test.go which mock the AuthZen evaluator directly, this test validates the -// full HTTP roundtrip: request serialization, response parsing, and error propagation. +// s2s_vptoken_test.go which mock the AuthZen evaluator, this test validates the full HTTP +// roundtrip: request serialization, response parsing, and outcomes that depend on the +// evaluator actually being called. +// +// Scope is intentionally narrow: scenarios covered by policy/authzen/client_test.go (HTTP +// errors, malformed response, timeouts) or by the s2s unit tests (VP validation, profile-only +// rejection) are not duplicated here. The tests below cover the outcomes that require the +// server-side flow + real HTTP together: approved scopes end up in the token, denied extra +// scopes are excluded, and PDP denial of the credential profile scope blocks token issuance. func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { - // Shared fixtures var presentationDefinition pe.PresentationDefinition require.NoError(t, json.Unmarshal([]byte(`{ "format": { @@ -78,24 +84,22 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) clientID := "https://example.com/oauth2/holder" - t.Run("PDP approves all scopes - token issued with all scopes", func(t *testing.T) { + // startPDP starts an httptest server that responds with the given decisions and captures + // the decoded AuthZen request for post-call assertions. + startPDP := func(t *testing.T, decisions []authzen.EvaluationResult) (*httptest.Server, *authzen.EvaluationsRequest) { var receivedRequest authzen.EvaluationsRequest - pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/access/v1/evaluations", r.URL.Path) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) require.NoError(t, json.NewDecoder(r.Body).Decode(&receivedRequest)) - - resp := authzen.EvaluationsResponse{ - Evaluations: []authzen.EvaluationResult{ - {Decision: true}, - {Decision: true}, - }, - } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(authzen.EvaluationsResponse{Evaluations: decisions}) })) - defer pdpServer.Close() + t.Cleanup(server.Close) + return server, &receivedRequest + } + t.Run("PDP approves all scopes - token issued and request shape correct over the wire", func(t *testing.T) { + pdpServer, receivedRequest := startPDP(t, []authzen.EvaluationResult{{Decision: true}, {Decision: true}}) realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) ctx := newTestClient(t) @@ -114,28 +118,20 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) assert.Equal(t, "example-scope extra-scope", *tokenResponse.Scope) - // Verify the PDP received a well-formed request via actual HTTP roundtrip + // Validate request serialization over the wire (not covered by mock-based unit tests). assert.Equal(t, "organization", receivedRequest.Subject.Type) assert.Equal(t, "request_scope", receivedRequest.Action.Name) assert.Equal(t, "example-scope", receivedRequest.Context.Policy) require.Len(t, receivedRequest.Evaluations, 2) - assert.Equal(t, "scope", receivedRequest.Evaluations[0].Resource.Type) assert.Equal(t, "example-scope", receivedRequest.Evaluations[0].Resource.ID) assert.Equal(t, "extra-scope", receivedRequest.Evaluations[1].Resource.ID) }) t.Run("PDP partial denial - denied scopes excluded from token", func(t *testing.T) { - pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resp := authzen.EvaluationsResponse{ - Evaluations: []authzen.EvaluationResult{ - {Decision: true}, - {Decision: false, Context: &authzen.EvaluationResultContext{Reason: "not permitted"}}, - }, - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) - })) - defer pdpServer.Close() + pdpServer, _ := startPDP(t, []authzen.EvaluationResult{ + {Decision: true}, + {Decision: false, Context: &authzen.EvaluationResultContext{Reason: "not permitted"}}, + }) realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) ctx := newTestClient(t) @@ -155,11 +151,11 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { assert.Equal(t, "example-scope", *tokenResponse.Scope) }) - t.Run("PDP returns HTTP 500 - server_error", func(t *testing.T) { - pdpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer pdpServer.Close() + t.Run("PDP denies credential profile scope - access_denied, no token issued", func(t *testing.T) { + pdpServer, _ := startPDP(t, []authzen.EvaluationResult{ + {Decision: false}, + {Decision: true}, + }) realAuthzenClient := authzen.NewClient(pdpServer.URL, http.DefaultClient) ctx := newTestClient(t) @@ -174,7 +170,7 @@ func TestIntegration_DynamicScopePolicy_AuthZenEndToEnd(t *testing.T) { resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) - _ = assertOAuthErrorWithCode(t, err, oauth.ServerError, "policy decision point unavailable") + _ = assertOAuthErrorWithCode(t, err, oauth.AccessDenied, `PDP denied credential profile scope "example-scope"`) assert.Nil(t, resp) }) }