Skip to content

API binding: accept service_provider_subject_id on request-service-access-token (4078-3)#4228

Open
stevenvegt wants to merge 10 commits into4078-2-two-vp-flowfrom
4078-3-api-client-id
Open

API binding: accept service_provider_subject_id on request-service-access-token (4078-3)#4228
stevenvegt wants to merge 10 commits into4078-2-two-vp-flowfrom
4078-3-api-client-id

Conversation

@stevenvegt
Copy link
Copy Markdown
Member

@stevenvegt stevenvegt commented May 1, 2026

Parent PRD

#4078

Summary

Adds the optional service_provider_subject_id body field on POST /internal/auth/v2/{subjectID}/request-service-access-token. When present, the handler routes the request into the experimental RFC 7523 two-VP jwt-bearer flow added in #4227; when absent, the existing single-VP vp_token-bearer flow runs unchanged. Also addresses all four deferred carry-overs from #4227's self-review.

What changed

API surface (docs/_static/auth/v2.yaml)

  • Optional service_provider_subject_id string added to ServiceAccessTokenRequest. Description marks it experimental and explains the four conditions that must hold for the two-VP flow to engage.
  • Generated bindings regenerated (auth/api/iam/generated.go, e2e-tests/browser/client/iam/generated.go).

Handler (auth/api/iam/api.go)

  • Threads request.Body.ServiceProviderSubjectId to the IAM client's serviceProviderSubjectID parameter (added in Two-VP token request flow (4078-2) #4227, previously always nil).
  • Validates: empty string is rejected up front (InvalidInputError), and the SP subject must exist locally (subjectExists check, mirroring the path-param subjectID treatment).
  • No duplicate API-level feature-flag check — the IAM client's gate already returns a typed oauth.UnsupportedGrantType that the existing error mapping renders as a 4xx.

Carry-overs from #4227

# Item Disposition
1 Drop *string sentinel from Client.RequestServiceAccessToken Kept the dispatcher shape (one method, both flows) — keeps the API handler implementation-agnostic. Renamed local param subjectIDorganizationSubjectID inside requestJwtBearerAccessToken for clarity (commit 8b710172).
2 iam.NewClient constructor shape Refactored to a ClientConfig struct (commit e13789f6). Future config additions (e.g. the grant-types-enabled list under #4231) become a one-line struct change.
3 Drop dead TLS-server setup in failure-mode tests The feature-disabled gate test now uses the lighter createClientTestContext (commit 034f0b28). The other two failure tests genuinely need the AS metadata HTTP fixture and stay on the heavier context.
4 Cover SP-side ListDIDs empty / BuildSubmission ErrNoCredentials Two new sub-tests mirroring the single-VP error tests (commit 43f12f69).

Documentation (docs/pages/deployment/policy.rst)

New top-level section "Two-VP flow and cross-VP binding (experimental)" with a sphinx warning banner and four subsections covering: when the flow triggers, how VP1 and VP2 are built, cross-VP binding via shared field.id with a worked delegating_hcp example, the credential_selection map (EHR-supplied vs server-captured), and the four-step required configuration. Release notes entry added.

How to review

  1. Start with auth/api/iam/api.go:726-742 — the handler, ~15 lines added. The validation block is the only behavioural change; everything else is a one-line thread-through at line 795.
  2. auth/api/iam/api_test.go "service_provider_subject_id" subtests — three sibling tests cover threads-through (with response body asserted), empty-string rejected, unknown subject returns 400. The absent-field path is implicitly covered by every existing single-VP test.
  3. auth/client/iam/openid4vp.go:73-99 — the new ClientConfig struct + NewClient. Pure refactor; the existing 9 positional args map 1:1 to fields. Single call site updated in auth/auth.go.
  4. auth/client/iam/openid4vp_test.go — two new SP-side failure-mode subtests, plus the lighter-context conversion of the feature-disabled test.
  5. docs/pages/deployment/policy.rst — the new docs section is the only place an operator or PD author can read about the two-VP mechanism end to end. Worth a careful pass for accuracy.

Decisions to look at

  • Field name service_provider_subject_id rather than the PRD's client_id. Same reasoning as Policy config: add client PD block (4078-1) #4226 renaming clientservice_provider: avoids overloading with the OAuth client_id form parameter (RFC 6749), which the two-VP flow specifically does NOT send (RFC 7521 §4.2).
  • Empty-string validation in the handler. A *string pointer to "" would have routed into the two-VP flow and surfaced as a misleading 412 "did method mismatch" downstream. Now an explicit InvalidInputError with the message "service_provider_subject_id is optional and cannot be empty: omit the field or set a non-empty value".
  • ClientConfig doc comment. Distinguishes interface-typed required fields (will nil-pan on first use if missing) from scalar fields where the zero value is meaningful and supported. Validation is intentionally not added inside NewClient (kept consistent with the previous positional signature; out of scope for this PR).

Deviations from spec

  • Field renamed client_idservice_provider_subject_id (see "Decisions to look at" above).
  • No duplicate API-level feature-flag check. The implementation spec said "API-level feature flag check: when flag disabled + client_id present → 400 with clear feature-disabled error". The IAM client's existing gate returns oauth.UnsupportedGrantType with the message jwt-bearer two-VP flow requires auth.experimental.jwt_bearer_client = true, which the existing error-mapping already surfaces as a 4xx. Duplicating the check at the API layer would duplicate the message and add a branch to test for no functional gain.
  • Four Two-VP token request flow (4078-2) #4227 carry-overs are addressed in this PR, in dedicated commits, in addition to the API binding (see table above).
  • Self-review fix added input validation (empty-string + subjectExists for the SP subject) — flagged as the one major issue in this PR's self-review and addressed in commit eda026e8 rather than deferred.

Dependencies

Stacked on #4227 (the two-VP flow internals). Both #4226 and #4227 are now APPROVED and awaiting merge; this PR merges after them.

#4229 (integration test) depends on this PR.

Related issues

Design context

Original implementation spec (used during AI-assisted development)

Public surface. After this PR, EHR callers can opt in to the two-VP flow by passing client_id.

What to build

OpenAPI spec

  • Edit docs/_static/auth/v2.yaml. Add an optional client_id string field to the ServiceAccessTokenRequest schema.
  • Description should mark it experimental and explain that it identifies the OAuth client (service provider) and triggers the two-VP jwt-bearer flow when the AS supports it and a client PD is configured.
  • Regenerate auth/api/iam/generated.go (and e2e-tests/browser/client/iam/generated.go if affected).

Wiring

  • In the API handler for request-service-access-token, thread client_id from the request body to RequestRFC021AccessToken's SP-subject parameter (added in PR 2).
  • API-level feature flag check: when auth.experimental.jwt_bearer_client = false and client_id is present in the request body, return HTTP 400 with a clear "feature disabled" error message.

Acceptance Criteria (original)

  • OpenAPI spec updated with client_id field, marked experimental.
  • auth/api/iam/generated.go regenerated.
  • Handler threads client_id to internal client.
  • Feature flag check at API level: flag disabled + client_id present → 400. (Skipped — see Deviations.)
  • Existing single-VP path unaffected.
  • API tests cover all flag/parameter combinations.

@qltysh
Copy link
Copy Markdown

qltysh Bot commented May 1, 2026

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on 4078-2-two-vp-flow by 0.01%.

Modified Files with Diff Coverage (3)

RatingFile% DiffUncovered Line #s
Coverage rating: C Coverage rating: C
auth/auth.go100.0%
Coverage rating: B Coverage rating: B
auth/api/iam/api.go100.0%
Coverage rating: C Coverage rating: C
auth/client/iam/openid4vp.go5.0%93-114
Total51.3%
🤖 Increase coverage with AI coding...
In the `4078-3-api-client-id` branch, add test coverage for this new code:

- `auth/client/iam/openid4vp.go` -- Line 93-114

🚦 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 9 commits May 4, 2026 16:14
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>
…ssToken

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

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

The only call site is auth/auth.go.

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

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

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

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

Carry-over from #4227 self-review.

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

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

Carry-over from #4228 self-review.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@stevenvegt stevenvegt force-pushed the 4078-3-api-client-id branch from acc5ffd to fbbd56c Compare May 5, 2026 09:38
@stevenvegt stevenvegt changed the title API binding: accept client_id on request-service-access-token (4078-3) API binding: accept service_provider_subject_id on request-service-access-token (4078-3) May 5, 2026
…docs

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@stevenvegt stevenvegt marked this pull request as ready for review May 5, 2026 13:39
@stevenvegt stevenvegt changed the base branch from feature/4078-jwt-bearer-two-vp to 4078-2-two-vp-flow May 5, 2026 17:36
@qltysh
Copy link
Copy Markdown

qltysh Bot commented May 5, 2026

4 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 9): RequestServiceAccessToken 2
qlty Duplication Found 48 lines of identical code in 2 locations (mass = 71) 2

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