Skip to content

Client-side RFC 7523 JWT Bearer grant with two VPs (#4078)#4275

Open
stevenvegt wants to merge 101 commits into
masterfrom
feature/4078-jwt-bearer-two-vp
Open

Client-side RFC 7523 JWT Bearer grant with two VPs (#4078)#4275
stevenvegt wants to merge 101 commits into
masterfrom
feature/4078-jwt-bearer-two-vp

Conversation

@stevenvegt
Copy link
Copy Markdown
Member

Closes #4078

Summary

Implements the client-side RFC 7523 jwt-bearer grant with two VPs (PSA 10.10), bundling the four sub-PRs that were merged into this feature branch:

All four were reviewed and approved individually. This PR squashes them into master.

What's new

  • New service_provider PD block per credential profile in the policy file (alongside organization and user).
  • Optional service_provider_subject_id body field on request-service-access-token. When present (and gated on by auth.experimental.jwtbearerclient), the node builds two VPs: VP1 from the HCP wallet using the organization PD, VP2 from the SP wallet using the service_provider PD.
  • Cross-VP binding via standard PE: a shared field.id across the organization and client PDs captures values from VP1 and additively merges them into VP2's credential selection.
  • Token request follows PSA 10.10.6 wire format: grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, assertion=<VP1>, client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer, client_assertion=<VP2>.
  • All new surfaces are experimental and gated behind auth.experimental.jwtbearerclient (default false).

When service_provider_subject_id is absent, the existing single-VP vp_token-bearer flow is unchanged. When it's present but the AS doesn't advertise jwt-bearer or no service_provider PD is configured, the node returns an error rather than silently falling back.

Test plan

  • CI passes (unit + integration tests across all touched packages)
  • Manual: confirm existing single-VP request-service-access-token callers (without service_provider_subject_id) see no change in behavior
  • Manual: confirm two-VP flow with a service_provider PD configured, feature flag on, and an AS that advertises jwt-bearer
  • Manual: confirm error responses when feature flag off, when AS lacks jwt-bearer, and when policy has no service_provider PD

Notes for reviewers

This PR also brings master into the feature branch via merge commit 6b1a4695. The merge reconciles:

  • master's policy.ScopeEvaluator / policy.NewScopeGranter refactor (from Feature/4144 add support for more scope policies #4220's review cleanup) with the feature branch's earlier AuthZenEvaluator-based scope code. Resolution: master's design wins; the orphaned Wrapper.grantedScopesForPolicy and Wrapper.evaluateDynamicScopes helpers were deleted.
  • master's auth/openid4vci package extraction with the feature branch's OpenID4VPClient. Resolution: the OpenID4VCI methods (OpenIdCredentialIssuerMetadata, VerifiableCredentials) were dropped from OpenID4VPClient since they now live in auth/openid4vci.

Both sides' architectural improvements survive intact. See the merge commit body for details.

stevenvegt and others added 30 commits April 13, 2026 12:37
Rename the PDPBackend interface method and introduce new types
(CredentialProfileMatch, ScopePolicy, credentialProfileConfig)
to support mixed OAuth2 scopes. The policy config struct now uses
explicit fields for organization/user PDs and scope_policy,
defaulting to profile-only. All callers and mocks updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests cover: multi-scope with one profile scope + other scopes,
multiple profile scopes (error), no profile scope (error),
and empty scope string (error).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests cover: scope_policy parsed from JSON config (dynamic, passthrough),
invalid scope_policy rejected at load time, dynamic without AuthZen
endpoint fails at startup, passthrough without endpoint succeeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Introduce ErrAmbiguousScope for multiple credential profile scopes
  (instead of wrapping ErrNotFound which was semantically wrong)
- Use strings.Fields instead of strings.Split for robust whitespace handling
- Add nil-check: credential profile must define at least one of organization/user
- Add doc comments on Config, ErrNotFound, FindCredentialProfile implementation
- Use value receiver on toWalletOwnerMapping (small non-mutating struct)
- Add test for consecutive spaces in scope string
- Assert ScopePolicy in multi-scope test
- Make Configure tests load single files instead of whole directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements the HTTP client for the AuthZen Access Evaluations API
(POST /access/v1/evaluations). Request uses AuthZen batch format:
shared subject/action/context with per-scope evaluations array.
Returns scope→decision map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests cover: partial denial, HTTP 500, PDP unreachable, context
cancellation/timeout, evaluation count mismatch, malformed response.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Truncate PDP error body in error messages (prevent log injection)
- Validate duplicate resource IDs before sending request
- Add Accept: application/json header
- Add package doc comment
- Fix require.NoError inside httptest handler (capture request, assert outside)
- Rename context cancellation test for accuracy
- Add duplicate resource ID test
- Response body limiting delegated to StrictHTTPClient (caller responsibility)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
stevenvegt and others added 19 commits May 8, 2026 07:27
…ssToken

@reinkrul: in the two-VP context, the parameter is specifically the
organization (HCP) subject — distinguishing it from the service-provider
subject in the same signature makes the call sites self-documenting.
The dispatcher and the single-VP path keep their generic subjectID
since they don't share a signature with the SP subject.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous constructor took 9 positional arguments, including two
booleans separated by a duration. A struct makes the call site
self-documenting and means future config additions (e.g. the
grant-types-enabled list tracked under #4231) become a one-line
struct change instead of an N-call-site signature break.

The only call site is auth/auth.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The experimental-flag gate in RequestServiceAccessToken returns
synchronously before any HTTP call, so the TLS server fixture set up by
createClientServerTestContext was dead weight for this one test.
createClientTestContext gives just the client and the mocks, no TLS
server, no JSON metadata fixtures.

The other two failure-mode tests (AS-doesn't-advertise, no
service_provider PD) genuinely need the AS metadata HTTP fixture and
correctly stay on createClientServerTestContext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the existing single-VP error tests (BuildSubmission returning
pe.ErrNoCredentials, DID-method mismatch) for the second wallet trip
in the jwt-bearer flow:

- SP wallet returns DIDs whose methods are not in the AS's
  DIDMethodsSupported list — filterDIDsByMethods returns
  ErrPreconditionFailed.
- SP wallet has matching DIDs but BuildSubmission cannot satisfy the
  service_provider PD — pe.ErrNoCredentials must propagate so the API
  layer can map it to 412 Precondition Failed.

Carry-over from #4227 self-review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The OpenAPI field is *string; an explicit empty string would have routed
into the two-VP flow with a meaningless subject and surfaced as a
misleading 412 'did method mismatch' from a downstream ListDIDs("")
call. Reject empty up front with a clear InvalidInputError, and check
that a non-nil subject actually exists locally — same treatment as the
path-param subjectID.

Adds three sub-tests under "service_provider_subject_id":
- ok - threads through to IAM client (also tightened to assert the
  response body, not just NoError)
- empty string is rejected up front
- unknown subject returns 400

Carry-over from #4228 self-review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rephrase ClientConfig doc: 'all required unless noted' was inaccurate
  for bool/duration zero-valued scalars. Now distinguishes interface
  fields (required) from scalars (zero value is valid).
- Add release-notes entry for the new service_provider_subject_id API
  field, parallel to the existing #4226 / #4227 lines.
- Pin DIDMethodsSupported explicitly in the SP method-mismatch test so
  the assertion doesn't silently flip if the default fixture changes.

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

Adds a new top-level section to docs/pages/deployment/policy.rst that
explains, for policy authors and node operators:

- when the two-VP RFC 7523 jwt-bearer flow triggers (all four
  conditions: experimental flag, service_provider_subject_id body
  field, AS metadata advertises jwt-bearer, profile has a
  service_provider PD)
- how VP1 (organization) and VP2 (service_provider) are built and
  which subject's wallet/PD each uses
- cross-VP binding via shared field.id, with a worked example
  showing a delegating_hcp constraint in both PDs that ties VP2's
  delegation credential to VP1's HCP issuer
- the credential_selection map and how server-captured entries are
  additively merged with EHR-supplied ones
- the four-step required configuration

The section is prefaced by a warning that the entire mechanism is
experimental and subject to change while the underlying OAuth profile
stabilises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to the rename in 1c93225 (#4227): the OpenAPI description for
service_provider_subject_id and the new policy.rst section both still
referenced the old auth.experimental.jwt_bearer_client name.
Regenerated bindings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Boots a real Nuts node via test/node/StartServer (with the
experimental flag on, did:web, and a custom policy directory),
provisions four subjects (CIBG / Twiin issuers + HCP organization +
service provider) over the internal HTTP API, issues the three
credentials needed (HealthcareProviderCredential,
ServiceProviderCredential, ServiceProviderDelegationCredential),
stands up a mock authorization server via httptest that advertises
jwt-bearer and captures the form POST, drives the
request-service-access-token endpoint, and asserts:

- form body matches the RFC 7523 wire shape (grant_type,
  client_assertion_type, scope; assertion+client_assertion non-empty;
  no presentation_submission, no client_id)
- both captured VPs round-trip through the same node's
  /internal/vcr/v2/verifier/vp with validity=true
- the cross-VP binding survives end-to-end: the delegation
  credential's issuer equals VP1's signer DID, and its delegatedBy URA
  equals VP1's HCP URA

Negative paths (feature flag off, AS doesn't advertise jwt-bearer,
missing service_provider PD, SP wallet has no matching credentials)
are covered by unit tests in auth/client/iam/openid4vp_test.go and
the handler tests in auth/api/iam/api_test.go; this integration test
focuses on the happy-path round trip that those mock-based tests
cannot cover (real cryptographic signing, real DID resolution, real
verifyVP).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the polling lastForm() helper: the AS /token POST completes
synchronously inside request-service-access-token, so the captured
form is set by the time the API returns 200. capturedForm now fails
the test loudly if /token was never hit, instead of returning an
empty url.Values that would make wire-format asserts misleading.

Propagate ParseForm errors via an atomic so a malformed token-request
form fails the test instead of silently 400-ing the handler.

Replace the generic deepString walker with typed firstSubject and
firstIdentifierValue helpers that encode the actual credential shape.

Drop the idempotent GET-then-POST in provisionSubject; the test runs
against a fresh tempdir node, so the lookup branch was dead code.

Assisted-by: AI
Policy config: add client PD block (4078-1)
API binding: accept service_provider_subject_id on request-service-access-token (4078-3)
Integration test: jwt-bearer two-VP token request payload (4078-4)
@qltysh
Copy link
Copy Markdown
Contributor

qltysh Bot commented May 22, 2026

6 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 9): RequestServiceAccessToken 3
qlty Duplication Found 48 lines of identical code in 2 locations (mass = 71) 2
ripgrep Lint // TODO: This should be part of go-did library; we need to decode a JWT VP (and maybe later VC) and get the 1

@qltysh
Copy link
Copy Markdown
Contributor

qltysh Bot commented May 22, 2026

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on master by 0.11%.

Modified Files with Diff Coverage (9)

RatingFile% DiffUncovered Line #s
Coverage rating: D Coverage rating: C
auth/auth.go100.0%
Coverage rating: A Coverage rating: A
auth/cmd/cmd.go100.0%
Coverage rating: C Coverage rating: C
policy/local.go100.0%
Coverage rating: B Coverage rating: B
vcr/pe/util.go73.9%102-103, 149-150...
Coverage rating: B Coverage rating: B
auth/api/iam/api.go100.0%
Coverage rating: A Coverage rating: A
vcr/pe/presentation_definition.go100.0%
Coverage rating: A Coverage rating: A
auth/client/iam/pd_resolver.go100.0%
Coverage rating: C Coverage rating: C
auth/client/iam/openid4vp.go71.6%94-115, 352-353...
New Coverage rating: A
auth/client/iam/profile_validation.go100.0%
Total78.0%
🤖 Increase coverage with AI coding...
In the `feature/4078-jwt-bearer-two-vp` branch, add test coverage for this new code:

- `auth/client/iam/openid4vp.go` -- Lines 94-115, 352-353, 372-373, 380-382, 387-391, 394-398, 410-411, 428-429, 453-454, 521-522, and 525-526
- `vcr/pe/util.go` -- Lines 102-103, 149-150, 164, and 181

🚦 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.

Reconciles two overlapping refactors:

- master replaced policy.AuthZenEvaluator with the higher-level
  policy.ScopeEvaluator interface (via #4220 review cleanup), factored
  scope-granting into policy.NewScopeGranter, and moved OpenID4VCI
  methods out of OpenID4VPClient into a dedicated auth/openid4vci
  package.
- feature/4078 added the service_provider PD block, the two-VP
  jwt-bearer flow, the iam.ClientConfig struct, and the
  ExperimentalJwtBearerClient feature flag.

Resolution: take master's policy interface and scope-granting path
(deleting the now-redundant grantedScopesForPolicy and
evaluateDynamicScopes helpers on Wrapper), drop the OpenID4VCI methods
on OpenID4VPClient (already replaced by the auth/openid4vci package on
master), and keep the service_provider PD block, ClientConfig, and
cross-VP binding from the feature branch.

Assisted-by: AI
- Factor the DPoP signing + token POST + TokenResponse assembly out of
  requestVPTokenAccessToken and requestJwtBearerAccessToken into a shared
  postTokenRequest method. Removes the qlty-flagged duplication
  (48 identical lines across two locations).
- Run gofmt on imports and map literal alignment to clear the two
  remaining qlty blocking findings.

Assisted-by: AI
@stevenvegt stevenvegt force-pushed the feature/4078-jwt-bearer-two-vp branch from 1fa66ff to 18c5511 Compare May 22, 2026 15:16
Copy link
Copy Markdown
Member

@reinkrul reinkrul left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've already reviewd this one, right?

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.

Client-side RFC 7523 JWT Bearer grant with two VPs (PSA 10.10)

2 participants