Skip to content

Two-VP token request flow (4078-2)#4227

Open
stevenvegt wants to merge 37 commits intofeature/4078-jwt-bearer-two-vpfrom
4078-2-two-vp-flow
Open

Two-VP token request flow (4078-2)#4227
stevenvegt wants to merge 37 commits intofeature/4078-jwt-bearer-two-vpfrom
4078-2-two-vp-flow

Conversation

@stevenvegt
Copy link
Copy Markdown
Member

@stevenvegt stevenvegt commented May 1, 2026

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 — formerly RequestRFC021AccessToken, renamed to RequestServiceAccessToken — 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 as assertion and client_assertion. When no SP identifier is supplied, the existing single-VP vp_token-bearer flow runs unchanged. No public API surface changes — that lands in PR #4228.

What changed

Entry-point reshape (auth/client/iam/openid4vp.go)

  • RequestRFC021AccessToken renamed to RequestServiceAccessToken (matches the public HTTP endpoint name, no longer claims to do only RFC021).
  • Becomes a thin dispatcher: gate the experimental flag, fetch AS metadata once, then call requestVPTokenAccessToken (existing single-VP) or requestJwtBearerAccessToken (new two-VP).
  • Both grant-specific paths share buildSubmissionForSubject (DID listing + AS-method filtering + per-DID additionalCredentials expansion + wallet BuildSubmission) and the new signDPoPHeader helper.

Two-VP path (requestJwtBearerAccessToken)

  • Resolves both PDs from the local policy backend via the new loadAndValidateProfile helper. The two-VP flow is local-only — there is no AS-side mechanism to advertise a service_provider PD.
  • Builds VP1 from HCP wallet → resolves the submission against VP1 → extracts id-bearing constraint field values via pe.PresentationDefinition.ResolveConstraintsFields → merges captured values into credential_selection → builds VP2 from SP wallet using the merged selection.
  • Assembles the form body with grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, assertion=<VP1 raw>, client_assertion_type, client_assertion=<VP2 raw>, scope=<resolved scope>. No presentation_submission and no client_id (per RFC 7521 §4.2 the client is authenticated by the assertion).
  • DPoP, when requested, signs the proof with VP2's holder key (the SP DID) — the SP is the OAuth client and will present the issued token.
  • Scope policy mirrors pdResolver.resolveLocal: profile-only with extra scopes returns oauth.InvalidScope; passthrough forwards the full input.

Shared helpers

  • loadAndValidateProfile (auth/client/iam/profile_validation.go) — FindCredentialProfile + scope-policy + organization-PD-required checks. Called from both pdResolver.resolveLocal and 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 around Resolve for callers that already hold a parsed VP, so they don't need the Raw() + ParseEnvelope round-trip.
  • signDPoPHeader — returns zero values when useDPoP=false so both grant paths can call it unconditionally.

Configuration

  • auth.Config.Experimental.JwtBearerClient (new nested Experimental block).
  • Flag: auth.experimental.jwt_bearer_client, default false. 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

  • The three jwt-bearer rejections (feature flag off, AS doesn't advertise, no service_provider PD) return typed 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 (in vcr/pe) — guards the new convenience method.
  • All existing TestRelyingParty_RequestServiceAccessToken (single-VP) tests preserved and still passing.

Bonus fix

  • Both flows now return resolvedScope on TokenResponse.Scope instead of the raw input. Pre-existing bug in single-VP, mirrored across.

How to review

  1. Start with auth/client/iam/openid4vp.go:250-266 — the dispatcher. ~15 lines, makes the structural shape obvious.
  2. auth/client/iam/openid4vp.go:330-405requestJwtBearerAccessToken. Read alongside the inline comment about cross-VP binding (the four lines where parse → resolve descriptors → extract constraint fields → merge). The signDPoPHeader derivation from vp2.Holder is the SP-as-OAuth-client decision in code form.
  3. auth/client/iam/profile_validation.go — the shared profile/scope helper. Trivial to read top-to-bottom; pdResolver.resolveLocal becomes a 6-line caller of it.
  4. vcr/pe/presentation_submission.go:166-179 — the ResolveVP addition. Single new method, delegates to Resolve; fixes a previously-latent panic where the wrapped Envelope's raw field was nil.
  5. TestsTestRelyingParty_RequestServiceAccessToken_TwoVP/captured_VP1_field-id_values_flow_into_VP2_credential_selection_end-to-end is the hardest one to read; it has line-by-line comments explaining the cross-VP capture chain.
  6. Decisions to look at:
    • Two-VP bypasses pdResolver (always local — no AS advertisement standard exists). Documented in the requestJwtBearerAccessToken doc comment.
    • DPoP signs with the SP DID's key, not HCP. Per OAuth: the OAuth client (SP) presents the access token, so the proof must be SP-bound.
    • No OAuth client_id form param in jwt-bearer mode. Per RFC 7521 §4.2.
    • additionalCredentials flow into both wallets; each PD selects what matches. Documented on the interface.

Deviations from spec

  • Function rename: RequestRFC021AccessTokenRequestServiceAccessToken. 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.
  • Wallet-owner-type clientservice_provider (landed in PR Policy config: add client PD block (4078-1) #4226). Avoids overloading httpClient/authzenClient and the OAuth client_id form parameter; matches the PRD's prose ("service provider acting on behalf of an organization").
  • DPoP wired through the two-VP path. The PRD listed DPoP as out of scope (already implemented for single-VP), but the dispatcher accepts 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).
  • No OAuth client_id form parameter on the jwt-bearer path. Per RFC 7521 §4.2 the client_assertion authenticates the client, so client_id is 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.
  • loadAndValidateProfile extracted as a shared helper. Both pdResolver.resolveLocal and requestJwtBearerAccessToken had 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.ResolveVP added 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 through Raw() + ParseEnvelope. Also fixes a latent panic where the constructed Envelope's raw was nil.
  • TokenResponse.Scope returns the resolved scope (collapsed under profile-only, full input under passthrough) for both flows. Pre-existing bug in single-VP, fixed in passing.
  • Scope policy enforced in the two-VP path. Mirrors pdResolver.resolveLocal exactly.

Dependencies

Stacked on #4226 (the policy config loader for the service_provider PD 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:

  • Public-interface signature cleanup — drop the *string sentinel on Client.RequestServiceAccessToken (split into two methods, or reshape).
  • iam.NewClient constructor — 9 positional args including two booleans; functional options would be cheap once the API binding touches the wiring.
  • Coverage gaps — SP-side ListDIDs empty, second BuildSubmission ErrNoCredentials.
  • Test ergonomics — TwoVP failure-mode tests still spin up a TLS server they don't need.

Design context

  • PRD Client-side RFC 7523 JWT Bearer grant with two VPs (PSA 10.10) #4078 — overall design including the cross-VP field.id binding convention and the failure-loud rule (no silent fallback when the caller opts in to two-VP).
  • PRD §"Cross-VP field binding" — describes the shared-field.id mechanism realized here via ResolveConstraintsFields + applyCapturedFieldsToSelection.
  • PRD §"Token request parameters (jwt-bearer flow, PSA 10.10.6)" — the wire format this PR's happy-path test asserts.
  • PRD §"Out of Scope" — DPoP was listed there because it was already implemented for single-VP; this PR extends it to two-VP rather than letting the explicit useDPoP parameter silently downgrade.
Original implementation spec (used during AI-assisted development)

Internal client-side change. Adds the two-VP jwt-bearer flow 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

  • Register new config key auth.experimental.jwt_bearer_client (default false).
  • The flag gates the new code path inside RequestRFC021AccessToken.

Two-VP flow inside RequestRFC021AccessToken

Located in auth/client/iam/. Extend its signature to accept an optional SP subject identifier (e.g. clientID *string or similar — internal API, called from API binding in PR 3).

When the SP subject identifier is absent: existing single-VP vp_token-bearer path runs unchanged.

When the SP subject identifier is present:

  1. Fetch AS metadata. If urn:ietf:params:oauth:grant-type:jwt-bearer is not in grant_types_supported → return error.
  2. Look up the client PD for the requested credential profile (loaded by PR 1). If absent → return error.
  3. Build VP1 from the HCP wallet (existing path-param subjectID) using the organization PD. Existing BuildSubmission call.
  4. Call the existing resolveInputDescriptorValues (auth/api/iam/s2s_vptoken.go) on VP1's selected credentials to extract {field.id → matched value}.
  5. Additively merge captured values into the credential_selection map.
  6. Build VP2 by calling BuildSubmission again with the SP subject (from clientID) and the client PD, passing the merged credential_selection.
  7. Assemble the token request form body with grant_type, assertion, client_assertion_type, client_assertion, scope. No presentation_submission.
  8. POST to the AS token endpoint and parse the token response as today.

Modules touched

Acceptance Criteria

  • auth.experimental.jwt_bearer_client feature flag registered.
  • RequestServiceAccessToken accepts an optional SP subject identifier (renamed — see Deviations).
  • Two-VP flow constructs the correct jwt-bearer form body when SP identifier is present and AS supports jwt-bearer.
  • Single-VP flow runs unchanged when SP identifier is absent.
  • Cross-VP binding (shared field.id) works without EHR involvement.
  • All failure modes return clear errors (typed oauth.OAuth2Error).
  • Unit tests cover all the scenarios listed above.

@qltysh
Copy link
Copy Markdown

qltysh Bot commented May 1, 2026

Qlty


Coverage Impact

⬇️ Merging this pull request will decrease total coverage on feature/4078-jwt-bearer-two-vp by 0.04%.

Modified Files with Diff Coverage (9)

RatingFile% DiffUncovered Line #s
Coverage rating: C 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: B Coverage rating: C
auth/client/iam/openid4vp.go74.7%76-91, 319, 353-354...
New Coverage rating: A
auth/client/iam/profile_validation.go100.0%
Total78.5%
🤖 Increase coverage with AI coding...
In the `4078-2-two-vp-flow` branch, add test coverage for this new code:

- `auth/client/iam/openid4vp.go` -- Lines 76-91, 319, 353-354, 373-374, 381-385, 388-392, 395-399, 405-406, 411-412, 415-416, 427-428, 447-448, 533-534, and 537-538
- `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.

stevenvegt and others added 4 commits May 1, 2026 16:27
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>
stevenvegt and others added 15 commits May 1, 2026 17:20
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>
@stevenvegt stevenvegt force-pushed the 4078-2-two-vp-flow branch from 3b47ab5 to b2ba045 Compare May 4, 2026 09:20
stevenvegt and others added 6 commits May 4, 2026 11:39
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>
stevenvegt and others added 4 commits May 4, 2026 12:03
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>
Comment thread auth/client/iam/openid4vp.go
Comment thread auth/cmd/cmd.go
Comment thread vcr/pe/presentation_submission.go Outdated
Comment thread auth/client/iam/openid4vp.go Outdated
Comment thread auth/client/iam/interface.go Outdated
Comment thread auth/client/iam/openid4vp.go
Comment thread auth/client/iam/openid4vp.go Outdated
Comment thread auth/client/iam/openid4vp.go Outdated
Comment thread auth/client/iam/openid4vp.go Outdated
stevenvegt and others added 7 commits May 4, 2026 15:09
@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>
@qltysh
Copy link
Copy Markdown

qltysh Bot commented May 4, 2026

4 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 6): requestVPTokenAccessToken 3
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

@stevenvegt stevenvegt requested a review from reinkrul May 4, 2026 14:11
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>
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.

2 participants