diff --git a/auth/api/iam/jwtbearer_integration_test.go b/auth/api/iam/jwtbearer_integration_test.go new file mode 100644 index 000000000..e0c89ff15 --- /dev/null +++ b/auth/api/iam/jwtbearer_integration_test.go @@ -0,0 +1,471 @@ +/* + * 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) + + 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.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") + 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)") + 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, firstIdentifierValue(t, firstSubject(t, hcpCred)), + "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 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() + 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. +// +// 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)) + 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": 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": 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": 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. +// +// 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 { + 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") { + 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") + _, _ = w.Write([]byte(`{"access_token": "mock-token", "token_type": "Bearer", "expires_in": 900}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + m.server = httptest.NewServer(mux) + t.Cleanup(m.server.Close) + return m +} + +// 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) + } + 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 +// 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 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 { + 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 { + 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 +} + +// 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() + 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") +}