Repository navigation
Client-side RFC 7523 JWT Bearer grant with two VPs (PSA 10.10) #4078
Description
Problem Statement
The Nuts node currently uses the vp_token-bearer grant type (RFC021) with a single Verifiable Presentation in access token requests. The LSPxNuts PSA section 10.10 OAuth profile requires the RFC 7523 jwt-bearer grant type (PSA 10.10.1) with two separate VPs in the token request (PSA 10.10.6): VP1 from the healthcare provider (zorginstelling) as the authorization grant, and VP2 from the service provider (dienstverlener) as the client assertion. This separation is needed because the party making the technical request (the service provider) is not the same party that holds the authorization (the healthcare provider). The node must support both flows simultaneously to maintain backward compatibility.
Solution
Extend the client-side access token request flow to support the RFC 7523 jwt-bearer grant type with two VPs. The EHR signals intent by passing a new experimental client_id parameter on the existing request-service-access-token endpoint. When client_id is present, the AS advertises jwt-bearer support, and a client PD is configured for the requested credential profile, the node builds two VPs:
- VP1 from the healthcare provider's wallet (identified by the path-param
subjectID), using theorganizationPD. - VP2 from the service provider's wallet (identified by the
client_idparameter), using theclientPD.
When client_id is omitted, the node uses the existing single-VP vp_token-bearer flow. When client_id is present but two-VP cannot be honored (AS does not advertise jwt-bearer or no client PD is configured), the node returns an error rather than silently falling back.
Cross-VP binding (e.g. "the delegation VC in VP2 must be issued by the DID that signed VP1") is expressed in the policy file using standard PE: a constraint field with a shared id across the organization and client PDs. The matcher captures matched values from VP1 and additively populates the existing #4067 credential_selection map for VP2's evaluation. PE documents stay 100% standard.
User Stories
- As an EHR developer, I want to request access tokens from authorization servers that require the RFC 7523 JWT bearer grant type, so that I can integrate with the LSPxNuts use cases.
- As an EHR developer, I want the node to use the two-VP flow when I pass
client_idand the AS supports it, so that I can opt in to JWT bearer per request without configuration changes. - As an EHR developer, I want the existing
request-service-access-tokenAPI to remain unchanged for callers that do not passclient_id, so that my existing integrations don't break. - As an EHR developer, I want to identify the OAuth client (service provider) per request via the API, so that one node can serve multiple SP identities without restart.
- As a Nuts node operator, I want to configure a
clientpresentation definition per credential profile, so that the node knows which SP credentials to include in VP2. - As a use-case designer, I want the service provider and healthcare provider to be authenticated separately in the token request, so that the delegation model is correctly represented.
- As a Nuts node operator, I want the node to sign VP2 with the SP subject's keys and VP1 with the HCP subject's keys, so that each party's identity is cryptographically asserted.
- As an EHR developer, I want clear error messages when the SP wallet lacks the required credentials for VP2 or when two-VP flow cannot be honored, so that I can diagnose configuration issues.
- As an EHR developer, I want the two-layer scope model — credential profile scope and resource scopes (PSA 10.10.2) — to work with the JWT bearer flow, so that SMART on FHIR scopes are forwarded alongside the credential profile.
- As a use-case designer, I want VP2's delegation credential to be selected automatically based on VP1's issuer, so that the delegation chain is enforced by the policy without EHR involvement.
Implementation Decisions
Public surface — all new params experimental
The new client_id API field, the client policy block, and the cross-VP field.id binding convention are all marked experimental, documented as subject to change. Implementation gated behind a feature flag (auth.experimental.jwt_bearer_client = false by default).
API extension
POST /internal/auth/v2/{subjectID}/request-service-access-token gains a single optional field:
{
"authorization_server": "https://verifier.example.com/oauth2/<sp-tenant>",
"scope": "urn:nuts:medication-overview patient/Observation.read patient/MedicationRequest.read",
"client_id": "acme-ehr",
"credential_selection": { "patient_id": "999990123" },
"token_type": "DPoP"
}client_id: Nuts subject identifier of the OAuth client (service provider). When present, triggers the two-VP flow (subject to AS support andclientPD presence). Absent → single-VP flow as today.subjectID(path) — unchanged; identifies the HCP whose wallet supplies VP1.scope— mixed scopes handled per Support mixed OAuth2 scopes with configurable scope policy #4144's scope-policy mechanism.credential_selection— unchanged from Credential selection when multiple credential from wallet match #4067; carries EHR-supplied selections (e.g. patient id). Cross-VP bindings flow through the same map but are populated server-side.
Auto-negotiation and failure modes
When client_id is present:
- Fetch AS metadata. If
urn:ietf:params:oauth:grant-type:jwt-bearernot ingrant_types_supported→ error. - Look up
clientPD for the requested credential profile. If absent → error. - Otherwise → use jwt-bearer two-VP flow.
When client_id is absent → single-VP vp_token-bearer flow (existing behavior).
Two-VP construction
The existing generic VP builder (BuildSubmission) is called twice:
- VP1 (
assertion): built from the HCP wallet (path-paramsubjectID), using theorganizationPD. Authorization grant. - VP2 (
client_assertion): built from the SP wallet (client_id), using theclientPD. Client assertion per RFC 7523. - Both VPs are JWT-encoded.
Cross-VP field binding
PD authors assign a shared id to constraint fields across the organization and client PDs. Example: a delegation VC in VP2 must be issued by the DID that signed VP1.
{
"organization": {
"input_descriptors": [{
"constraints": {
"fields": [
{ "path": ["$.type"], "filter": { "type": "string", "const": "HealthcareProviderCredential" } },
{ "id": "delegating_hcp", "path": ["$.issuer"] }
]
}
}]
},
"client": {
"input_descriptors": [{
"constraints": {
"fields": [
{ "path": ["$.type"], "filter": { "type": "string", "const": "ServiceProviderDelegationCredential" } },
{ "id": "delegating_hcp", "path": ["$.issuer"] }
]
}
}]
}
}Matcher behavior change (small extension to the existing flow):
- While building VP1, after credential selection, call the existing
resolveInputDescriptorValues(auth/api/iam/s2s_vptoken.go, used today for the server-sideInputDescriptorConstraintIdMap) on VP1 to extract{field.id → matched value}. - Before evaluating VP2's
clientPD, additively merge captured values into thecredential_selectionmap (don't overwrite EHR-provided keys; by construction they're equal anyway because VP1 was filtered using them). - Existing
NewFieldSelector(Credential selection when multiple credential from wallet match #4067) does the rest.
PE stays 100% standard.
Token request parameters (jwt-bearer flow, PSA 10.10.6)
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=<VP1 JWT>
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<VP2 JWT>
scope=<credential profile scope + resource scopes>
No presentation_submission form parameter — RFC 7523 does not define one.
SP subject identity
The client_id value must be a Nuts subject managed by the operator via existing wallet APIs. The subject's wallet must contain credentials matching the client PD. The subject follows the node's globally-configured DID-method abstraction (same as HCP subjects).
network.nodedid is not touched by this PRD.
Policy config extension
The policy entry per credential profile gains an optional client block alongside the existing organization and user:
{
"urn:nuts:medication-overview": {
"scope_policy": "passthrough",
"organization": { ... },
"client": { ... },
"user": { ... }
}
}Absent client → falls back to single-VP regardless of API/AS state.
Modules to build/modify
- VP-build flow (
auth/client/iam): extendRequestRFC021AccessTokento negotiate jwt-bearer, build two VPs, and assemble the token request withassertion+client_assertion. Build VP2 by callingBuildSubmissionwith the SP subject andclientPD, passing the mergedcredential_selection. ReuseresolveInputDescriptorValuesfor VP1 field capture — no new evaluator. - Policy config loader: support the
clientPD block. - API binding: accept
client_idon the request body; thread through to the client. - Feature flag:
auth.experimental.jwt_bearer_clientgates the new code paths.
Testing Decisions
A good test verifies that, given a specific AS metadata response, wallet state, policy configuration, and API request, the correct token request is built (single VP or two VPs, correct grant type, correct parameters, correct VP signers).
Modules to test:
- Two-VP flow: VP1 built from HCP wallet using
organizationPD, VP2 from SP wallet usingclientPD, each signed with the correct keys. - Cross-VP binding: when
organizationandclientPDs share afield.id, the captured value flows into VP2's selection. - Grant type negotiation: jwt-bearer selected when AS advertises and
clientPD present; single-VP otherwise. - Failure modes: error when
client_idpresent but AS doesn't support jwt-bearer; error whenclient_idpresent but noclientPD; error when SP wallet lacks required credentials. - Token request construction: correct parameters for both
jwt-bearerandvp_token-bearerflows; nopresentation_submissionform param in the jwt-bearer flow. - Policy config:
clientPD loads alongsideorganizationanduser. - Mixed scopes: scope param built with credential profile + resource scopes per Support mixed OAuth2 scopes with configurable scope policy #4144's scope policy.
- Experimental feature flag: with
auth.experimental.jwt_bearer_client = false, a request carryingclient_idreturns an error (feature disabled). - End-to-end payload check: e2e test that captures the actual HTTP form POST body sent to the AS and asserts it matches the PSA 10.10.6 wire format (grant_type, assertion, client_assertion_type, client_assertion, scope; no presentation_submission).
- Prior art: PoC test in
auth/client/iam/openid4vp_test.go(commit 19f5960),policy/local_test.go.
Impact Assessment
Backwards compatibility: Fully additive. client_id is optional; existing callers see no change. client PD is optional in policy files. network.nodedid is untouched. PE documents stay standard PE.
Versioning: Minor bump.
Configuration/deployment: No new config keys for callers that don't use the feature. Operators using the feature must (a) set auth.experimental.jwt_bearer_client = true, (b) create an SP subject via existing wallet APIs, (c) provision the SP wallet with credentials matching the client PD, and (d) extend their policy file with a client block per credential profile.
Security:
- VP2 is signed with keys the operator already controls; no new key material types.
- The cross-VP binding is more restrictive than current behavior — VP2 candidates narrow to those issued by the exact DID that signed VP1, matching the LSPxNuts delegation trust model.
- Failure modes fail loud (no silent fallback when
client_idis present), avoiding misconfiguration footguns. - Items to verify in implementation: log redaction (DIDs/JWTs in logs), error message info leakage (do "no matching delegation VC" errors disclose wallet contents?), per-request crypto cost (two VP signs vs. one).
PSA stability: PSA 10.10 is ~80% finalized; protocol details may shift. Mitigation: feature flag (default off), and document the PSA draft version pinned-to in the operator docs.
Out of Scope
- Server-side JWT bearer support (covered in a separate PRD)
- Delegation verification by client (PSA 10.10.5)
- DPoP changes (already implemented, PSA 10.10.6)
- URA-based token endpoint addressing (PSA 10.10.3)
- VC-type-specific validation logic
- Credential validation beyond wallet selection
- AuthZen callback (server-side concern, see Support mixed OAuth2 scopes with configurable scope policy #4144)
- Multiple SP identities per node beyond what
client_idper request already allows - Custom error codes (PSA 10.10.10, covered in server-side PRD)
- Issuance of
ServiceProviderCredential/ServiceProviderDelegationCredential(operator handles via existing wallet APIs)
Further Notes
- The
clientPD is optional per credential profile. - Long-term, a standardized AS-side mechanism for advertising per-wallet-owner PDs (e.g., in metadata) could replace the local
clientPD config. No standard exists yet. - This PRD references the LSPxNuts PSA section 10.10 OAuth profile. The spec is approximately 80% finalized; some details may change.
Revision Notes (2026-05-01)
This PRD body was rewritten in this session to fold in design decisions from the original comments and to add an impact assessment. Highlights for a returning reviewer:
SP identity — earlier: rename network.nodedid to a dedicated config field; author comment then proposed auto-creating a service-provider subject on first boot. Now: explicit client_id parameter on the API per request. No config, no auto-creation. network.nodedid is untouched. Marked experimental.
Cross-VP delegation binding — earlier comments explored DCQL queries and templated parameters in PDs. Now: same field.id across organization and client PDs auto-binds via the existing credential_selection plumbing from #4067. PEs stay 100% standard PE; no new query language, no template substitution. The matcher reuses the existing resolveInputDescriptorValues (used today for the introspection InputDescriptorConstraintIdMap) to capture VP1 field values and additively merge them into the selection map for VP2.
Dependency clarification — original referenced #4038, which is closed and superseded by #4144. This PRD depends on #4144 (multi-scope / scope-policy plumbing) for forwarding mixed scopes through the jwt-bearer flow.
API surface — earlier considered a new endpoint or a global SP DID config. Now: existing request-service-access-token extended with a single optional client_id field. Behavior fails loud when client_id is present but two-VP cannot be honored (no silent fallback).
Token request body — dropped presentation_submission form parameter; RFC 7523 does not define one.
Implementation reuse — the original "build a new PD evaluator" module is gone. Replaced by reuse of resolveInputDescriptorValues and NewFieldSelector (#4067).
Experimental gating — new feature flag auth.experimental.jwt_bearer_client (default false) gates all the new code paths.
New sections — Impact Assessment (backwards compat, versioning, config/deployment, security, PSA stability), explicit failure modes, integration test design using the existing POST /internal/vcr/v2/verifier/vp API to validate captured VPs.
User stories — original 9 retained (with US4 reframed from "configure SP DID once globally" to "identify the OAuth client per request"); added US10 covering automatic VP2 delegation selection from VP1's issuer.
Implementation Plan
Feature branch: feature/4078-jwt-bearer-two-vp (cut from feature/4144-mixed-scopes)
| # | Description | PR | Depends on |
|---|---|---|---|
| 1 | Policy config: add service_provider PD block (renamed from client per #4226) |
#4226 | — |
| 2 | Two-VP token request flow (gated by experimental flag) | #4227 | #4226 |
| 3 | API binding: accept service_provider_subject_id (renamed from client_id per #4228) on request-service-access-token |
#4228 | #4227 |
| 4 | Integration test: jwt-bearer two-VP token request payload | #4229 | #4228 |
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
stevenvegt commentedon Mar 23, 2026
Regarding the SP DID config:
Possible solutions:
Start with solution 2 and and the first one can be added later.
Advantages:
stevenvegt commentedon Mar 23, 2026
How to select the ServiceProviderDelegationCredential from the client's wallet?
Proposed solution:
Add parameters to the
clientPD which allows for did's based on the subject-id. This solution is credential type agnostic and adds minimal custom magic.stevenvegt commentedon May 4, 2026
Deviation in PR #4227
DPoP extended to the jwt-bearer two-VP path.
The PRD lists DPoP under Out of Scope: "DPoP changes (already implemented, PSA 10.10.6)". That phrasing applied to the single-VP path. PR #4227 builds the dispatcher that fans
useDPoP(a parameter the public method accepts) into either grant path. Silently dropping it on the two-VP branch would be a footgun: callers that opted into DPoP would receive a bearer-only token without warning, weakening proof-of-possession on the issued token relative to caller intent.The fix wires DPoP through both paths consistently. The DPoP proof on the two-VP path is signed with the SP DID's key (
vp2.Holder) — the SP is the OAuth client per RFC 7523, and the SP wallet is what will present the issued access token, so the proof binding has to be SP-bound.No spec or wire-format change; both paths now behave the same way for the
useDPoP=truecase. Tests cover both branches.Client-side RFC 7523 JWT Bearer grant with two VPs (#4078) (#4275)