diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index ba9be2cd5..5424ab8e1 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -190,7 +190,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("ok", func(t *testing.T) { test := newTestClient(t) - test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "example-scope", WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope"}}) @@ -215,7 +215,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { walletOwnerMapping := pe.WalletOwnerMapping{pe.WalletOwnerUser: pe.PresentationDefinition{Id: "test"}} test := newTestClient(t) - test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "example-scope", WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope", WalletOwnerType: &userWalletType}}) @@ -227,7 +227,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("err - unknown wallet type", func(t *testing.T) { test := newTestClient(t) - test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + test.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "example-scope", WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{SubjectID: verifierSubject, Params: PresentationDefinitionParams{Scope: "example-scope", WalletOwnerType: &userWalletType}}) @@ -289,7 +289,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { OpenIDProvider: serverMetadata, }, } - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "test"}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "test", WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "test"}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) ctx.iamClient.EXPECT().OpenIDConfiguration(gomock.Any(), holderURL.String()).Return(&configuration, nil) ctx.jar.EXPECT().Create(verifierDID, verifierURL.String(), holderClientID, gomock.Any()).DoAndReturn(func(client did.DID, clientID string, audience string, modifier requestObjectModifier) jarRequest { req := createJarRequest(client, clientID, audience, modifier) @@ -1571,23 +1571,24 @@ func statusCodeFrom(err error) int { } type testCtx struct { - authnServices *auth.MockAuthenticationServices - ctrl *gomock.Controller - client *Wrapper - documentOwner *didsubject.MockDocumentOwner - iamClient *iam.MockClient - jwtSigner *cryptoNuts.MockJWTSigner - keyResolver *resolver.MockKeyResolver - policy *policy.MockPDPBackend - resolver *resolver.MockDIDResolver - relyingParty *oauthServices.MockRelyingParty - vcr *vcr.MockVCR - vdr *vdr.MockVDR - vcIssuer *issuer.MockIssuer - vcVerifier *verifier.MockVerifier - wallet *holder.MockWallet - subjectManager *didsubject.MockManager - jar *MockJAR + authnServices *auth.MockAuthenticationServices + ctrl *gomock.Controller + client *Wrapper + documentOwner *didsubject.MockDocumentOwner + iamClient *iam.MockClient + jwtSigner *cryptoNuts.MockJWTSigner + keyResolver *resolver.MockKeyResolver + policy *policy.MockPDPBackend + authzenEvaluator *policy.MockAuthZenEvaluator + resolver *resolver.MockDIDResolver + relyingParty *oauthServices.MockRelyingParty + vcr *vcr.MockVCR + vdr *vdr.MockVDR + vcIssuer *issuer.MockIssuer + vcVerifier *verifier.MockVerifier + wallet *holder.MockWallet + subjectManager *didsubject.MockManager + jar *MockJAR } func newTestClient(t testing.TB) *testCtx { @@ -1600,6 +1601,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b storageEngine := storage.NewTestStorageEngine(t) authnServices := auth.NewMockAuthenticationServices(ctrl) policyInstance := policy.NewMockPDPBackend(ctrl) + authzenEvaluator := policy.NewMockAuthZenEvaluator(ctrl) mockResolver := resolver.NewMockDIDResolver(ctrl) relyingPary := oauthServices.NewMockRelyingParty(ctrl) vcIssuer := issuer.NewMockIssuer(ctrl) @@ -1641,21 +1643,22 @@ 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, + authzenEvaluator: authzenEvaluator, + relyingParty: relyingPary, + vcIssuer: vcIssuer, + vcVerifier: vcVerifier, + resolver: mockResolver, + documentOwner: mockDocumentOwner, + subjectManager: subjectManager, + iamClient: iamClient, + vcr: mockVCR, + wallet: mockWallet, + keyResolver: keyResolver, + jwtSigner: jwtSigner, + jar: mockJAR, + client: client, } } diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index fe6bb045e..231149fc9 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 52e73713f..86fa35e4c 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -126,7 +126,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("failed to generate authorization request", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "test", WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) params := defaultParams() ctx.iamClient.EXPECT().OpenIDConfiguration(context.Background(), holderClientID).Return(&oauth.OpenIDConfiguration{ Metadata: oauth.EntityStatementMetadata{ @@ -142,7 +142,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("failed to resolve OpenID configuration", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "test").Return(&policy.CredentialProfileMatch{CredentialProfileScope: "test", WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: PresentationDefinition{}}, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) params := defaultParams() ctx.iamClient.EXPECT().OpenIDConfiguration(context.Background(), holderClientID).Return(nil, assert.AnError) diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index c215ea426..8c5cbaf23 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -23,11 +23,14 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/policy/authzen" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -73,11 +76,17 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin return nil, err } } - walletOwnerMapping, err := r.presentationDefinitionForScope(ctx, scope) + match, err := r.findCredentialProfile(ctx, scope) if err != nil { return nil, err } - pexConsumer := newPEXConsumer(walletOwnerMapping) + if match.ScopePolicy == policy.ScopePolicyProfileOnly && len(match.OtherScopes) > 0 { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidScope, + Description: "scope policy 'profile-only' does not allow additional scopes", + } + } + pexConsumer := newPEXConsumer(match.WalletOwnerMapping) if err := pexConsumer.fulfill(*submission, *pexEnvelope); err != nil { return nil, oauthError(oauth.InvalidRequest, err.Error()) } @@ -107,15 +116,108 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin } } + // Compute granted scopes based on scope policy. Never pass through the raw input scope + // directly — always derive granted scopes from the policy decision. + grantedScope, err := r.grantedScopesForPolicy(ctx, match, credentialSubjectID, *pexConsumer) + if err != nil { + return nil, err + } + // All OK, allow access issuerURL := r.subjectToBaseURL(subject) - response, err := r.createAccessToken(issuerURL.String(), clientID, time.Now(), scope, *pexConsumer, dpopProof) + response, err := r.createAccessToken(issuerURL.String(), clientID, time.Now(), grantedScope, *pexConsumer, dpopProof) if err != nil { return nil, err } return HandleTokenRequest200JSONResponse(*response), nil } +// grantedScopesForPolicy returns the scopes to include in the access token based on the scope policy. +// Profile-only grants only the credential profile scope. Passthrough grants the credential profile +// scope plus all other requested scopes. Dynamic calls the configured AuthZen PDP for per-scope evaluation. +func (r Wrapper) grantedScopesForPolicy(ctx context.Context, match *policy.CredentialProfileMatch, subjectDID did.DID, pexState PEXConsumer) (string, error) { + switch match.ScopePolicy { + case policy.ScopePolicyProfileOnly: + return match.CredentialProfileScope, nil + case policy.ScopePolicyPassthrough: + scopes := append([]string{match.CredentialProfileScope}, match.OtherScopes...) + return strings.Join(scopes, " "), nil + case policy.ScopePolicyDynamic: + return r.evaluateDynamicScopes(ctx, match, subjectDID, pexState) + default: + return "", oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("unsupported scope policy: %s", match.ScopePolicy), + } + } +} + +// evaluateDynamicScopes calls the AuthZen PDP to evaluate each requested scope. +// Returns the space-joined granted scopes. If the PDP denies the credential profile scope, +// the request is rejected. Other denied scopes are simply excluded from the granted set. +func (r Wrapper) evaluateDynamicScopes(ctx context.Context, match *policy.CredentialProfileMatch, subjectDID did.DID, pexState PEXConsumer) (string, error) { + evaluator := r.policyBackend.AuthZenEvaluator() + if evaluator == nil { + // Should be caught at startup by policy.LocalPDP.Configure, but guard here defensively. + return "", oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "dynamic scope policy configured but no AuthZen evaluator available", + } + } + credentialMap, err := pexState.credentialMap() + if err != nil { + return "", oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "failed to extract credentials for scope evaluation", + InternalError: err, + } + } + claims, err := resolveInputDescriptorValues(pexState.RequiredPresentationDefinitions, credentialMap) + if err != nil { + return "", err + } + allScopes := append([]string{match.CredentialProfileScope}, match.OtherScopes...) + request := authzen.EvaluationsRequest{ + Subject: authzen.Subject{ + Type: "organization", + ID: subjectDID.String(), + Properties: authzen.SubjectProperties{ + Organization: claims, + }, + }, + Action: authzen.Action{Name: "request_scope"}, + Context: authzen.EvaluationContext{Policy: match.CredentialProfileScope}, + Evaluations: make([]authzen.Evaluation, len(allScopes)), + } + for i, s := range allScopes { + request.Evaluations[i] = authzen.Evaluation{Resource: authzen.Resource{Type: "scope", ID: s}} + } + + decisions, err := evaluator.Evaluate(ctx, request) + if err != nil { + // Keep Description generic to avoid leaking PDP internals to the OAuth2 client. + // Details remain available in InternalError for server-side logging. + return "", oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "policy decision point unavailable", + InternalError: err, + } + } + if !decisions[match.CredentialProfileScope] { + return "", oauth.OAuth2Error{ + Code: oauth.AccessDenied, + Description: fmt.Sprintf("PDP denied credential profile scope %q", match.CredentialProfileScope), + } + } + granted := []string{match.CredentialProfileScope} + for _, s := range match.OtherScopes { + if decisions[s] { + granted = append(granted, s) + } + } + return strings.Join(granted, " "), nil +} + func resolveInputDescriptorValues(presentationDefinitions pe.WalletOwnerMapping, credentialMap map[string]vc.VerifiableCredential) (map[string]any, error) { fieldsMap := make(map[string]any) for _, definition := range presentationDefinitions { diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index a8f9e0ed2..eb95d1961 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -27,6 +27,7 @@ import ( "errors" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/policy/authzen" "go.uber.org/mock/gomock" "net/http" "testing" @@ -118,7 +119,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("JSON-LD VP", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -165,7 +166,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { require.NoError(t, token.Set(jwt.AudienceKey, issuerClientID)) }, verifiableCredential) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -200,7 +201,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("replay attack (nonce is reused)", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil).Times(2) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil).Times(2) _, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) @@ -211,7 +212,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JSON-LD VP is missing nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { proof.Domain = &issuerClientID proof.Nonce = nil @@ -224,7 +225,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JSON-LD VP has empty nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { proof.Domain = &issuerClientID proof.Nonce = new(string) @@ -237,7 +238,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP is missing nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Remove("nonce") @@ -249,7 +250,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP has empty nonce", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Set("nonce", "") @@ -261,7 +262,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("JWT VP nonce is not a string", func(t *testing.T) { ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerClientID) _ = token.Set("nonce", true) @@ -297,7 +298,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("VP verification fails", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid")) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) @@ -364,7 +365,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, otherVerifiableCredential) ctx := newTestClient(t) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - presentation submission does not conform to presentation definition (id=)") @@ -375,13 +376,136 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { httpRequest := &http.Request{Header: http.Header{"Dpop": []string{"invalid"}}} httpRequest.Header.Set("DPoP", "invalid") contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) - ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), requestedScope).Return(&policy.CredentialProfileMatch{CredentialProfileScope: requestedScope, WalletOwnerMapping: walletOwnerMapping, ScopePolicy: policy.ScopePolicyProfileOnly}, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw()) _ = 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().AuthZenEvaluator().Return(ctx.authzenEvaluator) + // Verify the AuthZen request shape matches the PRD contract. + ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) { + assert.Equal(t, "organization", req.Subject.Type) + assert.Equal(t, "request_scope", req.Action.Name) + assert.Equal(t, "example-scope", req.Context.Policy) + require.Len(t, req.Evaluations, 2) + assert.Equal(t, "scope", req.Evaluations[0].Resource.Type) + assert.Equal(t, "example-scope", req.Evaluations[0].Resource.ID) + assert.Equal(t, "extra-scope", req.Evaluations[1].Resource.ID) + 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().AuthZenEvaluator().Return(ctx.authzenEvaluator) + ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(map[string]bool{ + "example-scope": true, + "extra-scope": true, + "other-scope": false, + }, nil) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope other-scope", submissionJSON, presentation.Raw()) + + require.NoError(t, err) + tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) + assert.Equal(t, "example-scope extra-scope", *tokenResponse.Scope) + }) + t.Run("dynamic scope policy - PDP denies credential profile scope - access_denied", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) + ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(map[string]bool{ + "example-scope": false, + "extra-scope": true, + }, nil) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + _ = assertOAuthErrorWithCode(t, err, oauth.AccessDenied, `PDP denied credential profile scope "example-scope"`) + assert.Nil(t, resp) + }) + t.Run("dynamic scope policy - PDP error returns server_error", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().FindCredentialProfile(gomock.Any(), "example-scope extra-scope").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "example-scope", + WalletOwnerMapping: walletOwnerMapping, + ScopePolicy: policy.ScopePolicyDynamic, + OtherScopes: []string{"extra-scope"}, + }, nil) + ctx.policy.EXPECT().AuthZenEvaluator().Return(ctx.authzenEvaluator) + ctx.authzenEvaluator.EXPECT().Evaluate(gomock.Any(), gomock.Any()).Return(nil, errors.New("PDP unreachable")) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, "example-scope extra-scope", submissionJSON, presentation.Raw()) + + _ = assertOAuthErrorWithCode(t, err, oauth.ServerError, "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 2d19128a5..81c3c6659 100644 --- a/auth/api/iam/validation.go +++ b/auth/api/iam/validation.go @@ -27,7 +27,6 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vcr/pe" ) // validatePresentationSigner checks if the presenter of the VP is the same as the subject of the VCs being presented. @@ -78,7 +77,7 @@ func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresenta } } -func (r Wrapper) presentationDefinitionForScope(ctx context.Context, scope string) (pe.WalletOwnerMapping, error) { +func (r Wrapper) findCredentialProfile(ctx context.Context, scope string) (*policy.CredentialProfileMatch, error) { match, err := r.policyBackend.FindCredentialProfile(ctx, scope) if err != nil { if errors.Is(err, policy.ErrNotFound) || errors.Is(err, policy.ErrAmbiguousScope) { @@ -94,5 +93,5 @@ func (r Wrapper) presentationDefinitionForScope(ctx context.Context, scope strin Description: fmt.Sprintf("failed to retrieve presentation definition for scope (%s): %s", scope, err.Error()), } } - return match.WalletOwnerMapping, nil + return match, nil } diff --git a/policy/interface.go b/policy/interface.go index 00ab47661..67ed291f2 100644 --- a/policy/interface.go +++ b/policy/interface.go @@ -21,6 +21,7 @@ package policy import ( "context" "errors" + "github.com/nuts-foundation/nuts-node/policy/authzen" "github.com/nuts-foundation/nuts-node/vcr/pe" ) @@ -59,6 +60,13 @@ type CredentialProfileMatch struct { OtherScopes []string } +// AuthZenEvaluator evaluates OAuth2 scopes against an external AuthZen-compatible PDP. +// The interface allows PDPBackend implementations to provide the evaluator (or nil +// when no endpoint is configured) without exposing concrete client types. +type AuthZenEvaluator interface { + Evaluate(ctx context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) +} + // PDPBackend is the interface for the policy backend. // Both the remote and local policy backend implement this interface. type PDPBackend interface { @@ -66,4 +74,7 @@ type PDPBackend interface { // It parses the space-delimited scope string, identifies exactly one credential profile scope, // and returns the matched profile along with any remaining scopes. FindCredentialProfile(ctx context.Context, scope string) (*CredentialProfileMatch, error) + // AuthZenEvaluator returns the configured AuthZen evaluator for dynamic scope policy evaluation. + // Returns nil when no AuthZen endpoint is configured. + AuthZenEvaluator() AuthZenEvaluator } diff --git a/policy/local.go b/policy/local.go index e5efbddea..b8f6d02a0 100644 --- a/policy/local.go +++ b/policy/local.go @@ -23,13 +23,19 @@ import ( "encoding/json" "fmt" "github.com/nuts-foundation/nuts-node/core" + httpClient "github.com/nuts-foundation/nuts-node/http/client" + "github.com/nuts-foundation/nuts-node/policy/authzen" "github.com/nuts-foundation/nuts-node/vcr/pe" v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" "io" "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: @@ -52,6 +58,9 @@ type LocalPDP struct { config Config // mapping holds the credential profile configuration per scope mapping map[string]credentialProfileConfig + // authzenClient is created during Configure when an AuthZen endpoint is configured. + // It is nil when no endpoint is configured. + authzenClient AuthZenEvaluator } func (b *LocalPDP) Name() string { @@ -81,10 +90,18 @@ func (b *LocalPDP) Configure(_ core.ServerConfig) error { return fmt.Errorf("credential profile %q has scope_policy %q but no AuthZen endpoint is configured (policy.authzen.endpoint)", scope, ScopePolicyDynamic) } } + } else { + // Use StrictHTTPClient: enforces TLS, bounds response body size, applies timeout. + b.authzenClient = authzen.NewClient(b.config.AuthZen.Endpoint, httpClient.New(authzenTimeout)) } return nil } +// AuthZenEvaluator returns the AuthZen evaluator, or nil when no endpoint is configured. +func (b *LocalPDP) AuthZenEvaluator() AuthZenEvaluator { + return b.authzenClient +} + func (b *LocalPDP) Config() interface{} { return &b.config } diff --git a/policy/mock.go b/policy/mock.go index 8e05e7600..fd51dbfe2 100644 --- a/policy/mock.go +++ b/policy/mock.go @@ -13,9 +13,49 @@ import ( context "context" reflect "reflect" + authzen "github.com/nuts-foundation/nuts-node/policy/authzen" gomock "go.uber.org/mock/gomock" ) +// MockAuthZenEvaluator is a mock of AuthZenEvaluator interface. +type MockAuthZenEvaluator struct { + ctrl *gomock.Controller + recorder *MockAuthZenEvaluatorMockRecorder + isgomock struct{} +} + +// MockAuthZenEvaluatorMockRecorder is the mock recorder for MockAuthZenEvaluator. +type MockAuthZenEvaluatorMockRecorder struct { + mock *MockAuthZenEvaluator +} + +// NewMockAuthZenEvaluator creates a new mock instance. +func NewMockAuthZenEvaluator(ctrl *gomock.Controller) *MockAuthZenEvaluator { + mock := &MockAuthZenEvaluator{ctrl: ctrl} + mock.recorder = &MockAuthZenEvaluatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAuthZenEvaluator) EXPECT() *MockAuthZenEvaluatorMockRecorder { + return m.recorder +} + +// Evaluate mocks base method. +func (m *MockAuthZenEvaluator) Evaluate(ctx context.Context, req authzen.EvaluationsRequest) (map[string]bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Evaluate", ctx, req) + ret0, _ := ret[0].(map[string]bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Evaluate indicates an expected call of Evaluate. +func (mr *MockAuthZenEvaluatorMockRecorder) Evaluate(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Evaluate", reflect.TypeOf((*MockAuthZenEvaluator)(nil).Evaluate), ctx, req) +} + // MockPDPBackend is a mock of PDPBackend interface. type MockPDPBackend struct { ctrl *gomock.Controller @@ -40,6 +80,20 @@ func (m *MockPDPBackend) EXPECT() *MockPDPBackendMockRecorder { return m.recorder } +// AuthZenEvaluator mocks base method. +func (m *MockPDPBackend) AuthZenEvaluator() AuthZenEvaluator { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AuthZenEvaluator") + ret0, _ := ret[0].(AuthZenEvaluator) + return ret0 +} + +// AuthZenEvaluator indicates an expected call of AuthZenEvaluator. +func (mr *MockPDPBackendMockRecorder) AuthZenEvaluator() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthZenEvaluator", reflect.TypeOf((*MockPDPBackend)(nil).AuthZenEvaluator)) +} + // FindCredentialProfile mocks base method. func (m *MockPDPBackend) FindCredentialProfile(ctx context.Context, scope string) (*CredentialProfileMatch, error) { m.ctrl.T.Helper()