Skip to content

Integration test: jwt-bearer two-VP token request payload (4078-4)#4229

Open
stevenvegt wants to merge 7 commits into
4078-3-api-client-idfrom
4078-4-integration-test
Open

Integration test: jwt-bearer two-VP token request payload (4078-4)#4229
stevenvegt wants to merge 7 commits into
4078-3-api-client-idfrom
4078-4-integration-test

Conversation

@stevenvegt
Copy link
Copy Markdown
Member

@stevenvegt stevenvegt commented May 1, 2026

Parent PRD

#4078

Summary

End-to-end integration test that boots a real Nuts node, drives the
request-service-access-token API with service_provider_subject_id, and
asserts the resulting token-request form body matches the PSA 10.10.6 /
RFC 7523 jwt-bearer wire format. Both captured VPs are round-tripped through
the same node's /internal/vcr/v2/verifier/vp endpoint to confirm signatures
verify and the cross-VP delegation binding holds end-to-end with real keys.

This is the last PR in the #4078 stack. It validates that #4226 (policy
config), #4227 (two-VP flow), and #4228 (API binding) compose correctly under
real cryptographic signing and real DID resolution, coverage the mock-based
unit tests cannot provide.

What changed

  • Adds auth/api/iam/jwtbearer_integration_test.go, a single happy-path test
    that:
    • Boots a Nuts node with NUTS_AUTH_EXPERIMENTAL_JWTBEARERCLIENT=true and a
      medication-overview policy with shared delegating_hcp field-id binding
      across organization and service_provider PDs.
    • Provisions four did:web subjects (CIBG issuer, Twiin issuer, HCP org,
      service provider) and issues the three credentials required by the
      profile (HealthcareProviderCredential, ServiceProviderCredential,
      ServiceProviderDelegationCredential).
    • Stands up an httptest.Server mock AS that advertises jwt-bearer support
      and synchronously captures the form POST to /token.
    • Asserts wire-format invariants on the captured form (grant_type,
      client_assertion_type, scope, presence of both assertion and
      client_assertion, absence of presentation_submission).
    • Round-trips both captured VPs through the node's verifyVP endpoint and
      asserts the cross-VP binding (delegation credential issuer == VP1 signer,
      delegation delegatedBy URA == HCP credential URA).

How to review

  • Start with TestIntegration_JwtBearer_TwoVPHappyPath to see the overall
    flow.
  • The wire-format assertions live in the main test function; the
    verifyVP round-trips and cross-VP binding checks follow.
  • Helpers below (mockAS, provisionSubject, issueAndLoad, credential
    builders, pluckCredentialByType, firstSubject, firstIdentifierValue)
    are scoped to this file and exist purely to keep the test readable.
  • mockAS.capturedForm(t) is synchronous: the IAMClient's /token POST
    completes inside the API call, so the captured form is set by the time
    the API returns 200. No polling; failures (parse error or never-called)
    surface as test failures, not silent empty maps.
  • The format-key conventions used here differ across three specs (issuer API
    uses jwt_vc, PE policy uses jwt_vc/jwt_vp, AS metadata uses
    jwt_vc_json/jwt_vp_json). The header comment on issueAndLoad spells
    this out.

Deviations from spec

The implementation spec calls for negative-path assertions in this
integration test (AS doesn't advertise jwt-bearer, missing service_provider
PD, feature flag disabled). Those scenarios are instead covered by the
mock-based tests added in #4227 and #4228:

  • Feature-flag-off, missing service_provider PD, AS doesn't advertise
    jwt-bearer: auth/client/iam/openid4vp_test.go.
  • service_provider_subject_id validation at the API layer:
    auth/api/iam/api_test.go.

This integration test focuses exclusively on the happy-path round trip,
where the value is real crypto + real DID resolution + real verifyVP,
things unit tests cannot exercise. The file header comment in
jwtbearer_integration_test.go documents this scope decision.

Dependencies

This is PR #4 of 4 in the #4078 stack. It targets 4078-3-api-client-id so
the diff shows only the integration test. Review order:

  1. Policy config: add client PD block (4078-1) #4226: policy config (service_provider PD block)
  2. Two-VP token request flow (4078-2) #4227: two-VP flow
  3. API binding: accept service_provider_subject_id on request-service-access-token (4078-3) #4228: API binding (service_provider_subject_id)
  4. Integration test: jwt-bearer two-VP token request payload (4078-4) #4229: this PR

All three predecessors are approved and out of draft as of opening.

Design context

  • PRD Client-side RFC 7523 JWT Bearer grant with two VPs (PSA 10.10) #4078, "Client-side RFC 7523 JWT Bearer grant with two VPs (PSA 10.10)".
  • The cross-VP binding mechanism (shared field.id on
    $.issuer) is described in the PRD's "Cross-VP field binding" section. This
    test exercises it end-to-end: the captured VP2's
    ServiceProviderDelegationCredential.issuer must equal the DID that signed
    VP1.
  • PSA 10.10.6 wire format expectations are pinned in the PRD's "Token request
    parameters (jwt-bearer flow)" section.
Original implementation spec (used during AI-assisted development)

Parent PRD

#4078

Implementation Spec

Integration safety net. Asserts the full client-side flow produces a PSA 10.10.6-conformant token request and that both VPs verify cleanly through the existing VCR verifyVP API.

What to build

In-process Go integration test (under auth/api/iam/, extend or add alongside the existing integration_test.go). No docker-compose; that's reserved for cross-node workflows.

Test setup

  1. Spin up a single Nuts node test instance with the experimental feature flag enabled.
  2. Pre-provision two subjects in its wallet:
    • HCP subject with credentials matching the organization PD (e.g. HealthcareProviderCredential).
    • SP subject with a ServiceProviderDelegationCredential whose issuer is one of the HCP subject's DIDs.
  3. Configure the policy with a medication-overview profile that has both organization and client PDs, sharing a delegating_hcp field id on $.issuer.
  4. Stand up an httptest.Server mock AS that:
    • Serves oauth-authorization-server metadata advertising urn:ietf:params:oauth:grant-type:jwt-bearer in grant_types_supported.
    • Captures the form POST body received on the token endpoint.
    • Returns a canned valid token response so the client doesn't error.

Positive path assertions

  • Call POST /internal/auth/v2/{HCP-subjectID}/request-service-access-token with body containing client_id, authorization_server (mock AS URL), and a mixed scope.
  • Inspect the captured form POST body:
    • grant_type equals urn:ietf:params:oauth:grant-type:jwt-bearer.
    • client_assertion_type equals urn:ietf:params:oauth:client-assertion-type:jwt-bearer.
    • scope matches what was requested.
    • assertion and client_assertion are both present and non-empty.
    • No presentation_submission form parameter is present.
  • Submit the captured assertion JWT-VP to the same node's POST /internal/vcr/v2/verifier/vp and assert validity: true. Inspect the verified credentials, VP1 must contain the expected HCP credentials and be signed by the HCP DID.
  • Submit the captured client_assertion JWT-VP the same way. VP2 must contain the delegation credential, be signed by the SP DID, and the delegation's issuer must equal the HCP DID that signed VP1 (cross-VP binding verified end-to-end).

Negative path assertions

  • Same setup but mock AS metadata omits jwt-bearer from grant_types_supported: API call returns 400 with a clear error.
  • Same setup but no client PD configured for the requested profile: API call returns 400.
  • Feature flag disabled + client_id present: API call returns 400 (feature disabled).

Modules touched

  • auth/api/iam/integration_test.go (or a new sibling test file for the jwt-bearer flow).
  • Possibly test fixture policies and credentials under test/ or alongside.

Why no docker e2e

PSA 10.10.6 server-side support is out of scope for #4078 (separate PRD). There is no second Nuts node that can receive and validate the request. A mock AS via httptest is the appropriate level, the test verifies wire format and round-trips both VPs through the same node's verifyVP for proof validation.

Acceptance Criteria

@qltysh
Copy link
Copy Markdown
Contributor

qltysh Bot commented May 1, 2026

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on 4078-3-api-client-id by 0.13%.

🚦 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 added a commit that referenced this pull request May 8, 2026
The original koanf tag had underscores (jwt_bearer_client) which conflict
with koanf's env-var loader: NUTS_AUTH_EXPERIMENTAL_JWT_BEARER_CLIENT
parses as auth.experimental.jwt.bearer.client (5 path components) and
doesn't bind. The new name jwtbearerclient (single lowercase word)
matches the rest of the codebase's koanf-tag convention (auth.contract
validators, auth.accesstokenlifespan, network.enablediscovery, ...) and
parses correctly via the env var.

The Go field name JwtBearerClient is unchanged.

Caught while writing the integration test (#4229) — env-var override of
the flag silently failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stevenvegt and others added 7 commits May 8, 2026 07:31
Boots a real Nuts node via test/node/StartServer (with the
experimental flag on, did:web, and a custom policy directory),
provisions four subjects (CIBG / Twiin issuers + HCP organization +
service provider) over the internal HTTP API, issues the three
credentials needed (HealthcareProviderCredential,
ServiceProviderCredential, ServiceProviderDelegationCredential),
stands up a mock authorization server via httptest that advertises
jwt-bearer and captures the form POST, drives the
request-service-access-token endpoint, and asserts:

- form body matches the RFC 7523 wire shape (grant_type,
  client_assertion_type, scope; assertion+client_assertion non-empty;
  no presentation_submission, no client_id)
- both captured VPs round-trip through the same node's
  /internal/vcr/v2/verifier/vp with validity=true
- the cross-VP binding survives end-to-end: the delegation
  credential's issuer equals VP1's signer DID, and its delegatedBy URA
  equals VP1's HCP URA

Negative paths (feature flag off, AS doesn't advertise jwt-bearer,
missing service_provider PD, SP wallet has no matching credentials)
are covered by unit tests in auth/client/iam/openid4vp_test.go and
the handler tests in auth/api/iam/api_test.go; this integration test
focuses on the happy-path round trip that those mock-based tests
cannot cover (real cryptographic signing, real DID resolution, real
verifyVP).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the polling lastForm() helper: the AS /token POST completes
synchronously inside request-service-access-token, so the captured
form is set by the time the API returns 200. capturedForm now fails
the test loudly if /token was never hit, instead of returning an
empty url.Values that would make wire-format asserts misleading.

Propagate ParseForm errors via an atomic so a malformed token-request
form fails the test instead of silently 400-ing the handler.

Replace the generic deepString walker with typed firstSubject and
firstIdentifierValue helpers that encode the actual credential shape.

Drop the idempotent GET-then-POST in provisionSubject; the test runs
against a fresh tempdir node, so the lookup branch was dead code.

Assisted-by: AI
@stevenvegt stevenvegt changed the base branch from feature/4078-jwt-bearer-two-vp to 4078-3-api-client-id May 12, 2026 09:18
@stevenvegt stevenvegt force-pushed the 4078-4-integration-test branch from ebd9d3a to d422393 Compare May 12, 2026 09:18
@stevenvegt stevenvegt marked this pull request as ready for review May 12, 2026 11:21
// Cross-VP binding survived end-to-end: the delegation credential's issuer equals the
// organization's DID (the same DID that signed VP1), and its delegatedBy URA equals the
// HCP credential's URA.
delegationCred := pluckCredentialByType(t, delegationVP, "ServiceProviderDelegationCredential")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pluck?

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