Skip to content

Client-side multi-scope token flow#4178

Open
stevenvegt wants to merge 5 commits into4144-1-scope-parsing-and-configfrom
4144-3-client-side-flow
Open

Client-side multi-scope token flow#4178
stevenvegt wants to merge 5 commits into4144-1-scope-parsing-and-configfrom
4144-3-client-side-flow

Conversation

@stevenvegt
Copy link
Copy Markdown
Member

@stevenvegt stevenvegt commented Apr 13, 2026

Parent PRD

#4144

Summary

Modifies the client-side S2S token request flow to support mixed OAuth2 scopes. Introduces a PresentationDefinitionResolver that decides whether to fetch the PD from the remote AS (Nuts-to-Nuts, trust the server) or fall back to local policy resolution (integration with non-Nuts AS), and enforces scope policy when resolving locally.

What changed

New PresentationDefinitionResolver (auth/client/iam/pd_resolver.go):

  • Resolves a PD + the scope string to forward in the token request
  • Remote path: metadata has a PD endpoint → fetch from remote, forward all scopes unchanged
  • Local path: no PD endpoint → call FindCredentialProfile locally; enforce scope policy (profile-only rejects extras; passthrough/dynamic forward all)
  • Nil guard: resolver returns a clear error if no policy backend is configured but the remote has no PD endpoint

OpenID4VPClient wiring (auth/client/iam/openid4vp.go):

  • RequestRFC021AccessToken replaces its direct PD fetch with c.pdResolver.Resolve(...) and uses resolved.Scope for the token request
  • OpenID4VPClient gets a pdResolver field; NewClient takes a policy.PDPBackend parameter and wires the resolver internally

Propagation through Auth (auth/auth.go, cmd/root.go):

  • Auth struct gets a policyBackend field
  • NewAuthInstance takes policy.PDPBackend as a new parameter
  • cmd/root.go reorders: policy.New() moves before auth.NewAuthInstance so it can be passed in

Dynamic = passthrough on client: the client doesn't call the AuthZen PDP. The server enforces dynamic at token-grant time (#4179).

How to review

Start with pd_resolver.go — the core logic:

  • Resolve dispatches to remote or local
  • resolveLocal enforces scope policy and selects the organization PD (TODO comment for Server-side RFC 7523 JWT Bearer grant with two VPs (PSA 10.10) #4080 two-VP flow)
  • Tests in pd_resolver_test.go cover both paths with all scope policies, nil guards, and missing-PD / unknown-scope edge cases (10 cases total)

Then openid4vp.go — the call site change is minimal (replacing the direct PD fetch with pdResolver.Resolve). The important detail is that data.Set(oauth.ScopeParam, resolved.Scope) uses the resolver's output, not the raw input.

Wiring reviewauth.go and cmd/root.go add the policy backend dependency. Note the line reordering in cmd/root.go (policyInstance moved up).

Deviations from spec

  • Spec targeted presentationDefinitionForScope, but that's server-side only. The actual client-side flow goes through RequestRFC021AccessToken. The resolver replaces the direct PD fetch there.
  • Added remote-vs-local PD resolution (not in original spec). The spec assumed the client always resolves PD locally. In practice the client-side flow fetches from the remote PD endpoint. Added local fallback explicitly for non-Nuts AS integrations — this is a meaningful new capability.
  • PresentationDefinitionResolver was added as an abstraction instead of inlining the logic in RequestRFC021AccessToken. Keeps the OAuth client focused on the OAuth flow and makes PD resolution independently testable.
  • Profile-only returns canonical CredentialProfileScope (not raw input) — caught during self-review to avoid echoing whitespace/formatting artifacts.

Dependencies

Depends on: #4176 (base branch, provides FindCredentialProfile and scope policy types).

Independent of #4177 (AuthZen client) — client-side doesn't call the PDP. Can be reviewed in parallel with #4177.

Design context

Acceptance Criteria

  • PresentationDefinitionResolver resolves PD from remote when PD endpoint exists
  • Resolver falls back to local PD via FindCredentialProfile when no remote PD endpoint
  • Local fallback: profile-only rejects extra scopes
  • Local fallback: passthrough and dynamic forward all scopes
  • Remote path: all scopes forwarded, scope policy not enforced locally
  • RequestRFC021AccessToken uses resolver instead of direct PD fetch
  • Single-scope requests work unchanged (backwards compatible)
  • Unit tests cover both resolver paths with all scope policies
  • Nil policy backend guard (added during self-review)
  • Profile-only returns canonical scope, not raw input (added during self-review)
Original implementation spec (used during AI-assisted development)

Parent PRD

#4144

Implementation Spec

Overview

Modify the client-side token request flow to support mixed OAuth2 scopes. Introduces a PresentationDefinitionResolver that abstracts the decision of whether to fetch a PD from the remote AS or fall back to local policy resolution. When using local resolution, scope policy is enforced.

Design decisions

  • PresentationDefinitionResolver abstraction: The client (RequestRFC021AccessToken) should not decide where the PD comes from. A resolver encapsulates the remote-vs-local decision and scope policy enforcement, keeping the client focused on the OAuth flow.
  • Remote PD endpoint → trust remote AS: When the remote AS metadata advertises a PD endpoint, the resolver fetches the PD from there and returns the full scope string. The remote server enforces scope policy (covered by PR Server-side multi-scope flow & scope policy evaluation #4179).
  • No remote PD endpoint → local fallback: When no PD endpoint exists, the resolver calls FindCredentialProfile locally to get the PD and scope classification. Scope policy is enforced locally: profile-only rejects extra scopes, passthrough/dynamic forward all scopes.
  • Dynamic same as passthrough on client side: The client does not call the AuthZen PDP — the server handles dynamic scope evaluation at token-grant time (PR Server-side multi-scope flow & scope policy evaluation #4179).
  • Resolver on OpenID4VPClient: The resolver is a struct dependency, wired through AuthNewClient. Policy backend passed through Auth to enable local PD fallback.

Deviations from original spec

  • Spec assumed presentationDefinitionForScope was the modification point: That helper is server-side only. The actual client-side flow goes through RequestRFC021AccessToken → remote PD endpoint. The resolver replaces the direct PD fetch.
  • Spec didn't account for remote-vs-local PD resolution: The client fetches PDs from the remote AS when possible, falling back to local only when no PD endpoint exists. This enables integration with external (non-Nuts) authorization servers.
  • Policy backend wired through Auth struct: Added policyBackend parameter to NewAuthInstance and iam.NewClient to propagate the policy backend to the resolver.

Modified flow

RequestRFC021AccessToken(ctx, ..., scope, ...)
    1. Fetch remote AS metadata
    2. resolved, err := c.pdResolver.Resolve(ctx, scope, metadata)
       Internally:
       a. If metadata has PD endpoint → fetch from remote, return full scope
       b. If no PD endpoint → FindCredentialProfile locally, enforce scope policy
    3. Build VP using resolved.PresentationDefinition
    4. Send token request with resolved.Scope

Acceptance Criteria

  • PresentationDefinitionResolver resolves PD from remote when PD endpoint exists
  • Resolver falls back to local PD via FindCredentialProfile when no remote PD endpoint
  • Local fallback: profile-only rejects extra scopes
  • Local fallback: passthrough and dynamic forward all scopes
  • Remote path: all scopes forwarded, scope policy not enforced locally
  • RequestRFC021AccessToken uses resolver instead of direct PD fetch
  • Single-scope requests work unchanged (backwards compatible)
  • Unit tests cover both resolver paths with all scope policies
  • Nil policy backend guard (added during self-review)
  • Profile-only returns canonical scope, not raw input (added during self-review)

@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
auth/auth.go100.0%
Coverage rating: A Coverage rating: A
cmd/root.go100.0%
Coverage rating: B Coverage rating: B
auth/client/iam/openid4vp.go20.0%69-87
New file Coverage rating: A
auth/client/iam/pd_resolver.go95.7%63-64
Total79.1%
🤖 Increase coverage with AI coding...

In the `4144-3-client-side-flow` branch, add test coverage for this new code:

- `auth/client/iam/openid4vp.go` -- Line 69-87
- `auth/client/iam/pd_resolver.go` -- Line 63-64

🚦 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 5 commits April 14, 2026 10:06
Introduces PresentationDefinitionResolver that abstracts PD resolution.
When the remote AS metadata advertises a PD endpoint, the PD is fetched
remotely and the full scope string is returned for the token request.
Local fallback path is stubbed for the next cycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When no remote PD endpoint exists, the resolver calls FindCredentialProfile
locally. Profile-only rejects extra scopes, passthrough/dynamic forward all.
Tests cover both remote and local paths with all scope policies.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Replace direct PD fetch in RequestRFC021AccessToken with the
PresentationDefinitionResolver. The resolver is a dependency on
OpenID4VPClient, wired through Auth → NewClient. The policy backend
is passed through Auth to enable local PD fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Add nil guard on policyBackend in resolveLocal
- Return canonical credential profile scope for profile-only (not raw input)
- Add comment explaining dynamic treated same as passthrough on client side
- Add tests: nil policy backend, missing org PD, remote endpoint error
- Fix import grouping in test file

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@stevenvegt stevenvegt force-pushed the 4144-3-client-side-flow branch from f1ee8fb to 0b190e6 Compare April 14, 2026 16:00
@stevenvegt stevenvegt marked this pull request as ready for review April 16, 2026 14:42
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