Skip to content

Server-side multi-scope flow & scope policy evaluation#4179

Open
stevenvegt wants to merge 10 commits into4144-2-authzen-clientfrom
4144-4-server-side-flow
Open

Server-side multi-scope flow & scope policy evaluation#4179
stevenvegt wants to merge 10 commits into4144-2-authzen-clientfrom
4144-4-server-side-flow

Conversation

@stevenvegt
Copy link
Copy Markdown
Member

@stevenvegt stevenvegt commented Apr 13, 2026

Parent PRD

#4144

Summary

Enforces scope policy on the server side of the S2S (RFC021) token flow. Rejects extra scopes in profile-only mode, forwards all in passthrough, and calls an AuthZen PDP for dynamic evaluation. The access token's scope field reflects the granted scopes, never the raw request.

What changed

Server-side token handler (auth/api/iam/s2s_vptoken.go):

  • grantedScopesForPolicy switch derives granted scopes explicitly per policy — never passes the raw input through
  • evaluateDynamicScopes builds an AuthZen EvaluationsRequest, calls the PDP, and maps per-scope decisions to the granted set
  • Profile-only rejects extras early (before expensive VP verification)
  • Dynamic: PDP denial of credential profile scope → access_denied; other denials simply excluded; PDP error → generic server_error (details in InternalError, not in Description)

Policy interface (policy/interface.go, policy/local.go):

  • New AuthZenEvaluator interface in the policy package (Evaluate(ctx, req) (map[string]bool, error))
  • PDPBackend gets an AuthZenEvaluator() method returning the evaluator or nil
  • LocalPDP.Configure creates an authzen.Client (via StrictHTTPClient — 1MB body limit, 10s timeout, TLS enforced) when an endpoint is configured

Helper rename (auth/api/iam/validation.go):

  • presentationDefinitionForScopefindCredentialProfile, now returns the full *CredentialProfileMatch
  • Call sites in s2s_vptoken.go and openid4vp.go updated to use match.WalletOwnerMapping where they only needed the PDs

Claim extraction: reuses the existing resolveInputDescriptorValues (same pattern as introspection) to build Subject.Properties.Organization — no new helper needed.

Mock regenerated: policy.MockPDPBackend now has AuthZenEvaluator(); new policy.MockAuthZenEvaluator added.

How to review

Start with s2s_vptoken.go — the policy-dispatch is the core of this PR:

  • handleS2SAccessTokenRequest — placement of the profile-only early reject (before VP verification) and the call to grantedScopesForPolicy after verification
  • grantedScopesForPolicy — the switch statement, easy to follow
  • evaluateDynamicScopes — AuthZen request construction, error paths (nil evaluator, PDP error, profile scope denial)

Then policy/interface.go + policy/local.go — the evaluator wiring:

  • AuthZenEvaluator interface is one-method, kept on the policy side so callers don't import policy/authzen directly for the type
  • LocalPDP.Configure creates the client when endpoint is configured — note StrictHTTPClient usage for body limit / timeout

Tests (s2s_vptoken_test.go):

  • 8 new subtests cover all three policies + dynamic error paths
  • The "PDP approves all" test verifies request shape on the wire (subject.type, action.name, context.policy, evaluations layout) via DoAndReturn — catches regressions in request construction

Deviations from spec

  • Claim extraction simpler than planned: the PRD mentioned a dedicated ExtractSubjectProperties helper. Instead reused resolveInputDescriptorValues — same extraction as introspection, no new helper.
  • AuthZen client on the policy module, not the Wrapper: the PRD suggested wiring the AuthZen client as "an optional dependency on the Wrapper". Moved it behind PDPBackend.AuthZenEvaluator() — keeps ownership with the module that owns the config and avoids dependency-order issues in cmd/root.go (config isn't loaded when RegisterRoutes runs).
  • Helper renamed from presentationDefinitionForScope to findCredentialProfile — the return value now is the full match, not just a mapping.

Known follow-ups

Dependencies

Depends on:

Review order: Review #4176 and #4177 first; this PR builds directly on both.

Design context

Acceptance Criteria

  • Server parses multi-scope strings and validates VP against credential profile scope's PD
  • Profile-only rejects extra scopes (before expensive VP verification)
  • Passthrough grants all requested scopes
  • Dynamic calls AuthZen PDP and respects per-scope decisions
  • PDP denial of credential profile scope → access_denied error
  • PDP unreachable/timeout → server_error
  • Token response scope field contains only granted scopes
  • AuthZen client wired via PDPBackend.AuthZenEvaluator() (nil when not configured)
  • Unit tests cover all three scope policies and error cases
  • AuthZen request shape verified in tests
  • HTTP client uses timeout + response body limit (memory #4185)
  • Auth-code flow scope policy enforcement — deferred to Apply scope policy to OpenID4VP / auth-code flow token issuance #4202
Original implementation spec (used during AI-assisted development)

Parent PRD

#4144

Implementation Spec

Overview

Modify the server-side token request flow to support mixed OAuth2 scopes with scope policy evaluation. The server parses the incoming scope string, validates the VP against the credential profile scope's PD, then applies the scope policy to determine which scopes are granted.

Key files modified

  • auth/api/iam/validation.go — Renamed presentationDefinitionForScopefindCredentialProfile, returns full CredentialProfileMatch
  • auth/api/iam/s2s_vptoken.go — Scope policy enforcement, dynamic PDP evaluation via AuthZen
  • auth/api/iam/openid4vp.go — Updated caller to use new helper
  • auth/api/iam/api.go — No Wrapper changes (evaluator accessed via policyBackend.AuthZenEvaluator())
  • policy/interface.go — Added AuthZenEvaluator interface + AuthZenEvaluator() method on PDPBackend
  • policy/local.go — Creates authzen.Client during Configure when endpoint is set (using StrictHTTPClient)

Design decisions

  • AuthZen client ownership in policy module: The policy module owns the config and creates the AuthZen client during Configure() (when config is loaded). PDPBackend exposes it via AuthZenEvaluator(). This avoids wiring through cmd/root.go before config is available.
  • Explicit granted scopes derivation: grantedScopesForPolicy explicitly computes granted scopes per policy — no implicit pass-through of raw input. Prevents silent scope leakage when new policies are added.
  • Claims via resolveInputDescriptorValues: Same extraction pattern as introspection. InputDescriptorConstraintIdMap is reused as the AuthZen Subject.Properties.Organization payload.
  • StrictHTTPClient: AuthZen client uses http/client.New(timeout) — enforces TLS, bounds response body size to 1MB, applies 10s timeout (memory #4185).

Deviations from original spec

  • Claim extraction simpler than planned: The PRD mentioned building a dedicated ExtractSubjectProperties helper. Instead we reuse resolveInputDescriptorValues which already does the extraction for introspection. Single pattern, no new helper.
  • AuthZen client on policy module, not Wrapper: The PRD suggested wiring the AuthZen client as an "optional dependency on the Wrapper". Moving it behind PDPBackend.AuthZenEvaluator() keeps ownership with the module that owns the config and avoids dependency order issues in cmd/root.go.

Known follow-ups

Acceptance Criteria

  • Server parses multi-scope strings and validates VP against credential profile scope's PD
  • Profile-only rejects extra scopes (before expensive VP verification)
  • Passthrough grants all requested scopes
  • Dynamic calls AuthZen PDP and respects per-scope decisions
  • PDP denial of credential profile scope → access_denied error
  • PDP unreachable/timeout → server_error
  • Token response scope field contains only granted scopes
  • AuthZen client wired via PDPBackend.AuthZenEvaluator() (evaluates to nil when not configured)
  • Unit tests cover all three scope policies and error cases
  • AuthZen request shape verified in tests (subject.type, action.name, context.policy, evaluations)
  • HTTP client uses timeout + response body limit (memory #4185)
  • Auth-code flow scope policy enforcement (deferred to Apply scope policy to OpenID4VP / auth-code flow token issuance #4202)

@qltysh
Copy link
Copy Markdown

qltysh bot commented Apr 13, 2026

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (4)

RatingFile% DiffUncovered Line #s
Coverage rating: C Coverage rating: C
policy/local.go50.0%101-103
Coverage rating: B Coverage rating: B
auth/api/iam/openid4vp.go100.0%
Coverage rating: B Coverage rating: B
auth/api/iam/s2s_vptoken.go77.9%147-151, 161-166...
Coverage rating: B Coverage rating: B
auth/api/iam/validation.go100.0%
Total77.1%
🤖 Increase coverage with AI coding...
In the `4144-4-server-side-flow` branch, add test coverage for this new code:

- `auth/api/iam/s2s_vptoken.go` -- Lines 147-151, 161-166, 169-174, and 177-178
- `policy/local.go` -- Line 101-103

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

stevenvegt and others added 9 commits April 15, 2026 06:33
Returns the full CredentialProfileMatch instead of only WalletOwnerMapping.
Callers that only need WalletOwnerMapping access match.WalletOwnerMapping.
Prepares for scope policy enforcement on the server side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Profile-only scope policy rejects token requests with extra scopes
beyond the credential profile scope. Check happens early, before
expensive VP signature verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Verifies that passthrough scope policy grants all requested scopes.
No implementation change needed — existing code already passes the
full scope string through when not rejected by profile-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the implicit pass-through of the raw input scope with an
explicit grantedScopesForPolicy switch. Profile-only grants only
the credential profile scope. Passthrough grants the profile scope
plus other scopes. Dynamic returns an error (not yet implemented).

Prevents accidental scope pass-through when a new policy is added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LocalPDP creates an authzen.Client during Configure when an AuthZen
endpoint is configured. PDPBackend exposes it via AuthZenEvaluator(),
returning nil when no endpoint is set.

This keeps AuthZen client ownership in the policy module (which owns
the config) and avoids wiring through cmd/root.go before config is loaded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When scope_policy is 'dynamic', the server builds an AuthZen batch
evaluation request from the validated credentials (claims extracted
via resolveInputDescriptorValues, matching introspection behavior)
and calls the PDP. The credential profile scope must be approved
by the PDP or the request is denied. Other scopes are granted only
when the PDP approves them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rror

- Partial denial: denied other scopes excluded, approved ones granted
- PDP denies credential profile scope: request rejected (access_denied)
- PDP call fails: server_error returned with details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use StrictHTTPClient (timeout + response body limit) for AuthZen client
  instead of http.DefaultClient (memory #4185)
- Wrap credentialMap() / resolveInputDescriptorValues errors as OAuth2Error
  to preserve the spec-compliant error response contract
- Use generic Description for PDP errors, keep details in InternalError
  to avoid leaking PDP internals to the OAuth2 client
- Tighten dynamic-approves-all test to verify AuthZen request shape
  (subject.type, action.name, context.policy, evaluations layout)
- Fix AuthZenEvaluator interface doc comment
- Apply gofmt

Follow-up issues:
- #4202: apply scope policy to OpenID4VP / auth-code flow
- Claim role-bucket mismatch deferred to #4080 (two-VP flow)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@stevenvegt stevenvegt force-pushed the 4144-4-server-side-flow branch from 485a64b to de5213b Compare April 16, 2026 12:40
@stevenvegt stevenvegt marked this pull request as ready for review April 16, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support mixed OAuth2 scopes with configurable scope policy

1 participant