Two-VP token request flow (4078-2)#4227
Open
stevenvegt wants to merge 37 commits intofeature/4078-jwt-bearer-two-vpfrom
Open
Two-VP token request flow (4078-2)#4227stevenvegt wants to merge 37 commits intofeature/4078-jwt-bearer-two-vpfrom
stevenvegt wants to merge 37 commits intofeature/4078-jwt-bearer-two-vpfrom
Conversation
|
Coverage Impact ⬇️ Merging this pull request will decrease total coverage on Modified Files with Diff Coverage (9)
🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
Adds the WalletOwnerClient constant and threads an optional `client` PresentationDefinition through the policy config loader. Consumers iterating WalletOwnerMapping see the new entry only when a profile declares a `client` block; existing org-only profiles are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Locks the invariant that the client field uses the validating type, so a malformed client PD (e.g. missing input_descriptors) is rejected at load time with the same schema error as organization and user. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Relaxes the load-time check from "at least one of organization or user" to "at least one of organization, client, or user", so a profile that only configures the OAuth-client PD loads. The error message is updated to list all three valid blocks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fold the new client-PD subtests into TestStore_LoadFromFile so loading is exercised in one place, and assert WalletOwnerMapping contents instead of just length so an org-only profile no longer accidentally qualifies as a passing absence-of-client check. - Cover the new "no PD defined" validation branch with a dedicated fixture (invalid/no_pds.json), and tighten the malformed-client fixture to contain only a malformed client block so the schema error can only originate from that field. - Document the client block in the operator policy guide, including the RFC021 vs RFC 7523 audience caveat, and add a release-notes entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 tasks
The original "client" name overloaded an already-busy term in this codebase (httpClient, authzenClient, the OAuth client_id form param) and required a comment to disambiguate. Switching to the domain term "service_provider" — the role this PD describes in the LSPxNuts delegation model — removes that ambiguity and matches the snake_case convention used by sibling compound keys (wallet_owner_type, scope_policy, auth.experimental.jwt_bearer_client). Renames the WalletOwnerClient constant, the credentialProfileConfig field, the JSON config key, the test fixtures and directory, the operator-doc paragraph, and the release-notes entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sn't Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends RequestRFC021AccessToken's signature with an optional serviceProviderSubjectID *string and registers the experimental auth.experimental.jwt_bearer_client config flag (default false). Mock and existing call sites updated to pass nil; behaviour is unchanged. The new parameter is currently unused — gate logic and the two-VP flow follow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When serviceProviderSubjectID is set but auth.experimental.jwt_bearer_client is false, the request is rejected immediately. The flag value is passed into OpenID4VPClient via NewClient so tests can exercise both states by setting the field directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the JwtBearerGrantType / JwtBearerClientAssertionType / client- assertion form-param constants and fails the request if the resolved authorization-server metadata does not list jwt-bearer in grant_types_supported. This is the no-silent-fallback rule from the PRD: when the caller has opted into the two-VP flow, an unsupported peer must produce a clear error rather than dropping back to RFC021. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Once the gate and AS-advertisement checks pass, the two-VP path looks up the credential profile from the local policy backend (the service_provider PD has no remote-endpoint equivalent) and fails loud when the resolved profile does not contain a service_provider PresentationDefinition. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When serviceProviderSubjectID is set, the request branches into a dedicated path that builds VP1 from the HCP wallet using the organization PD and VP2 from the SP wallet using the service_provider PD, and posts a form body with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, the two VPs as assertion and client_assertion, and no presentation_submission. Extracts filterDIDsByMethods so the new path and the existing single-VP path share the AS-method filtering instead of duplicating the loop. Cross-VP field-id binding is intentionally not yet wired — the next cycle adds the resolveInputDescriptorValues capture and the additive merge into credential_selection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After VP1 is built, resolve the organization PD's id-bearing constraint fields against the submitted credentials and additively merge the resulting field-id → value pairs into the credential_selection map passed to VP2. Existing keys (EHR-supplied) are not overwritten; non-string field values are skipped. This realizes the cross-VP binding that lets a service_provider PD constrain VP2 by values matched in VP1 (for example, a delegating-HCP issuer DID flowing from VP1 into VP2's credential selection) without introducing any non-standard PE features. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve currently requires the caller to wrap a Verifiable Presentation in an Envelope, which forces callers that already hold a parsed VP in memory to round-trip through Raw() + ParseEnvelope just to drop the VP back into a struct so its private asInterface field can be used for JSONPath traversal. ResolveVP wraps the conversion: it computes asInterface from the VP directly and delegates to Resolve. parseJSONObjectOrStringEnvelope is refactored to share the same vpAsInterface helper so the JWT-vs-JSON-LD extraction logic stays in one place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace the parse-envelope + submission.Resolve dance with PresentationSubmission.ResolveVP, since we already hold the parsed VP1 in memory after the wallet builds it. - Drop the duplicated supportedDIDMethods argument on buildSubmissionForSubject; the same value is already carried on holder.BuildParams.DIDMethods. - Rename the FindCredentialProfile result variable from match to profile — what we hold is the credential profile, not the act of matching it. - Stop setting the OAuth client_id form parameter in the jwt-bearer path. Per RFC 7521 §4.2 the client_assertion authenticates the client, so client_id is optional; including it would force the API layer to plumb a service-provider base URL through purely for the form field. - Drop the cross-VP orchestration tests; the helper they exercised (mergeCapturedFields) has been replaced by the smaller applyCapturedFieldsToSelection, unit-tested directly with plain maps. The happy-path orchestration test stays as the smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RequestRFC021AccessToken now reads as a thin dispatcher: gate the two-VP flag, fetch AS metadata once, then call requestJwtBearerAccessToken or requestVPTokenAccessToken. Both grant-specific paths live as private methods named after the grant type they assemble, so the structural parallel is obvious and neither path leaks implementation detail into the entry point. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The function used to assemble only the RFC021 vp_token-bearer flow,
but it now also dispatches to the RFC 7523 jwt-bearer two-VP path.
Renaming aligns with the public API endpoint
POST /internal/auth/v2/{subjectID}/request-service-access-token and
abstracts the protocol choice that the function makes internally.
Mechanical rename of the interface, implementation, mock, callers and
test references; no behavioural change. The interface docstring is
also updated to drop the misleading "using Nuts RFC021" qualifier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both grant-specific paths used to repeat the same preamble: FindCredentialProfile, reject extras under profile-only, require an organization PD, collapse the resolved scope. The rules and error messages were identical; only the surrounding orchestration differed. loadAndValidateProfile now owns those rules; pdResolver.resolveLocal and requestJwtBearerAccessToken both call it and add only their flow-specific bits (single-VP picks the org PD; two-VP additionally requires the service_provider PD). Tests follow the same shape: TestLoadAndValidateProfile covers every scope-policy and missing-PD scenario with just a policyBackend mock — no HTTP server, no wallet, no subject manager. The two orchestration- level scope-policy tests in TestRelyingParty_RequestServiceAccessToken_TwoVP are removed; the helper test stands in for them and runs in a fraction of the setup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
requestVPTokenAccessToken used to inline the ListDIDs → filterDIDsByMethods → expand additionalCredentials per DID → wallet.BuildSubmission sequence — the same logic that buildSubmissionForSubject already encapsulates and that requestJwtBearerAccessToken calls twice. Have single-VP call the same helper so both grant paths share the primitive and the inline copy goes away. The call shape into the wallet is byte-identical, so the existing single-VP test suite still passes without touch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3b47ab5 to
b2ba045
Compare
ResolveVP constructed an Envelope with an empty `raw []byte` field. Resolve never consults raw, so today nothing breaks — but any caller that inspected the wrapped envelope and called MarshalJSON would panic on `e.raw[0]`. Set raw from presentation.Raw() so the constructed envelope is a fully-formed value. Also tighten the doc comment to mention what the returned map is keyed by, so callers don't have to jump to Resolve to learn the contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dispatcher accepted useDPoP and silently dropped it on the
two-VP path. DPoP binds the issued access token to a key the holder
controls — for the jwt-bearer flow that holder is the service
provider (the OAuth client per RFC 7523). Sign the proof with the
SP DID's key (taken from vp2.Holder) so callers that opt into DPoP
get the same proof-of-possession on both grant types.
Extracts signDPoPHeader as a small helper that returns ("","",nil)
when useDPoP is false, so both grant paths can call it
unconditionally and stop branching on the bool around the dpop()
call.
Also fixes the existing happy-path fixture: vp2.Holder must be set
because the new DPoP-DID derivation reads it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The happy-path test stubs both BuildSubmission calls with empty
PresentationSubmission{}, so the cross-VP capture chain runs over an
empty descriptor map and asserts nothing. Add a focused subtest that
returns a real VP1 + submission shaped to capture a constraint field
(`delegating_hcp` = `did:test:hcp`) and asserts that the second
BuildSubmission receives the captured value in its
credential_selection argument.
Closes the e2e gap on the PRD's "cross-VP binding works without EHR
involvement" acceptance criterion. The pure-function helper is still
covered by TestApplyCapturedFieldsToSelection; this test guards the
wiring around it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both grant paths sent `resolvedScope` (collapsed to the credential profile scope under profile-only, or the full input string under passthrough) on the wire to the AS, but echoed the raw `scopes` input back on the TokenResponse. A caller inspecting the response would see scopes the AS never granted. Return what was actually sent so the response matches the request. Pre-existing in the single-VP path; mirrored across to the new jwt-bearer path while we're here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add a release-notes entry for the experimental auth.experimental.jwt_bearer_client flag so operators discover the feature in the changelog rather than having to read the diff. - Spell out the HCP/SP abbreviations in requestJwtBearerAccessToken's function comment, and note that both PDs are resolved from the local policy backend (the AS's remote presentation_definition endpoint is not consulted in this flow — no standard exists yet for the AS to advertise a service_provider PD). - Document on the Client interface that additionalCredentials are offered to both wallets in the two-VP flow, that signed VCs flow through unchanged, and that unsigned self-attested credentials get per-holder-DID issuance via AutoCorrectSelfAttestedCredential. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JwtBearerClientAssertionType is a client_assertion_type value, not an OAuth grant_type. Living under the "// grant types" header was misleading for readers scanning the file by section. Promote it to its own "// client assertion types" block so it sits next to the ClientAssertionTypeParam param it parameterises. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Catches up the deployment configuration table with two flags that were not regenerated when their commits landed: - auth.experimental.jwt_bearer_client (this PR) - policy.authzen.endpoint (#4144) Operators can now discover both from the docs without having to read the source. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subtests previously did ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true which couples them to the concrete type behind the Client interface and bypasses the constructor. Wrap the field-poke in a small enableJwtBearerClient(t, ctx) helper so subtests can express intent without the type assertion noise. The "feature disabled" subtest now relies on the zero-value default explicitly (with a comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mutating the caller-supplied selection map risked leaking captured field values into anything that retained the original reference (request DTO, logs, retries, caches). Always allocate a fresh map so the function is a pure value-in / value-out helper. The contract is now unambiguous: callers must use the return value, and the input map is never touched. A new subtest pins the no-mutation guarantee. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three rejection sites in the two-VP path used plain errors.New / fmt.Errorf, which the API layer cannot map cleanly to OAuth status codes — they fall through to a generic 500 instead of the appropriate 4xx. Switch them to oauth.OAuth2Error matching the existing pattern already used inside loadAndValidateProfile: - "experimental flag off" / "AS does not advertise jwt-bearer" → UnsupportedGrantType (RFC 6749 §5.2) - "no service_provider PD configured for scope" → InvalidScope (matches the existing profile-only-extras error from loadAndValidateProfile) The "AS doesn't advertise" test is tightened to assert the error type and code so the contract is locked in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 tasks
reinkrul
requested changes
May 4, 2026
@reinkrul flagged that adding a one-call syntactic-sugar method to PresentationSubmission widens the otherwise-clean API for marginal benefit. Move the conversion into a dedicated constructor on the Envelope side: pe.NewEnvelopeFromVP(vp) returns an *Envelope, callers hand it to the existing PresentationSubmission.Resolve. Same correctness — Envelope.raw is populated from vp.Raw() so MarshalJSON stays panic-free — without growing PresentationSubmission's surface. Caller in auth/client/iam picks up two lines (constructor + error check) instead of one. Test renamed and adjusted to exercise the new constructor + Resolve combination. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per @reinkrul: include the spec'd grant-type URN in the error description so EHR developers can grep for it directly in the AS's metadata response. The new wording reads: authorization server does not advertise "urn:ietf:params:oauth:grant-type:jwt-bearer" in grant_types_supported Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@reinkrul: "don't we have a constant for this?" — there wasn't one. Both grant paths used `time.Now().Add(time.Second * 5)` inline. Pull out vpAssertionLifetime so the policy is documented once and a future change touches one line instead of two. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both wallet.BuildSubmission calls used the same params struct, so VP2 was signed with VP1's nonce. Per spec each Verifiable Presentation must carry its own nonce; reuse would let a verifier confuse the two assertions or accept a replayed pairing. Regenerate params.Nonce between the VP1 and VP2 builds. Expires is intentionally left untouched — the two calls run within milliseconds and the lifetime is already short. A focused subtest captures both BuildSubmission params and asserts the nonces differ. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@reinkrul: "EHR developers will have no idea what they're doing wrong here." The cross-VP-binding error chain only carried the input descriptor ID and the credential ID; the failing JSONPath — the most useful piece for whoever is fixing the policy file — was inside the wrapped error, but unattributed. Two changes, one direction: - vcr/pe/matchField now wraps getValueAtPath / matchFilter errors with `path %q: %w`, so the path that failed is part of the chain for every caller (introspection, server-side, this PR's two-VP). - The three cross-VP error sites in requestJwtBearerAccessToken (envelope-build, submission-resolve, constraint-fields-resolve) return typed oauth.OAuth2Error{ServerError} with operator-facing descriptions naming the artefact ("organization presentation definition" for the constraint-fields case) so the API layer maps to a recognisable status code. Sample chain end to end: failed to extract cross-VP binding values from VP1 against the organization presentation definition: failed to match constraint for input descriptor 'id_org_cred' and credential 'urn:vc:42': path "$.issuer": <inner jsonpath/filter error> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@reinkrul: VP1/VP2 are non-descriptive. Rename the local variables in requestJwtBearerAccessToken and the TwoVP test fixtures to use the role of each VP at the call site: vp1 -> organizationVP vp1Submission -> organizationSubmission vp2 -> serviceProviderVP vp1Params -> organizationVPParams vp2Params -> serviceProviderVPParams The trace log labels and the cross-VP test's chain comment update to match. No behavioural change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…jectID @reinkrul: the interface parameter was named subjectDID, but it actually receives a subject identifier (a string used to look up the subject's wallet DIDs), not a DID itself. The implementation has always called it subjectID. Match the names. Mock regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 new issues
|
reinkrul
approved these changes
May 4, 2026
stevenvegt
added a commit
that referenced
this pull request
May 5, 2026
Threads the new optional OpenAPI field through the handler to the IAM client's existing serviceProviderSubjectID parameter (added in #4227). The two-VP dispatch and feature gating already live in the IAM client; the handler's job here is just to forward the value. Named service_provider_subject_id rather than client_id (which the PRD body suggested) to avoid overloading with the OAuth client_id form parameter and to match the convention adopted in #4226 (service_provider PD block) and the internal serviceProviderSubjectID parameter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stevenvegt
added a commit
that referenced
this pull request
May 5, 2026
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>
stevenvegt
added a commit
that referenced
this pull request
May 5, 2026
- 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Parent PRD
#4078
Summary
Adds the RFC 7523 jwt-bearer two-VP token request flow inside the access-token client, gated behind
auth.experimental.jwt_bearer_client(default off). The entry point — formerlyRequestRFC021AccessToken, renamed toRequestServiceAccessToken— is now a thin dispatcher: when an SP subject identifier is supplied it builds VP1 from the HCP wallet (organization PD) and VP2 from the SP wallet (service_provider PD), then POSTs them asassertionandclient_assertion. When no SP identifier is supplied, the existing single-VPvp_token-bearerflow runs unchanged. No public API surface changes — that lands in PR #4228.What changed
Entry-point reshape (
auth/client/iam/openid4vp.go)RequestRFC021AccessTokenrenamed toRequestServiceAccessToken(matches the public HTTP endpoint name, no longer claims to do only RFC021).requestVPTokenAccessToken(existing single-VP) orrequestJwtBearerAccessToken(new two-VP).buildSubmissionForSubject(DID listing + AS-method filtering + per-DIDadditionalCredentialsexpansion + walletBuildSubmission) and the newsignDPoPHeaderhelper.Two-VP path (
requestJwtBearerAccessToken)loadAndValidateProfilehelper. The two-VP flow is local-only — there is no AS-side mechanism to advertise a service_provider PD.pe.PresentationDefinition.ResolveConstraintsFields→ merges captured values intocredential_selection→ builds VP2 from SP wallet using the merged selection.grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer,assertion=<VP1 raw>,client_assertion_type,client_assertion=<VP2 raw>,scope=<resolved scope>. Nopresentation_submissionand noclient_id(per RFC 7521 §4.2 the client is authenticated by the assertion).pdResolver.resolveLocal:profile-onlywith extra scopes returnsoauth.InvalidScope;passthroughforwards the full input.Shared helpers
loadAndValidateProfile(auth/client/iam/profile_validation.go) —FindCredentialProfile+ scope-policy + organization-PD-required checks. Called from bothpdResolver.resolveLocaland the two-VP path; the rules and error messages are now defined once.applyCapturedFieldsToSelection— pure function, takes the existing selection map and the captured field values, returns a fresh map (never mutates caller's input). String-only; non-string captured values are dropped.pe.PresentationSubmission.ResolveVP(vcr/pe/presentation_submission.go) — convenience wrapper aroundResolvefor callers that already hold a parsed VP, so they don't need theRaw()+ParseEnveloperound-trip.signDPoPHeader— returns zero values whenuseDPoP=falseso both grant paths can call it unconditionally.Configuration
auth.Config.Experimental.JwtBearerClient(new nestedExperimentalblock).auth.experimental.jwt_bearer_client, defaultfalse. Description carries "Subject to change without notice".OAuth constants (
auth/oauth/types.go)JwtBearerGrantType,JwtBearerClientAssertionType,ClientAssertionTypeParam,ClientAssertionParam. The client-assertion-type lives in its own const block; it isn't a grant type.Errors
oauth.OAuth2Error(UnsupportedGrantType/InvalidScope) so the API layer maps them to 4xx, not generic 500.Tests
TestRelyingParty_RequestServiceAccessToken_TwoVP(orchestration) — gate, AS-advertise, missing SP-PD, happy-path form body, cross-VP binding e2e, DPoP-with-SP-key.TestLoadAndValidateProfile(unit) — every scope-policy + missing-PD scenario with just a policyBackend mock; no HTTP, wallet, or subject-manager mocks.TestApplyCapturedFieldsToSelection(unit) — merge invariants on plain maps, including the new no-mutation guarantee.TestPresentationSubmission_ResolveVP(invcr/pe) — guards the new convenience method.TestRelyingParty_RequestServiceAccessToken(single-VP) tests preserved and still passing.Bonus fix
resolvedScopeonTokenResponse.Scopeinstead of the raw input. Pre-existing bug in single-VP, mirrored across.How to review
auth/client/iam/openid4vp.go:250-266— the dispatcher. ~15 lines, makes the structural shape obvious.auth/client/iam/openid4vp.go:330-405—requestJwtBearerAccessToken. Read alongside the inline comment about cross-VP binding (the four lines where parse → resolve descriptors → extract constraint fields → merge). ThesignDPoPHeaderderivation fromvp2.Holderis the SP-as-OAuth-client decision in code form.auth/client/iam/profile_validation.go— the shared profile/scope helper. Trivial to read top-to-bottom;pdResolver.resolveLocalbecomes a 6-line caller of it.vcr/pe/presentation_submission.go:166-179— theResolveVPaddition. Single new method, delegates toResolve; fixes a previously-latent panic where the wrappedEnvelope'srawfield was nil.TestRelyingParty_RequestServiceAccessToken_TwoVP/captured_VP1_field-id_values_flow_into_VP2_credential_selection_end-to-endis the hardest one to read; it has line-by-line comments explaining the cross-VP capture chain.pdResolver(always local — no AS advertisement standard exists). Documented in therequestJwtBearerAccessTokendoc comment.client_idform param in jwt-bearer mode. Per RFC 7521 §4.2.additionalCredentialsflow into both wallets; each PD selects what matches. Documented on the interface.Deviations from spec
RequestRFC021AccessToken→RequestServiceAccessToken. The function dispatches between RFC021 and RFC 7523 now, so the old RFC021 name no longer fits. The new name matches the public API endpoint.client→service_provider(landed in PR Policy config: add client PD block (4078-1) #4226). Avoids overloadinghttpClient/authzenClientand the OAuthclient_idform parameter; matches the PRD's prose ("service provider acting on behalf of an organization").useDPoP, so silently dropping it on the two-VP path would be a footgun. The proof is signed with the SP DID's key (vp2.Holder).client_idform parameter on the jwt-bearer path. Per RFC 7521 §4.2 theclient_assertionauthenticates the client, soclient_idis optional. Omitting it removes the need to plumb a service-provider base URL through this PR (or PR API binding: accept service_provider_subject_id on request-service-access-token (4078-3) #4228) just to fill a form field.loadAndValidateProfileextracted as a shared helper. BothpdResolver.resolveLocalandrequestJwtBearerAccessTokenhad the same FindCredentialProfile + scope-policy + organization-PD checks. Extracted; the rules are now unit-tested directly instead of through orchestration in both flows.pe.PresentationSubmission.ResolveVPadded to vcr/pe. Not in the spec; introduced because the cross-VP capture step had a parsed VP in memory and would otherwise round-trip throughRaw()+ParseEnvelope. Also fixes a latent panic where the constructedEnvelope'srawwas nil.TokenResponse.Scopereturns the resolved scope (collapsed underprofile-only, full input under passthrough) for both flows. Pre-existing bug in single-VP, fixed in passing.pdResolver.resolveLocalexactly.Dependencies
Stacked on #4226 (the policy config loader for the
service_providerPD block). Should be reviewed and merged in order.PR #4228 (API binding) and PR #4229 (integration test) depend on this PR.
Carry-overs to PR #4228
Recorded in PR #4228's body so they aren't forgotten when implementation starts:
*stringsentinel onClient.RequestServiceAccessToken(split into two methods, or reshape).iam.NewClientconstructor — 9 positional args including two booleans; functional options would be cheap once the API binding touches the wiring.ListDIDsempty, secondBuildSubmissionErrNoCredentials.Design context
field.idbinding convention and the failure-loud rule (no silent fallback when the caller opts in to two-VP).field.idmechanism realized here viaResolveConstraintsFields+applyCapturedFieldsToSelection.useDPoPparameter silently downgrade.Original implementation spec (used during AI-assisted development)
Internal client-side change. Adds the two-VP
jwt-bearerflow but does not yet expose it on the public API (no caller can reach it without PR 3). Gated behind an experimental feature flag.What to build
Feature flag
auth.experimental.jwt_bearer_client(defaultfalse).RequestRFC021AccessToken.Two-VP flow inside
RequestRFC021AccessTokenLocated in
auth/client/iam/. Extend its signature to accept an optional SP subject identifier (e.g.clientID *stringor similar — internal API, called from API binding in PR 3).When the SP subject identifier is absent: existing single-VP
vp_token-bearerpath runs unchanged.When the SP subject identifier is present:
urn:ietf:params:oauth:grant-type:jwt-beareris not ingrant_types_supported→ return error.clientPD for the requested credential profile (loaded by PR 1). If absent → return error.subjectID) using theorganizationPD. ExistingBuildSubmissioncall.resolveInputDescriptorValues(auth/api/iam/s2s_vptoken.go) on VP1's selected credentials to extract{field.id → matched value}.credential_selectionmap.BuildSubmissionagain with the SP subject (fromclientID) and theclientPD, passing the mergedcredential_selection.Modules touched
auth/client/iam/core/config registrationresolveInputDescriptorValues,NewFieldSelector(from Credential selection when multiple credential from wallet match #4067).Acceptance Criteria
auth.experimental.jwt_bearer_clientfeature flag registered.RequestServiceAccessTokenaccepts an optional SP subject identifier (renamed — see Deviations).field.id) works without EHR involvement.oauth.OAuth2Error).