From c6d75920b02a7adadbbff0849d59d37685c1e585 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:54:31 +0200 Subject: [PATCH 1/7] Initialize branch for PR From 83afb0a16853d8e8ad3a8af99ffe5b6bde26a1ab Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:55:30 +0200 Subject: [PATCH 2/7] Initialize branch for PR From 6b711e9305a87bff6efc1b2d4ea54cd563eb127f Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:54:31 +0200 Subject: [PATCH 3/7] Initialize branch for PR From e88e79c6712a6b7ae0c69482fbc4ba52276ea59d Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:55:30 +0200 Subject: [PATCH 4/7] Initialize branch for PR From de07e6a896d11858f7f30e8f4d4373e2d9f8206e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:55:55 +0200 Subject: [PATCH 5/7] Initialize branch for PR From 72023b82b0937ff2b6fa7c8540b85fdfe46a67be Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 8 May 2026 09:51:52 +0200 Subject: [PATCH 6/7] Add jwt-bearer two-VP happy-path integration test 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) --- auth/api/iam/jwtbearer_integration_test.go | 477 +++++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 auth/api/iam/jwtbearer_integration_test.go diff --git a/auth/api/iam/jwtbearer_integration_test.go b/auth/api/iam/jwtbearer_integration_test.go new file mode 100644 index 000000000..8453cca87 --- /dev/null +++ b/auth/api/iam/jwtbearer_integration_test.go @@ -0,0 +1,477 @@ +/* + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package iam_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/test/node" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_JwtBearer_TwoVPHappyPath boots a real Nuts node, provisions four subjects +// (CIBG issuer, Twiin issuer, HCP organization, service provider), issues the three credentials +// needed to satisfy the medication-overview profile (HealthcareProviderCredential, +// ServiceProviderCredential, ServiceProviderDelegationCredential), drives the API endpoint, and +// asserts the captured token-request form body matches the RFC 7523 jwt-bearer wire format and +// both VPs verify cleanly through the same node's /internal/vcr/v2/verifier/vp endpoint. +// +// Negative paths (feature flag off, AS doesn't advertise jwt-bearer, missing service_provider PD, +// SP wallet has no matching credentials) are covered by the 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, and real verifyVP. +func TestIntegration_JwtBearer_TwoVPHappyPath(t *testing.T) { + policyDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(policyDir, "medication-overview.json"), []byte(medicationOverviewPolicy), 0o644)) + + // Mock authorization server: serves AS metadata advertising jwt-bearer support and captures + // the form body POSTed to /token. Set up before the node so we can include its URL in the + // node config (not strictly required since metadata fetch is from the AS itself, but keeps + // the wiring obvious). + asMock := newMockAS(t) + defer asMock.server.Close() + + internalURL, _, _ := node.StartServer(t, func(_, _ string) { + t.Setenv("NUTS_AUTH_EXPERIMENTAL_JWTBEARERCLIENT", "true") + t.Setenv("NUTS_DIDMETHODS", "web") + t.Setenv("NUTS_POLICY_DIRECTORY", policyDir) + }) + + const ( + cibgSubject = "cibg-issuer" + twiinSubject = "twiin-issuer" + orgSubject = "org1" + spSubject = "sp1" + ura = "78551223" + ) + + cibgDID := provisionSubject(t, internalURL, cibgSubject) + twiinDID := provisionSubject(t, internalURL, twiinSubject) + orgDID := provisionSubject(t, internalURL, orgSubject) + spDID := provisionSubject(t, internalURL, spSubject) + + // HCP credential: CIBG → org. Asserts the org is a healthcare provider with the given URA. + issueAndLoad(t, internalURL, orgSubject, buildHCPCredential(cibgDID, orgDID, ura)) + // SP credential: Twiin → sp. Required by the service_provider PD's first input descriptor. + issueAndLoad(t, internalURL, spSubject, buildSPCredential(twiinDID, spDID)) + // Delegation credential: org → sp. The cross-VP binding ties this credential's `issuer` + // (orgDID) and its delegatedBy URA back to VP1's HCP credential. + issueAndLoad(t, internalURL, spSubject, buildDelegationCredential(orgDID, spDID, ura)) + + // Drive the API. + body := map[string]any{ + "authorization_server": asMock.server.URL + "/oauth2/" + spSubject, + "scope": "medication-overview", + "service_provider_subject_id": spSubject, + "token_type": "Bearer", + } + bodyBytes, _ := json.Marshal(body) + resp, err := http.Post(internalURL+"/internal/auth/v2/"+orgSubject+"/request-service-access-token", + "application/json", bytes.NewReader(bodyBytes)) + require.NoError(t, err) + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode, "request-service-access-token failed: %s", respBody) + + // Wire-format assertions on the captured form body. + form := asMock.lastForm() + assert.Equal(t, oauth.JwtBearerGrantType, form.Get("grant_type"), "grant_type") + assert.Equal(t, oauth.JwtBearerClientAssertionType, form.Get("client_assertion_type"), "client_assertion_type") + assert.Equal(t, "medication-overview", form.Get("scope"), "scope") + assert.NotEmpty(t, form.Get("assertion"), "assertion (VP1)") + assert.NotEmpty(t, form.Get("client_assertion"), "client_assertion (VP2)") + assert.Empty(t, form.Get("presentation_submission"), "presentation_submission must not be set per RFC 7523") + assert.Empty(t, form.Get("client_id"), "client_id must not be set on jwt-bearer (RFC 7521 §4.2)") + + // Round-trip both VPs through the same node's verifier. + hcpVP := verifyVP(t, internalURL, form.Get("assertion")) + delegationVP := verifyVP(t, internalURL, form.Get("client_assertion")) + + // 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") + assert.Equal(t, orgDID, jsonString(t, delegationCred, "issuer"), + "delegation credential issuer must equal VP1 signer (cross-VP binding on $.issuer == $.credentialSubject.id)") + assert.Equal(t, ura, deepString(t, delegationCred, "credentialSubject", 0, "hasDelegation", "delegatedBy", "identifier", 0, "value"), + "delegation delegatedBy URA must equal VP1 HCP URA (cross-VP binding on URA)") + + // Sanity: VP1 (organization VP) carried the HCP credential. + hcpCred := pluckCredentialByType(t, hcpVP, "HealthcareProviderCredential") + assert.Equal(t, ura, deepString(t, hcpCred, "credentialSubject", 0, "identifier", 0, "value"), + "HCP credential carries the expected URA") +} + +// medicationOverviewPolicy defines an organization PD on HealthcareProviderCredential and a +// service_provider PD with two input descriptors (ServiceProviderCredential and +// ServiceProviderDelegationCredential). The two PDs share two field IDs that realise the +// cross-VP binding: +// +// - delegating_hcp: HCP cred's $.credentialSubject.id ↔ Delegation cred's $.issuer +// - delegating_hcp_ura: HCP cred's URA value ↔ Delegation cred's delegatedBy URA +// +// Both must equal across the two VPs for the SP wallet's submission to satisfy the PD. +const medicationOverviewPolicy = `{ + "medication-overview": { + "organization": { + "id": "pd_org", + "format": { + "jwt_vc": {"alg": ["ES256", "PS256", "RS256"]}, + "jwt_vp": {"alg": ["ES256", "PS256", "RS256"]} + }, + "input_descriptors": [{ + "id": "id_hcp", + "constraints": { + "fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "HealthcareProviderCredential"}}, + {"id": "delegating_hcp", "path": ["$.credentialSubject[0].id", "$.credentialSubject.id"], "filter": {"type": "string"}}, + {"id": "delegating_hcp_ura", "path": ["$.credentialSubject[0].identifier[*].value", "$.credentialSubject.identifier[*].value"], "filter": {"type": "string"}} + ] + } + }] + }, + "service_provider": { + "id": "pd_sp", + "format": { + "jwt_vc": {"alg": ["ES256", "PS256", "RS256"]}, + "jwt_vp": {"alg": ["ES256", "PS256", "RS256"]} + }, + "input_descriptors": [ + { + "id": "id_sp", + "constraints": { + "fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ServiceProviderCredential"}} + ] + } + }, + { + "id": "id_sp_delegation", + "constraints": { + "fields": [ + {"path": ["$.type"], "filter": {"type": "string", "const": "ServiceProviderDelegationCredential"}}, + {"id": "delegating_hcp", "path": ["$.issuer"], "filter": {"type": "string"}}, + {"id": "delegating_hcp_ura", "path": ["$.credentialSubject[0].hasDelegation.delegatedBy.identifier[*].value", "$.credentialSubject.hasDelegation.delegatedBy.identifier[*].value"], "filter": {"type": "string"}} + ] + } + } + ] + } + } +}` + +// provisionSubject creates a Nuts subject if it doesn't already exist and returns its first +// did:web. Idempotent across the test run. +func provisionSubject(t *testing.T, internalURL, subjectID string) string { + t.Helper() + resp, err := http.Get(internalURL + "/internal/vdr/v2/subject/" + subjectID) + require.NoError(t, err) + if resp.StatusCode == http.StatusOK { + var dids []string + require.NoError(t, json.NewDecoder(resp.Body).Decode(&dids)) + resp.Body.Close() + for _, d := range dids { + if strings.HasPrefix(d, "did:web:") { + return d + } + } + t.Fatalf("subject %s exists but has no did:web", subjectID) + } + resp.Body.Close() + body, _ := json.Marshal(map[string]string{"subject": subjectID}) + resp, err = http.Post(internalURL+"/internal/vdr/v2/subject", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "create subject %s", subjectID) + var created struct { + Documents []struct { + ID string `json:"id"` + } `json:"documents"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&created)) + require.NotEmpty(t, created.Documents, "subject creation returned no DID documents") + return created.Documents[0].ID +} + +// issueAndLoad issues a credential against /internal/vcr/v2/issuer/vc and loads it into the +// holder wallet under the given subject. Both calls are routed through the same node. +func issueAndLoad(t *testing.T, internalURL, holderSubject string, body []byte) { + t.Helper() + resp, err := http.Post(internalURL+"/internal/vcr/v2/issuer/vc", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode, "issuer/vc failed: %s", respBody) + loadResp, err := http.Post(internalURL+"/internal/vcr/v2/holder/"+holderSubject+"/vc", "application/json", bytes.NewReader(respBody)) + require.NoError(t, err) + defer loadResp.Body.Close() + loadBody, _ := io.ReadAll(loadResp.Body) + require.True(t, loadResp.StatusCode >= 200 && loadResp.StatusCode < 300, + "holder/%s/vc returned %d: %s", holderSubject, loadResp.StatusCode, loadBody) +} + +func buildHCPCredential(issuerDID, subjectDID, ura string) []byte { + body, _ := json.Marshal(map[string]any{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential", "HealthcareProviderCredential"}, + "issuer": issuerDID, + // expirationDate is required when withStatusList2021Revocation is not set; the + // statuslist machinery would add network round-trips we don't need. + "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + "credentialSubject": map[string]any{ + "id": subjectDID, + "type": "HealthcareProvider", + "identifier": []map[string]any{ + {"system": "http://fhir.nl/fhir/NamingSystem/ura", "value": ura}, + }, + "name": "Test HCP", + }, + "format": "jwt_vc", + }) + return body +} + +func buildSPCredential(issuerDID, subjectDID string) []byte { + body, _ := json.Marshal(map[string]any{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential", "ServiceProviderCredential"}, + "issuer": issuerDID, + // expirationDate is required when withStatusList2021Revocation is not set; the + // statuslist machinery would add network round-trips we don't need. + "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + "credentialSubject": map[string]any{ + "id": subjectDID, + "type": "ServiceProvider", + "name": "Test SP", + }, + "format": "jwt_vc", + }) + return body +} + +func buildDelegationCredential(issuerDID, subjectDID, ura string) []byte { + body, _ := json.Marshal(map[string]any{ + "@context": []string{"https://www.w3.org/2018/credentials/v1"}, + "type": []string{"VerifiableCredential", "ServiceProviderDelegationCredential"}, + "issuer": issuerDID, + // expirationDate is required when withStatusList2021Revocation is not set; the + // statuslist machinery would add network round-trips we don't need. + "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), + "credentialSubject": map[string]any{ + "id": subjectDID, + "type": "ServiceProvider", + "hasDelegation": map[string]any{ + "type": "Delegation", + "delegatedBy": map[string]any{ + "type": "HealthcareProvider", + "identifier": []map[string]any{ + {"system": "http://fhir.nl/fhir/NamingSystem/ura", "value": ura}, + }, + }, + }, + }, + "format": "jwt_vc", + }) + return body +} + +// mockAS is the httptest authorization server. It advertises jwt-bearer support and did:web in +// its metadata, captures the form body posted to /token, and returns a canned token response. +type mockAS struct { + server *httptest.Server + captured atomic.Pointer[url.Values] +} + +func newMockAS(t *testing.T) *mockAS { + t.Helper() + m := &mockAS{} + mux := http.NewServeMux() + // RFC 8414: for issuer `/oauth2/`, the discovery URL is + // `/.well-known/oauth-authorization-server/oauth2/` (well-known is right after the host, + // not at the end of the path). + mux.HandleFunc("/.well-known/oauth-authorization-server/", func(w http.ResponseWriter, r *http.Request) { + issuerPath := strings.TrimPrefix(r.URL.Path, "/.well-known/oauth-authorization-server") + issuer := m.server.URL + issuerPath + meta := map[string]any{ + "issuer": issuer, + "token_endpoint": issuer + "/token", + "grant_types_supported": []string{oauth.JwtBearerGrantType}, + "did_methods_supported": []string{"web"}, + "vp_formats_supported": map[string]any{"jwt_vp_json": map[string]any{"alg_values_supported": []string{"ES256"}}, "jwt_vc_json": map[string]any{"alg_values_supported": []string{"ES256"}}}, + "response_types_supported": []string{"code"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(meta) + }) + mux.HandleFunc("/oauth2/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/token") { + require.NoError(t, r.ParseForm()) + form := r.PostForm + m.captured.Store(&form) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "Bearer", "expires_in": 900}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + m.server = httptest.NewServer(mux) + return m +} + +func (m *mockAS) lastForm() url.Values { + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if f := m.captured.Load(); f != nil { + return *f + } + time.Sleep(20 * time.Millisecond) + } + return nil +} + +// verifyVP submits a JWT-VP to /internal/vcr/v2/verifier/vp and returns the parsed envelope. The +// validity field must be true; any failure aborts the test. +func verifyVP(t *testing.T, internalURL, vpJWT string) map[string]any { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "verifiablePresentation": vpJWT, + }) + resp, err := http.Post(internalURL+"/internal/vcr/v2/verifier/vp", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode, "verifier/vp returned %d: %s", resp.StatusCode, respBody) + var result map[string]any + require.NoError(t, json.Unmarshal(respBody, &result)) + require.True(t, jsonBool(t, result, "validity"), "verifier/vp returned validity=false: %s", respBody) + return result +} + +// pluckCredentialByType returns the first verifiable credential in the verifier response whose +// type list contains the requested type. The verifier's "credentials" array contains either +// parsed JSON objects (ldp_vc) or JWT strings (jwt_vc); for the latter we decode the payload to +// inspect the vc claim. Returns the parsed credential payload (the inner object for JWT VCs, or +// the entry itself for JSON-LD VCs). +func pluckCredentialByType(t *testing.T, vp map[string]any, credType string) map[string]any { + t.Helper() + creds, ok := vp["credentials"].([]any) + require.True(t, ok, "verifier/vp response missing 'credentials' array") + for _, c := range creds { + var cm map[string]any + switch v := c.(type) { + case map[string]any: + cm = v + case string: + cm = decodeJWTVCPayload(t, v) + default: + t.Fatalf("unsupported credential element type %T", c) + } + types, _ := cm["type"].([]any) + for _, ty := range types { + if s, _ := ty.(string); s == credType { + return cm + } + } + } + t.Fatalf("no credential of type %q in verifier response", credType) + return nil +} + +// decodeJWTVCPayload decodes a JWT-encoded VerifiableCredential and returns the inner `vc` claim +// merged with the outer `iss` (-> issuer) and `sub` (-> credentialSubject.id) claims, matching the +// shape that ldp_vc credentials would return directly. Used to make assertions credential-format +// agnostic in the integration test. +func decodeJWTVCPayload(t *testing.T, jwtStr string) map[string]any { + t.Helper() + parts := strings.Split(jwtStr, ".") + require.Len(t, parts, 3, "expected JWT with 3 parts, got %d", len(parts)) + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err, "decode JWT payload") + var claims map[string]any + require.NoError(t, json.Unmarshal(payloadBytes, &claims), "unmarshal JWT claims") + vc, ok := claims["vc"].(map[string]any) + require.True(t, ok, "JWT VC payload missing 'vc' claim") + // Lift the JWT's iss into the VC's issuer field so callers can assert on it without caring + // whether the credential is JWT-encoded or JSON-LD. + if iss, ok := claims["iss"].(string); ok { + if _, set := vc["issuer"]; !set { + vc["issuer"] = iss + } + } + return vc +} + +func jsonBool(t *testing.T, m map[string]any, key string) bool { + t.Helper() + v, ok := m[key] + require.True(t, ok, "key %q missing from response", key) + b, ok := v.(bool) + require.True(t, ok, "key %q is not bool: %T", key, v) + return b +} + +func jsonString(t *testing.T, m map[string]any, key string) string { + t.Helper() + v, ok := m[key] + require.True(t, ok, "key %q missing", key) + s, ok := v.(string) + require.True(t, ok, "key %q is not string: %T", key, v) + return s +} + +// deepString walks the JSON value through the given keys (string for object keys, int for array +// indices) and returns the leaf as a string. Useful for asserting on nested credential subject +// structures returned by the verifier. +func deepString(t *testing.T, m any, keys ...any) string { + t.Helper() + for i, k := range keys { + switch key := k.(type) { + case string: + obj, ok := m.(map[string]any) + require.True(t, ok, "step %d (%v): expected object, got %T", i, key, m) + m, ok = obj[key] + require.True(t, ok, "step %d (%v): key not found", i, key) + case int: + arr, ok := m.([]any) + require.True(t, ok, "step %d (%v): expected array, got %T", i, key, m) + require.Less(t, key, len(arr), "step %d (%v): index out of range (len=%d)", i, key, len(arr)) + m = arr[key] + default: + t.Fatalf("step %d: unsupported key type %T", i, key) + } + } + s, ok := m.(string) + require.True(t, ok, "leaf is not string: %T (%v)", m, m) + return s +} From d4223936ff98020e94909b4335beddced84a65d6 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 12 May 2026 10:59:39 +0200 Subject: [PATCH 7/7] test(iam): tighten jwt-bearer two-VP integration test 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 --- auth/api/iam/jwtbearer_integration_test.go | 138 ++++++++++----------- 1 file changed, 66 insertions(+), 72 deletions(-) diff --git a/auth/api/iam/jwtbearer_integration_test.go b/auth/api/iam/jwtbearer_integration_test.go index 8453cca87..e0c89ff15 100644 --- a/auth/api/iam/jwtbearer_integration_test.go +++ b/auth/api/iam/jwtbearer_integration_test.go @@ -60,7 +60,6 @@ func TestIntegration_JwtBearer_TwoVPHappyPath(t *testing.T) { // node config (not strictly required since metadata fetch is from the AS itself, but keeps // the wiring obvious). asMock := newMockAS(t) - defer asMock.server.Close() internalURL, _, _ := node.StartServer(t, func(_, _ string) { t.Setenv("NUTS_AUTH_EXPERIMENTAL_JWTBEARERCLIENT", "true") @@ -105,7 +104,7 @@ func TestIntegration_JwtBearer_TwoVPHappyPath(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode, "request-service-access-token failed: %s", respBody) // Wire-format assertions on the captured form body. - form := asMock.lastForm() + form := asMock.capturedForm(t) assert.Equal(t, oauth.JwtBearerGrantType, form.Get("grant_type"), "grant_type") assert.Equal(t, oauth.JwtBearerClientAssertionType, form.Get("client_assertion_type"), "client_assertion_type") assert.Equal(t, "medication-overview", form.Get("scope"), "scope") @@ -124,12 +123,13 @@ func TestIntegration_JwtBearer_TwoVPHappyPath(t *testing.T) { delegationCred := pluckCredentialByType(t, delegationVP, "ServiceProviderDelegationCredential") assert.Equal(t, orgDID, jsonString(t, delegationCred, "issuer"), "delegation credential issuer must equal VP1 signer (cross-VP binding on $.issuer == $.credentialSubject.id)") - assert.Equal(t, ura, deepString(t, delegationCred, "credentialSubject", 0, "hasDelegation", "delegatedBy", "identifier", 0, "value"), + delegatedBy, _ := firstSubject(t, delegationCred)["hasDelegation"].(map[string]any)["delegatedBy"].(map[string]any) + assert.Equal(t, ura, firstIdentifierValue(t, delegatedBy), "delegation delegatedBy URA must equal VP1 HCP URA (cross-VP binding on URA)") // Sanity: VP1 (organization VP) carried the HCP credential. hcpCred := pluckCredentialByType(t, hcpVP, "HealthcareProviderCredential") - assert.Equal(t, ura, deepString(t, hcpCred, "credentialSubject", 0, "identifier", 0, "value"), + assert.Equal(t, ura, firstIdentifierValue(t, firstSubject(t, hcpCred)), "HCP credential carries the expected URA") } @@ -191,26 +191,12 @@ const medicationOverviewPolicy = `{ } }` -// provisionSubject creates a Nuts subject if it doesn't already exist and returns its first -// did:web. Idempotent across the test run. +// provisionSubject creates a Nuts subject and returns its first did:web. The test runs against a +// fresh tempdir-backed node, so this is single-shot creation rather than idempotent lookup. func provisionSubject(t *testing.T, internalURL, subjectID string) string { t.Helper() - resp, err := http.Get(internalURL + "/internal/vdr/v2/subject/" + subjectID) - require.NoError(t, err) - if resp.StatusCode == http.StatusOK { - var dids []string - require.NoError(t, json.NewDecoder(resp.Body).Decode(&dids)) - resp.Body.Close() - for _, d := range dids { - if strings.HasPrefix(d, "did:web:") { - return d - } - } - t.Fatalf("subject %s exists but has no did:web", subjectID) - } - resp.Body.Close() body, _ := json.Marshal(map[string]string{"subject": subjectID}) - resp, err = http.Post(internalURL+"/internal/vdr/v2/subject", "application/json", bytes.NewReader(body)) + resp, err := http.Post(internalURL+"/internal/vdr/v2/subject", "application/json", bytes.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode, "create subject %s", subjectID) @@ -226,6 +212,15 @@ func provisionSubject(t *testing.T, internalURL, subjectID string) string { // issueAndLoad issues a credential against /internal/vcr/v2/issuer/vc and loads it into the // holder wallet under the given subject. Both calls are routed through the same node. +// +// expirationDate on the credential body is required when status-list revocation is not set; +// the statuslist machinery would add network round-trips we don't need in tests. +// +// Format key conventions in this file (three different specs in play): +// - issuer-API request body uses "format": "jwt_vc" (vcr v2 issuer API) +// - PE policy "format" object uses "jwt_vc" / "jwt_vp" (Presentation Exchange schema) +// - AS metadata "vp_formats_supported" uses "jwt_vc_json" +// and "jwt_vp_json" (OAuth 2.0 metadata) func issueAndLoad(t *testing.T, internalURL, holderSubject string, body []byte) { t.Helper() resp, err := http.Post(internalURL+"/internal/vcr/v2/issuer/vc", "application/json", bytes.NewReader(body)) @@ -246,8 +241,6 @@ func buildHCPCredential(issuerDID, subjectDID, ura string) []byte { "@context": []string{"https://www.w3.org/2018/credentials/v1"}, "type": []string{"VerifiableCredential", "HealthcareProviderCredential"}, "issuer": issuerDID, - // expirationDate is required when withStatusList2021Revocation is not set; the - // statuslist machinery would add network round-trips we don't need. "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "credentialSubject": map[string]any{ "id": subjectDID, @@ -267,8 +260,6 @@ func buildSPCredential(issuerDID, subjectDID string) []byte { "@context": []string{"https://www.w3.org/2018/credentials/v1"}, "type": []string{"VerifiableCredential", "ServiceProviderCredential"}, "issuer": issuerDID, - // expirationDate is required when withStatusList2021Revocation is not set; the - // statuslist machinery would add network round-trips we don't need. "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "credentialSubject": map[string]any{ "id": subjectDID, @@ -285,8 +276,6 @@ func buildDelegationCredential(issuerDID, subjectDID, ura string) []byte { "@context": []string{"https://www.w3.org/2018/credentials/v1"}, "type": []string{"VerifiableCredential", "ServiceProviderDelegationCredential"}, "issuer": issuerDID, - // expirationDate is required when withStatusList2021Revocation is not set; the - // statuslist machinery would add network round-trips we don't need. "expirationDate": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "credentialSubject": map[string]any{ "id": subjectDID, @@ -308,9 +297,17 @@ func buildDelegationCredential(issuerDID, subjectDID, ura string) []byte { // mockAS is the httptest authorization server. It advertises jwt-bearer support and did:web in // its metadata, captures the form body posted to /token, and returns a canned token response. +// +// The token-request path is fully synchronous: the IAMClient's POST to /token completes inside +// the request-service-access-token call. The captured form is therefore guaranteed to be set +// once the API returns 200, so no polling is needed. type mockAS struct { server *httptest.Server captured atomic.Pointer[url.Values] + // parseErr propagates a form-parsing failure from the token handler goroutine to the main + // test goroutine; require.NoError from inside the handler would only halt the handler, not + // the test. + parseErr atomic.Pointer[error] } func newMockAS(t *testing.T) *mockAS { @@ -336,7 +333,11 @@ func newMockAS(t *testing.T) *mockAS { }) mux.HandleFunc("/oauth2/", func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/token") { - require.NoError(t, r.ParseForm()) + if err := r.ParseForm(); err != nil { + m.parseErr.Store(&err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } form := r.PostForm m.captured.Store(&form) w.Header().Set("Content-Type", "application/json") @@ -346,18 +347,22 @@ func newMockAS(t *testing.T) *mockAS { w.WriteHeader(http.StatusNotFound) }) m.server = httptest.NewServer(mux) + t.Cleanup(m.server.Close) return m } -func (m *mockAS) lastForm() url.Values { - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if f := m.captured.Load(); f != nil { - return *f - } - time.Sleep(20 * time.Millisecond) +// capturedForm returns the form posted to /token. The handler runs synchronously inside the +// request-service-access-token call, so by the time the test reads this the form is set. +// Fails the test loudly (rather than returning an empty url.Values that makes wire-format +// asserts misleading) if the token endpoint was never hit or form parsing failed. +func (m *mockAS) capturedForm(t *testing.T) url.Values { + t.Helper() + if errPtr := m.parseErr.Load(); errPtr != nil { + t.Fatalf("mock AS failed to parse token-request form: %v", *errPtr) } - return nil + form := m.captured.Load() + require.NotNil(t, form, "mock AS /token endpoint was never called — request-service-access-token must have failed before reaching the AS") + return *form } // verifyVP submits a JWT-VP to /internal/vcr/v2/verifier/vp and returns the parsed envelope. The @@ -379,24 +384,16 @@ func verifyVP(t *testing.T, internalURL, vpJWT string) map[string]any { } // pluckCredentialByType returns the first verifiable credential in the verifier response whose -// type list contains the requested type. The verifier's "credentials" array contains either -// parsed JSON objects (ldp_vc) or JWT strings (jwt_vc); for the latter we decode the payload to -// inspect the vc claim. Returns the parsed credential payload (the inner object for JWT VCs, or -// the entry itself for JSON-LD VCs). +// type list contains the requested type. The verifier returns JWT-encoded credentials for the +// jwt_vc format used in this test; we decode the payload to inspect the vc claim. func pluckCredentialByType(t *testing.T, vp map[string]any, credType string) map[string]any { t.Helper() creds, ok := vp["credentials"].([]any) require.True(t, ok, "verifier/vp response missing 'credentials' array") for _, c := range creds { - var cm map[string]any - switch v := c.(type) { - case map[string]any: - cm = v - case string: - cm = decodeJWTVCPayload(t, v) - default: - t.Fatalf("unsupported credential element type %T", c) - } + jwtStr, ok := c.(string) + require.True(t, ok, "expected JWT-encoded credential string, got %T", c) + cm := decodeJWTVCPayload(t, jwtStr) types, _ := cm["type"].([]any) for _, ty := range types { if s, _ := ty.(string); s == credType { @@ -450,28 +447,25 @@ func jsonString(t *testing.T, m map[string]any, key string) string { return s } -// deepString walks the JSON value through the given keys (string for object keys, int for array -// indices) and returns the leaf as a string. Useful for asserting on nested credential subject -// structures returned by the verifier. -func deepString(t *testing.T, m any, keys ...any) string { +// firstSubject returns credentialSubject[0] of the given credential. The verifier returns +// credentialSubject as an array of objects; the test credentials always have a single subject. +func firstSubject(t *testing.T, cred map[string]any) map[string]any { t.Helper() - for i, k := range keys { - switch key := k.(type) { - case string: - obj, ok := m.(map[string]any) - require.True(t, ok, "step %d (%v): expected object, got %T", i, key, m) - m, ok = obj[key] - require.True(t, ok, "step %d (%v): key not found", i, key) - case int: - arr, ok := m.([]any) - require.True(t, ok, "step %d (%v): expected array, got %T", i, key, m) - require.Less(t, key, len(arr), "step %d (%v): index out of range (len=%d)", i, key, len(arr)) - m = arr[key] - default: - t.Fatalf("step %d: unsupported key type %T", i, key) - } - } - s, ok := m.(string) - require.True(t, ok, "leaf is not string: %T (%v)", m, m) - return s + subjects, ok := cred["credentialSubject"].([]any) + require.True(t, ok && len(subjects) > 0, "credentialSubject is not a non-empty array: %v", cred["credentialSubject"]) + subject, ok := subjects[0].(map[string]any) + require.True(t, ok, "credentialSubject[0] is not an object: %T", subjects[0]) + return subject +} + +// firstIdentifierValue returns identifier[0].value from the given object. The HCP credential's +// subject and the delegation credential's delegatedBy both follow the +// `identifier: [{system, value}]` shape from the gis-nl context. +func firstIdentifierValue(t *testing.T, m map[string]any) string { + t.Helper() + idents, ok := m["identifier"].([]any) + require.True(t, ok && len(idents) > 0, "identifier is not a non-empty array: %v", m["identifier"]) + ident, ok := idents[0].(map[string]any) + require.True(t, ok, "identifier[0] is not an object: %T", idents[0]) + return jsonString(t, ident, "value") }