You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 the organization PD.
VP2 from the service provider's wallet (identified by the client_id parameter), using the client PD.
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 #4067credential_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_id and 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-token API to remain unchanged for callers that do not pass client_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 client presentation 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:
client_id: Nuts subject identifier of the OAuth client (service provider). When present, triggers the two-VP flow (subject to AS support and client PD presence). Absent → single-VP flow as today.
Fetch AS metadata. If urn:ietf:params:oauth:grant-type:jwt-bearer not in grant_types_supported → error.
Look up client PD 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-param subjectID), using the organization PD. Authorization grant.
VP2 (client_assertion): built from the SP wallet (client_id), using the client PD. 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.
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-side InputDescriptorConstraintIdMap) on VP1 to extract {field.id → matched value}.
Before evaluating VP2's client PD, additively merge captured values into the credential_selection map (don't overwrite EHR-provided keys; by construction they're equal anyway because VP1 was filtered using them).
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:
Absent client → falls back to single-VP regardless of API/AS state.
Modules to build/modify
VP-build flow (auth/client/iam): extend RequestRFC021AccessToken to negotiate jwt-bearer, build two VPs, and assemble the token request with assertion + client_assertion. Build VP2 by calling BuildSubmission with the SP subject and client PD, passing the merged credential_selection. Reuse resolveInputDescriptorValues for VP1 field capture — no new evaluator.
Policy config loader: support the client PD block.
API binding: accept client_id on the request body; thread through to the client.
Feature flag: auth.experimental.jwt_bearer_client gates 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 organization PD, VP2 from SP wallet using client PD, each signed with the correct keys.
Cross-VP binding: when organization and client PDs share a field.id, the captured value flows into VP2's selection.
Grant type negotiation: jwt-bearer selected when AS advertises and client PD present; single-VP otherwise.
Failure modes: error when client_id present but AS doesn't support jwt-bearer; error when client_id present but no client PD; error when SP wallet lacks required credentials.
Token request construction: correct parameters for both jwt-bearer and vp_token-bearer flows; no presentation_submission form param in the jwt-bearer flow.
Policy config: client PD loads alongside organization and user.
Experimental feature flag: with auth.experimental.jwt_bearer_client = false, a request carrying client_id returns 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_id is 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)
Multiple SP identities per node beyond what client_id per 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 client PD 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 client PD 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)
Related: #4144, #3965, #4067
Problem Statement
The Nuts node currently uses the
vp_token-bearergrant type (RFC021) with a single Verifiable Presentation in access token requests. The LSPxNuts PSA section 10.10 OAuth profile requires the RFC 7523jwt-bearergrant 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-bearergrant type with two VPs. The EHR signals intent by passing a new experimentalclient_idparameter on the existingrequest-service-access-tokenendpoint. Whenclient_idis present, the AS advertisesjwt-bearersupport, and aclientPD is configured for the requested credential profile, the node builds two VPs:subjectID), using theorganizationPD.client_idparameter), using theclientPD.When
client_idis omitted, the node uses the existing single-VPvp_token-bearerflow. Whenclient_idis present but two-VP cannot be honored (AS does not advertisejwt-beareror noclientPD 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
idacross theorganizationandclientPDs. The matcher captures matched values from VP1 and additively populates the existing #4067credential_selectionmap for VP2's evaluation. PE documents stay 100% standard.User Stories
client_idand the AS supports it, so that I can opt in to JWT bearer per request without configuration changes.request-service-access-tokenAPI to remain unchanged for callers that do not passclient_id, so that my existing integrations don't break.clientpresentation definition per credential profile, so that the node knows which SP credentials to include in VP2.Implementation Decisions
Public surface — all new params experimental
The new
client_idAPI field, theclientpolicy block, and the cross-VPfield.idbinding convention are all marked experimental, documented as subject to change. Implementation gated behind a feature flag (auth.experimental.jwt_bearer_client = falseby default).API extension
POST /internal/auth/v2/{subjectID}/request-service-access-tokengains 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_idis present:urn:ietf:params:oauth:grant-type:jwt-bearernot ingrant_types_supported→ error.clientPD for the requested credential profile. If absent → error.When
client_idis absent → single-VPvp_token-bearerflow (existing behavior).Two-VP construction
The existing generic VP builder (
BuildSubmission) is called twice:assertion): built from the HCP wallet (path-paramsubjectID), using theorganizationPD. Authorization grant.client_assertion): built from the SP wallet (client_id), using theclientPD. Client assertion per RFC 7523.Cross-VP field binding
PD authors assign a shared
idto constraint fields across theorganizationandclientPDs. 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):
resolveInputDescriptorValues(auth/api/iam/s2s_vptoken.go, used today for the server-sideInputDescriptorConstraintIdMap) on VP1 to extract{field.id → matched value}.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).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)
No
presentation_submissionform parameter — RFC 7523 does not define one.SP subject identity
The
client_idvalue must be a Nuts subject managed by the operator via existing wallet APIs. The subject's wallet must contain credentials matching theclientPD. The subject follows the node's globally-configured DID-method abstraction (same as HCP subjects).network.nodedidis not touched by this PRD.Policy config extension
The policy entry per credential profile gains an optional
clientblock alongside the existingorganizationanduser:{ "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
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.clientPD block.client_idon the request body; thread through to the client.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:
organizationPD, VP2 from SP wallet usingclientPD, each signed with the correct keys.organizationandclientPDs share afield.id, the captured value flows into VP2's selection.clientPD present; single-VP otherwise.client_idpresent but AS doesn't support jwt-bearer; error whenclient_idpresent but noclientPD; error when SP wallet lacks required credentials.jwt-bearerandvp_token-bearerflows; nopresentation_submissionform param in the jwt-bearer flow.clientPD loads alongsideorganizationanduser.auth.experimental.jwt_bearer_client = false, a request carryingclient_idreturns an error (feature disabled).auth/client/iam/openid4vp_test.go(commit 19f5960),policy/local_test.go.Impact Assessment
Backwards compatibility: Fully additive.
client_idis optional; existing callers see no change.clientPD is optional in policy files.network.nodedidis 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 theclientPD, and (d) extend their policy file with aclientblock per credential profile.Security:
client_idis present), avoiding misconfiguration footguns.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
client_idper request already allowsServiceProviderCredential/ServiceProviderDelegationCredential(operator handles via existing wallet APIs)Further Notes
clientPD is optional per credential profile.clientPD config. No standard exists yet.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.nodedidto a dedicated config field; author comment then proposed auto-creating aservice-providersubject on first boot. Now: explicitclient_idparameter on the API per request. No config, no auto-creation.network.nodedidis untouched. Marked experimental.Cross-VP delegation binding — earlier comments explored DCQL queries and templated parameters in PDs. Now: same
field.idacrossorganizationandclientPDs auto-binds via the existingcredential_selectionplumbing from #4067. PEs stay 100% standard PE; no new query language, no template substitution. The matcher reuses the existingresolveInputDescriptorValues(used today for the introspectionInputDescriptorConstraintIdMap) 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-tokenextended with a single optionalclient_idfield. Behavior fails loud whenclient_idis present but two-VP cannot be honored (no silent fallback).Token request body — dropped
presentation_submissionform parameter; RFC 7523 does not define one.Implementation reuse — the original "build a new PD evaluator" module is gone. Replaced by reuse of
resolveInputDescriptorValuesandNewFieldSelector(#4067).Experimental gating — new feature flag
auth.experimental.jwt_bearer_client(defaultfalse) 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/vpAPI 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 fromfeature/4144-mixed-scopes)service_providerPD block (renamed fromclientper #4226)service_provider_subject_id(renamed fromclient_idper #4228) onrequest-service-access-token