diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index c4c309869c..a4970f89ad 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -28,8 +28,8 @@ import ( pkg2 "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/contract" - "github.com/nuts-foundation/nuts-node/auth/openid4vci" oauth2 "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/dummy" "github.com/nuts-foundation/nuts-node/auth/services/oauth" diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 739e24169f..76ed36fb3f 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -729,6 +729,17 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS if err != nil { return nil, err } + // service_provider_subject_id is optional. When absent the single-VP flow runs; when present it + // selects the two-VP jwt-bearer flow. An explicit empty string is ambiguous, reject it here so + // the flow doesn't route on an unset value. + if request.Body.ServiceProviderSubjectId != nil { + if *request.Body.ServiceProviderSubjectId == "" { + return nil, core.InvalidInputError("service_provider_subject_id is optional and cannot be empty: omit the field or set a non-empty value") + } + if err := r.subjectExists(ctx, *request.Body.ServiceProviderSubjectId); err != nil { + return nil, err + } + } tokenCache := r.accessTokenCache() cacheKey := accessTokenRequestCacheKey(request) @@ -782,7 +793,7 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection) + tokenResult, err := r.auth.IAMClient().RequestServiceAccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, request.Body.ServiceProviderSubjectId) if err != nil { // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials return nil, err diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 5295e724e7..b3af7d5e17 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -887,7 +887,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} request.Params.CacheControl = to.Ptr("no-cache") // Initial call to populate cache - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(response, nil).Times(2) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(response, nil).Times(2) token, err := ctx.client.RequestServiceAccessToken(nil, request) // Test call to check cache is bypassed @@ -908,7 +908,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { TokenType: "Bearer", ExpiresIn: to.Ptr(900), } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(response, nil) token, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -947,7 +947,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("cache expired", func(t *testing.T) { cacheKey := accessTokenRequestCacheKey(request) _ = ctx.client.accessTokenCache().Delete(cacheKey) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) otherToken, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -964,7 +964,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { Scope: "first second", TokenType: &tokenTypeBearer, } - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, nil).Return(&oauth.TokenResponse{}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, nil, nil).Return(&oauth.TokenResponse{}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -973,7 +973,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("ok with expired cache by ttl", func(t *testing.T) { ctx := newTestClient(t) request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -982,7 +982,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { }) t.Run("error - no matching credentials", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(nil, pe.ErrNoCredentials) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(nil, pe.ErrNoCredentials) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -998,8 +998,8 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { ctx.client.storageEngine = mockStorage request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) token1, err := ctx.client.RequestServiceAccessToken(nil, request) require.NoError(t, err) @@ -1008,6 +1008,64 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { assert.NotEqual(t, token1, token2) }) + t.Run("service_provider_subject_id", func(t *testing.T) { + t.Run("ok - threads through to IAM client", func(t *testing.T) { + // Verifies the wire-up of the new OpenAPI field. The dispatch and feature gating live in + // the IAM client (under #4227); the handler's job is to validate and forward the value. + ctx := newTestClient(t) + spSubject := verifierSubject // re-use the global fixture's existing Exists(true) mock + bodyWithSP := &RequestServiceAccessTokenJSONRequestBody{ + AuthorizationServer: verifierURL.String(), + Scope: "first second", + ServiceProviderSubjectId: &spSubject, + } + response := &oauth.TokenResponse{AccessToken: "sp-token", TokenType: "Bearer", ExpiresIn: to.Ptr(900)} + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, &spSubject).Return(response, nil) + + tokenResponse, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithSP}) + + require.NoError(t, err) + // Assert the response actually round-trips, not just that the call didn't error. + assert.Equal(t, "sp-token", tokenResponse.(RequestServiceAccessToken200JSONResponse).AccessToken) + }) + t.Run("empty string is rejected up front", func(t *testing.T) { + // service_provider_subject_id is optional; an explicit "" must not silently dispatch into + // the two-VP flow with a meaningless subject. + ctx := newTestClient(t) + emptySP := "" + bodyWithEmptySP := &RequestServiceAccessTokenJSONRequestBody{ + AuthorizationServer: verifierURL.String(), + Scope: "first second", + ServiceProviderSubjectId: &emptySP, + } + + _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithEmptySP}) + + require.Error(t, err) + assert.ErrorContains(t, err, "service_provider_subject_id") + assert.ErrorContains(t, err, "cannot be empty") + }) + t.Run("unknown subject returns 400", func(t *testing.T) { + // Mirrors the existing path-param check: a non-existent service-provider subject yields a + // clear OAuth invalid_request, not the misleading 412 "did method mismatch" that would come + // from a downstream ListDIDs("") result. + ctx := newTestClient(t) + unknownSP := unknownSubjectID + bodyWithUnknownSP := &RequestServiceAccessTokenJSONRequestBody{ + AuthorizationServer: verifierURL.String(), + Scope: "first second", + ServiceProviderSubjectId: &unknownSP, + } + + _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithUnknownSP}) + + require.Error(t, err) + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.InvalidRequest, oauthErr.Code) + assert.Contains(t, oauthErr.Description, "subject not found") + }) + }) t.Run("self-asserted credentials", func(t *testing.T) { response := &oauth.TokenResponse{ AccessToken: "token", @@ -1024,7 +1082,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { {ID: to.Ptr(ssi.MustParseURI("not empty"))}, } request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body} - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil).Return(response, nil) + ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil, nil).Return(response, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index e960c6f2e4..f4c6f46a0d 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -186,6 +186,19 @@ type ServiceAccessTokenRequest struct { // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` + // ServiceProviderSubjectId **Experimental.** Nuts subject identifier of the OAuth client (service provider). + // When present, the node uses the RFC 7523 jwt-bearer two-VP token request flow: + // VP1 is built from the wallet identified by the path-param `subjectID` (the healthcare + // provider) using the `organization` PD; VP2 is built from the wallet identified here + // using the `service_provider` PD. Requires `auth.experimental.jwtbearerclient = true`, + // an authorization server that advertises `urn:ietf:params:oauth:grant-type:jwt-bearer`, + // and a `service_provider` PD configured for the requested credential profile. + // + // When omitted, the existing single-VP `vp_token-bearer` flow runs unchanged. + // + // Subject to change without notice. + ServiceProviderSubjectId *string `json:"service_provider_subject_id,omitempty"` + // TokenType The type of access token that is preferred, default: DPoP TokenType *ServiceAccessTokenRequestTokenType `json:"token_type,omitempty"` } diff --git a/auth/api/iam/jwtbearer_integration_test.go b/auth/api/iam/jwtbearer_integration_test.go new file mode 100644 index 0000000000..51b4774da2 --- /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") +} diff --git a/auth/auth.go b/auth/auth.go index e09e367fe1..c64235dfcb 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -132,7 +132,17 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty { func (auth *Auth) IAMClient() iam.Client { keyResolver := resolver.DIDKeyResolver{Resolver: auth.vdrInstance.Resolver()} - return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout) + return iam.NewClient(iam.ClientConfig{ + Wallet: auth.vcr.Wallet(), + KeyResolver: keyResolver, + SubjectManager: auth.subjectManager, + JWTSigner: auth.keyStore, + LDDocumentLoader: auth.jsonldManager.DocumentLoader(), + PolicyBackend: auth.policyBackend, + StrictMode: auth.strictMode, + HTTPClientTimeout: auth.httpClientTimeout, + ExperimentalJwtBearerClient: auth.config.Experimental.JwtBearerClient, + }) } // OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client. diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index b5ab97a03b..1517b83bfa 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -44,11 +44,17 @@ type Client interface { PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (string, error) // PresentationDefinition returns the presentation definition from the given endpoint. PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error) - // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021. - // credentials are additional VCs to include alongside wallet-stored credentials. + // RequestServiceAccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server. + // When serviceProviderSubjectID is nil, the request uses the Nuts RFC021 vp_token-bearer single-VP flow. + // When serviceProviderSubjectID is non-nil it identifies a service-provider Nuts subject and triggers the RFC 7523 + // jwt-bearer two-VP flow; that flow is only honored when the experimental jwt-bearer client feature is enabled and + // the AS advertises jwt-bearer. + // credentials are additional VCs to include alongside wallet-stored credentials. In the two-VP flow they are + // offered to both wallets; each PD selects what matches its input descriptors. Signed VCs flow through unchanged; + // unsigned self-attested credentials are auto-issued per holder DID by AutoCorrectSelfAttestedCredential. // credentialSelection maps PD field IDs to expected values to disambiguate when multiple credentials match an input descriptor. - RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, - credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) + RequestServiceAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, useDPoP bool, + credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) // OpenIDConfiguration returns the OpenID Configuration of the remote wallet. OpenIDConfiguration(ctx context.Context, issuer string) (*oauth.OpenIDConfiguration, error) diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 6587d7f3af..dac35b1be8 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -178,17 +178,17 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestObjectByPost", reflect.TypeOf((*MockClient)(nil).RequestObjectByPost), ctx, requestURI, walletMetadata) } -// RequestRFC021AccessToken mocks base method. -func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) { +// RequestServiceAccessToken mocks base method. +func (m *MockClient) RequestServiceAccessToken(ctx context.Context, clientID, subjectID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection) + ret := m.ctrl.Call(m, "RequestServiceAccessToken", ctx, clientID, subjectID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } -// RequestRFC021AccessToken indicates an expected call of RequestRFC021AccessToken. -func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection any) *gomock.Call { +// RequestServiceAccessToken indicates an expected call of RequestServiceAccessToken. +func (mr *MockClientMockRecorder) RequestServiceAccessToken(ctx, clientID, subjectID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestServiceAccessToken", reflect.TypeOf((*MockClient)(nil).RequestServiceAccessToken), ctx, clientID, subjectID, authServerURL, scopes, useDPoP, credentials, credentialSelection, serviceProviderSubjectID) } diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 3d4b4d4b19..34522f3191 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -52,41 +52,67 @@ import ( // ErrPreconditionFailed is returned when a precondition is not met. var ErrPreconditionFailed = errors.New("precondition failed") +// vpAssertionLifetime is the validity window of a Verifiable Presentation built for a token request. +// Short by design: the VP is signed and posted within the same call. +const vpAssertionLifetime = 5 * time.Second + var _ Client = (*OpenID4VPClient)(nil) type OpenID4VPClient struct { - httpClient HTTPClient - jwtSigner nutsCrypto.JWTSigner - keyResolver resolver.KeyResolver - strictMode bool - wallet holder.Wallet - ldDocumentLoader ld.DocumentLoader - subjectManager didsubject.Manager - pdResolver PresentationDefinitionResolver + httpClient HTTPClient + jwtSigner nutsCrypto.JWTSigner + keyResolver resolver.KeyResolver + strictMode bool + wallet holder.Wallet + ldDocumentLoader ld.DocumentLoader + subjectManager didsubject.Manager + pdResolver PresentationDefinitionResolver + policyBackend policy.PDPBackend + experimentalJwtBearerClient bool +} + +// ClientConfig groups the dependencies and toggles needed to construct an OpenID4VPClient. +// The interface fields (Wallet, KeyResolver, SubjectManager, JWTSigner, LDDocumentLoader, +// PolicyBackend) are required; NewClient does not validate them and missing fields will surface as +// nil-pointer panics on first use. Scalar fields default to their zero value (StrictMode=false, +// HTTPClientTimeout=0, ExperimentalJwtBearerClient=false) and the zero values are valid. +type ClientConfig struct { + Wallet holder.Wallet + KeyResolver resolver.KeyResolver + SubjectManager didsubject.Manager + JWTSigner nutsCrypto.JWTSigner + LDDocumentLoader ld.DocumentLoader + PolicyBackend policy.PDPBackend + StrictMode bool + HTTPClientTimeout time.Duration + // ExperimentalJwtBearerClient gates the RFC 7523 jwt-bearer two-VP token request flow. + // Tracked for replacement by a list-style grant-types config under issue #4231. + ExperimentalJwtBearerClient bool } -// NewClient returns an implementation of Holder -func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectManager didsubject.Manager, jwtSigner nutsCrypto.JWTSigner, - ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { +// NewClient returns an OpenID4VPClient configured with the given dependencies. +func NewClient(cfg ClientConfig) *OpenID4VPClient { httpClient := HTTPClient{ - strictMode: strictMode, - httpClient: client.NewWithCache(httpClientTimeout), - keyResolver: keyResolver, - } - client := &OpenID4VPClient{ - httpClient: httpClient, - keyResolver: keyResolver, - jwtSigner: jwtSigner, - ldDocumentLoader: ldDocumentLoader, - subjectManager: subjectManager, - strictMode: strictMode, - wallet: wallet, - } - client.pdResolver = PresentationDefinitionResolver{ - pdFetcher: client, - policyBackend: policyBackend, - } - return client + strictMode: cfg.StrictMode, + httpClient: client.NewWithCache(cfg.HTTPClientTimeout), + keyResolver: cfg.KeyResolver, + } + c := &OpenID4VPClient{ + httpClient: httpClient, + keyResolver: cfg.KeyResolver, + jwtSigner: cfg.JWTSigner, + ldDocumentLoader: cfg.LDDocumentLoader, + subjectManager: cfg.SubjectManager, + strictMode: cfg.StrictMode, + wallet: cfg.Wallet, + policyBackend: cfg.PolicyBackend, + experimentalJwtBearerClient: cfg.ExperimentalJwtBearerClient, + } + c.pdResolver = PresentationDefinitionResolver{ + pdFetcher: c, + policyBackend: cfg.PolicyBackend, + } + return c } func (c *OpenID4VPClient) ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) { @@ -243,14 +269,36 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd return &token, nil } -func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, - useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) { - iamClient := c.httpClient +func (c *OpenID4VPClient) RequestServiceAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, + useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, serviceProviderSubjectID *string) (*oauth.TokenResponse, error) { + if serviceProviderSubjectID != nil && !c.experimentalJwtBearerClient { + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedGrantType, + Description: "jwt-bearer two-VP flow requires auth.experimental.jwtbearerclient = true", + } + } metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { return nil, err } + if serviceProviderSubjectID != nil { + if !slices.Contains(metadata.GrantTypesSupported, oauth.JwtBearerGrantType) { + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedGrantType, + Description: fmt.Sprintf("authorization server does not advertise %q in grant_types_supported", oauth.JwtBearerGrantType), + } + } + return c.requestJwtBearerAccessToken(ctx, subjectID, *serviceProviderSubjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) + } + return c.requestVPTokenAccessToken(ctx, clientID, subjectID, authServerURL, scopes, useDPoP, additionalCredentials, credentialSelection, metadata) +} +// requestVPTokenAccessToken implements the single-VP RFC021 vp_token-bearer flow: resolve the +// presentation definition (remotely if the AS advertises one, locally otherwise), build a single VP from +// the caller's wallet, and POST it as `assertion` alongside the PE submission and DPoP header (when used). +func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, + scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, + metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { // Resolve the presentation definition: from remote AS when available, local policy otherwise resolved, err := c.pdResolver.Resolve(ctx, scopes, *metadata) if err != nil { @@ -261,45 +309,12 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID params := holder.BuildParams{ Audience: authServerURL, DIDMethods: metadata.DIDMethodsSupported, - Expires: time.Now().Add(time.Second * 5), + Expires: time.Now().Add(vpAssertionLifetime), Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } - subjectDIDs, err := c.subjectManager.ListDIDs(ctx, subjectID) - if err != nil { - return nil, err - } - - // in the s2s flow we use the metadata to determine the DID methods supported by the verifier - // filter walletDIDs on the DID methods supported by the verifier - j := 0 - allMethods := map[string]struct{}{} - for i, d := range subjectDIDs { - allMethods[d.Method] = struct{}{} - if slices.Contains(metadata.DIDMethodsSupported, d.Method) { - subjectDIDs[j] = subjectDIDs[i] - j++ - } - } - subjectDIDs = subjectDIDs[:j] - - if len(subjectDIDs) == 0 { - availableMethods := make([]string, 0, len(allMethods)) - for key := range maps.Keys(allMethods) { - availableMethods = append(availableMethods, key) - } - return nil, errors.Join(ErrPreconditionFailed, fmt.Errorf("did method mismatch, requested: %v, available: %v", metadata.DIDMethodsSupported, availableMethods)) - } - - // each additional credential can be used by each DID - additionalWalletCredentials := map[did.DID][]vc.VerifiableCredential{} - for _, subjectDID := range subjectDIDs { - for _, curr := range additionalCredentials { - additionalWalletCredentials[subjectDID] = append(additionalWalletCredentials[subjectDID], credential.AutoCorrectSelfAttestedCredential(curr, subjectDID)) - } - } - vp, submission, err := c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, *presentationDefinition, credentialSelection, params) + vp, submission, err := c.buildSubmissionForSubject(ctx, subjectID, *presentationDefinition, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } @@ -317,22 +332,102 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) data.Set(oauth.ScopeParam, resolved.Scope) - // create DPoP header - var dpopHeader string - var dpopKid string - if useDPoP { - request, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.TokenEndpoint, nil) - if err != nil { - return nil, err + log.Logger().Tracef("Requesting access token from '%s' for scope '%s'\n VP: %s\n Submission: %s", metadata.TokenEndpoint, scopes, assertion, string(presentationSubmission)) + return c.postTokenRequest(ctx, *subjectDID, useDPoP, data, resolved.Scope, metadata) +} + +// requestJwtBearerAccessToken implements the RFC 7523 jwt-bearer two-VP token request flow. +// It builds VP1 from the healthcare-provider (HCP) wallet using the organization PD, and VP2 from the +// service-provider (SP) wallet using the service_provider PD, assembles them as `assertion` and +// `client_assertion`, and POSTs the token request. +// Per RFC 7521 §4.2 the client is authenticated by the client_assertion, so no OAuth client_id form +// parameter is sent on this path. +// Both PDs are resolved from the local policy backend; the AS's remote presentation_definition endpoint +// is not consulted (no standardised mechanism exists today for the AS to advertise a service_provider PD). +func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, organizationSubjectID string, serviceProviderSubjectID string, + authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, + metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { + profile, resolvedScope, err := loadAndValidateProfile(ctx, c.policyBackend, scopes) + if err != nil { + return nil, err + } + // loadAndValidateProfile guarantees the organization PD; the service_provider PD is two-VP-specific. + orgPD := profile.WalletOwnerMapping[pe.WalletOwnerOrganization] + spPD, hasSP := profile.WalletOwnerMapping[pe.WalletOwnerServiceProvider] + if !hasSP { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidScope, + Description: fmt.Sprintf("no service_provider presentation definition for scope %q", profile.CredentialProfileScope), } - dpopHeader, dpopKid, err = c.dpop(ctx, *subjectDID, *request) - if err != nil { - return nil, fmt.Errorf("failed to create DPoP header: %w", err) + } + params := holder.BuildParams{ + Audience: authServerURL, + DIDMethods: metadata.DIDMethodsSupported, + Expires: time.Now().Add(vpAssertionLifetime), + Format: metadata.VPFormatsSupported, + Nonce: nutsCrypto.GenerateNonce(), + } + organizationVP, organizationSubmission, err := c.buildSubmissionForSubject(ctx, organizationSubjectID, orgPD, additionalCredentials, credentialSelection, params) + if err != nil { + return nil, err + } + // Cross-VP binding: capture id-bearing constraint field values resolved against the organization VP + // and additively merge them into the credential_selection map for the service-provider VP. The + // submission tells us which credential satisfied each input descriptor; we use that to walk the PD's + // id-bearing fields and extract their matched values. + envelope, err := pe.NewEnvelopeFromVP(*organizationVP) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("failed to wrap the organization VP in an envelope for cross-VP binding: %s", err), } } + credentialMap, err := organizationSubmission.Resolve(*envelope) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("failed to resolve the organization VP submission for cross-VP binding: %s", err), + } + } + captured, err := orgPD.ResolveConstraintsFields(credentialMap) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: fmt.Sprintf("failed to extract cross-VP binding values from the organization VP against the organization presentation definition: %s", err), + } + } + credentialSelection = applyCapturedFieldsToSelection(credentialSelection, captured) + // Each VP must carry its own nonce; reuse would let a verifier confuse the two assertions. + params.Nonce = nutsCrypto.GenerateNonce() + serviceProviderVP, _, err := c.buildSubmissionForSubject(ctx, serviceProviderSubjectID, spPD, additionalCredentials, credentialSelection, params) + if err != nil { + return nil, err + } + // DPoP binds the issued access token to a key the service provider controls — the SP wallet will + // present and use the token, so the proof is signed with the SP DID's key. + spDID, err := did.ParseDID(serviceProviderVP.Holder.String()) + if err != nil { + return nil, err + } + data := url.Values{} + data.Set(oauth.GrantTypeParam, oauth.JwtBearerGrantType) + data.Set(oauth.AssertionParam, organizationVP.Raw()) + data.Set(oauth.ClientAssertionTypeParam, oauth.JwtBearerClientAssertionType) + data.Set(oauth.ClientAssertionParam, serviceProviderVP.Raw()) + data.Set(oauth.ScopeParam, resolvedScope) + + log.Logger().Tracef("Requesting jwt-bearer access token from '%s' for scope '%s'\n organization VP: %s\n service provider VP: %s", metadata.TokenEndpoint, resolvedScope, organizationVP.Raw(), serviceProviderVP.Raw()) + return c.postTokenRequest(ctx, *spDID, useDPoP, data, resolvedScope, metadata) +} - log.Logger().Tracef("Requesting access token from '%s' for scope '%s'\n VP: %s\n Submission: %s", metadata.TokenEndpoint, scopes, assertion, string(presentationSubmission)) - token, err := iamClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) +// postTokenRequest signs a DPoP header for the signerDID (when useDPoP), POSTs the token request to the +// AS, and assembles the oauth.TokenResponse. Shared tail of the single-VP and jwt-bearer two-VP flows. +func (c *OpenID4VPClient) postTokenRequest(ctx context.Context, signerDID did.DID, useDPoP bool, data url.Values, resolvedScope string, metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) { + dpopHeader, dpopKid, err := c.signDPoPHeader(ctx, useDPoP, signerDID, metadata.TokenEndpoint) + if err != nil { + return nil, err + } + token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) if err != nil { // the error could be a http error, we just relay it here to make use of any 400 status codes. return nil, err @@ -341,7 +436,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID AccessToken: token.AccessToken, ExpiresIn: token.ExpiresIn, TokenType: token.TokenType, - Scope: &scopes, + Scope: &resolvedScope, } if dpopKid != "" { tokenResponse.DPoPKid = &dpopKid @@ -349,6 +444,89 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID return &tokenResponse, nil } +// buildSubmissionForSubject lists DIDs for the given subject, filters them to those whose method is in +// params.DIDMethods, and asks the wallet to build a VP that fulfills the given PresentationDefinition. +func (c *OpenID4VPClient) buildSubmissionForSubject(ctx context.Context, subjectID string, presentationDefinition pe.PresentationDefinition, + additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string, params holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + subjectDIDs, err := c.subjectManager.ListDIDs(ctx, subjectID) + if err != nil { + return nil, nil, err + } + subjectDIDs, err = filterDIDsByMethods(subjectDIDs, params.DIDMethods) + if err != nil { + return nil, nil, err + } + additionalWalletCredentials := map[did.DID][]vc.VerifiableCredential{} + for _, subjectDID := range subjectDIDs { + for _, curr := range additionalCredentials { + additionalWalletCredentials[subjectDID] = append(additionalWalletCredentials[subjectDID], credential.AutoCorrectSelfAttestedCredential(curr, subjectDID)) + } + } + return c.wallet.BuildSubmission(ctx, subjectDIDs, additionalWalletCredentials, presentationDefinition, credentialSelection, params) +} + +// applyCapturedFieldsToSelection returns a fresh selection map containing every entry from selection +// plus any string-valued entry from captured whose key is not already present. Non-string captured values +// are skipped (selection is map[string]string). The caller's selection map is never mutated, so the +// captured values cannot leak back through any retained reference. +func applyCapturedFieldsToSelection(selection map[string]string, captured map[string]any) map[string]string { + merged := make(map[string]string, len(selection)+len(captured)) + for k, v := range selection { + merged[k] = v + } + for k, v := range captured { + if _, exists := merged[k]; exists { + continue + } + s, ok := v.(string) + if !ok { + continue + } + merged[k] = s + } + return merged +} + +// filterDIDsByMethods drops DIDs whose method is not in supportedMethods. Returns ErrPreconditionFailed when +// none of the subject's DIDs use a supported method. +func filterDIDsByMethods(subjectDIDs []did.DID, supportedMethods []string) ([]did.DID, error) { + j := 0 + allMethods := map[string]struct{}{} + for i, d := range subjectDIDs { + allMethods[d.Method] = struct{}{} + if slices.Contains(supportedMethods, d.Method) { + subjectDIDs[j] = subjectDIDs[i] + j++ + } + } + subjectDIDs = subjectDIDs[:j] + if len(subjectDIDs) == 0 { + availableMethods := make([]string, 0, len(allMethods)) + for key := range maps.Keys(allMethods) { + availableMethods = append(availableMethods, key) + } + return nil, errors.Join(ErrPreconditionFailed, fmt.Errorf("did method mismatch, requested: %v, available: %v", supportedMethods, availableMethods)) + } + return subjectDIDs, nil +} + +// signDPoPHeader signs a DPoP proof for a token-endpoint POST bound to signerDID's assertion key. +// Returns ("", "", nil) when useDPoP is false so callers can use the result unconditionally. +func (c *OpenID4VPClient) signDPoPHeader(ctx context.Context, useDPoP bool, signerDID did.DID, tokenEndpoint string) (string, string, error) { + if !useDPoP { + return "", "", nil + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, nil) + if err != nil { + return "", "", err + } + header, kid, err := c.dpop(ctx, signerDID, *request) + if err != nil { + return "", "", fmt.Errorf("failed to create DPoP header: %w", err) + } + return header, kid, nil +} + func (c *OpenID4VPClient) dpop(ctx context.Context, requester did.DID, request http.Request) (string, string, error) { // find the key to sign the DPoP token with keyID, _, err := c.keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 85851418ac..081617e082 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -41,6 +41,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/policy" http2 "github.com/nuts-foundation/nuts-node/test/http" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -237,7 +238,7 @@ func TestIAMClient_AuthorizationServerMetadata(t *testing.T) { }) } -func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { +func TestRelyingParty_RequestServiceAccessToken(t *testing.T) { const subjectID = "subby" const subjectClientID = "https://example.com/oauth2/subby" primaryWalletDID := did.MustParseDID("did:test:primary") @@ -254,7 +255,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -266,7 +267,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.ErrorIs(t, err, pe.ErrNoCredentials) assert.Nil(t, response) @@ -276,7 +277,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.authzServerMetadata.DIDMethodsSupported = []string{"other"} ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrPreconditionFailed) @@ -313,7 +314,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return createdVP, &pe.PresentationSubmission{}, nil }) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -327,7 +328,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil) + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -341,7 +342,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { } oauthErrorBytes, _ := json.Marshal(oauthError) ctx := createClientServerTestContext(t) - ctx.token = func(writer http.ResponseWriter) { + ctx.token = func(writer http.ResponseWriter, _ *http.Request) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusBadRequest) _, _ = writer.Write(oauthErrorBytes) @@ -349,7 +350,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) var oauthErrResult oauth.OAuth2Error @@ -367,7 +368,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.True(t, errors.As(err, &oauth.OAuth2Error{})) @@ -377,7 +378,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.metadata = nil - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidClientCall) @@ -391,7 +392,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, _ = writer.Write([]byte("{")) } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrBadGateway) @@ -402,12 +403,412 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, assert.AnError) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil) + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, nil) assert.Error(t, err) }) } +// enableJwtBearerClient flips the experimental jwt-bearer client feature flag on the test client. +// It exists so subtests don't have to type-assert to the concrete *OpenID4VPClient just to poke an +// unexported field; the intent ("opt this test into the two-VP flow") is also clearer at the call site. +func enableJwtBearerClient(t *testing.T, ctx *clientServerTestContext) { + t.Helper() + ctx.client.(*OpenID4VPClient).experimentalJwtBearerClient = true +} + +func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { + const subjectID = "subby" + const subjectClientID = "https://example.com/oauth2/subby" + const spSubjectID = "service-provider-subby" + scopes := "first second" + + t.Run("rejects the request when the experimental jwt-bearer feature is disabled", func(t *testing.T) { + // The gate fires synchronously before any HTTP call, so the lightweight test context (no TLS + // server, no metadata fixtures) is sufficient. The default zero value of + // experimentalJwtBearerClient is false; we leave it alone so the gate fires. + sp := spSubjectID + ctx := createClientTestContext(t, nil) + + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, "https://example.com/oauth2/verifier", scopes, false, nil, nil, &sp) + + require.Error(t, err) + assert.ErrorContains(t, err, "jwt-bearer") + }) + + t.Run("rejects the request when the AS does not advertise jwt-bearer", func(t *testing.T) { + sp := spSubjectID + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + // The default AS metadata in the test setup does not include JwtBearerGrantType in grant_types_supported. + + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.Error(t, err) + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.UnsupportedGrantType, oauthErr.Code) + assert.Contains(t, oauthErr.Description, oauth.JwtBearerGrantType) + assert.Contains(t, oauthErr.Description, "grant_types_supported") + }) + + t.Run("rejects the request when no service_provider PD is configured for the scope", func(t *testing.T) { + sp := spSubjectID + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + }, + }, nil) + + _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.Error(t, err) + assert.ErrorContains(t, err, "no service_provider presentation definition") + }) + + // Scope-policy behaviour (profile-only rejection, passthrough forwarding, missing organization PD) + // is covered by TestLoadAndValidateProfile, which exercises the same helper this path delegates to. + + t.Run("rejects the request when the SP wallet has no DIDs matching the AS's supported methods", func(t *testing.T) { + // VP1 succeeds; the second buildSubmissionForSubject for the SP wallet then runs ListDIDs + // followed by filterDIDsByMethods, which returns ErrPreconditionFailed when no DID's method + // is in the AS's DIDMethodsSupported list. The test pins DIDMethodsSupported explicitly so + // the assertion does not silently flip when the fixture default changes. + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDIDWrongMethod := did.MustParseDID("did:web:example.com:sp") + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.authzServerMetadata.DIDMethodsSupported = []string{"test"} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDIDWrongMethod}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil) + + _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.ErrorIs(t, err, ErrPreconditionFailed) + assert.ErrorContains(t, err, "did method mismatch") + }) + + t.Run("rejects the request when the SP wallet lacks credentials matching the service_provider PD", func(t *testing.T) { + // VP1 succeeds; the SP wallet has DIDs but its BuildSubmission cannot satisfy sp_pd, so it + // returns pe.ErrNoCredentials. The error must propagate to the caller unchanged so the API + // layer can map it to a 412 Precondition Failed. + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), + pe.PresentationDefinition{Id: "sp_pd"}, gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials) + + _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + assert.ErrorIs(t, err, pe.ErrNoCredentials) + }) + + t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) { + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // VP1 is built from the HCP wallet using the organization PD; VP2 from the SP wallet using the service_provider PD. + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), + pe.PresentationDefinition{Id: "sp_pd"}, gomock.Any(), gomock.Any()).Return(serviceProviderVP, &pe.PresentationSubmission{}, nil) + + var capturedForm url.Values + ctx.token = func(writer http.ResponseWriter, request *http.Request) { + require.NoError(t, request.ParseForm()) + capturedForm = request.PostForm + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) + } + + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, oauth.JwtBearerGrantType, capturedForm.Get(oauth.GrantTypeParam)) + assert.Equal(t, organizationVP.Raw(), capturedForm.Get(oauth.AssertionParam)) + assert.Equal(t, oauth.JwtBearerClientAssertionType, capturedForm.Get(oauth.ClientAssertionTypeParam)) + assert.Equal(t, serviceProviderVP.Raw(), capturedForm.Get(oauth.ClientAssertionParam)) + assert.Empty(t, capturedForm.Get(oauth.PresentationSubmissionParam)) + // Per RFC 7521 §4.2 client_id is optional when client_assertion is present and we omit it. + assert.Empty(t, capturedForm.Get(oauth.ClientIDParam)) + }) + + t.Run("VP1 and VP2 carry distinct nonces", func(t *testing.T) { + // Each VP must be signed with a fresh nonce. Reusing the same nonce would let a verifier mistakenly + // treat the two assertions as a single signed payload, or accept a replayed pairing of VP1 with a + // stale VP2 (or vice versa). + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // Capture both BuildSubmission's params arguments so we can compare nonces directly. + var organizationVPParams, serviceProviderVPParams holder.BuildParams + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, p holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + organizationVPParams = p + return organizationVP, &pe.PresentationSubmission{}, nil + }) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, p holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + serviceProviderVPParams = p + return serviceProviderVP, &pe.PresentationSubmission{}, nil + }) + + _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + require.NotEmpty(t, organizationVPParams.Nonce) + require.NotEmpty(t, serviceProviderVPParams.Nonce) + assert.NotEqual(t, organizationVPParams.Nonce, serviceProviderVPParams.Nonce, "VP2 must be signed with a fresh nonce") + }) + + t.Run("captured VP1 field-id values flow into VP2 credential_selection end-to-end", func(t *testing.T) { + // Cross-VP binding scenario, end to end: when the organization PD and the service_provider PD share + // the same constraint-field `id` (here: "delegating_hcp"), the value matched in the organization VP + // must flow into the service-provider VP's credential_selection so the wallet building it can pick + // a credential constrained by that value. This test follows the chain through + // requestJwtBearerAccessToken: + // + // organizationVP + organizationSubmission --NewEnvelopeFromVP+Resolve--> credentialMap + // credentialMap + orgPD --ResolveConstraintsFields--> {delegating_hcp: did:test:hcp} + // --applyCapturedFieldsToSelection--> credential_selection passed to the service-provider + // wallet's BuildSubmission + // + // TestApplyCapturedFieldsToSelection covers the merge step in isolation; this test exists to guard + // the wiring from the first BuildSubmission's return values into the second BuildSubmission's args. + + sp := spSubjectID + hcpDID := did.MustParseDID("did:test:hcp") + spDID := did.MustParseDID("did:test:sp") + + // VP1 is the healthcare provider's presentation. It must be a parseable JSON-LD VP that contains + // one credential whose $.issuer is the HCP DID — that value is what the binding will capture. + // `holder` is set so the DPoP code path (which derives a signing DID from vp.Holder) doesn't panic + // even though we don't enable DPoP in this test. + organizationVP, err := vc.ParseVerifiablePresentation(`{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiablePresentation"], + "holder": "did:test:hcp", + "verifiableCredential": [{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential"], + "issuer": "did:test:hcp", + "credentialSubject": {"id": "did:test:hcp"}, + "proof": {"type": "JsonWebSignature2020"} + }], + "proof": {"type": "JsonWebSignature2020"} + }`) + require.NoError(t, err) + // VP2's body is irrelevant for this test — we only assert what VP2's BuildSubmission was *called* + // with; we never inspect serviceProviderVP itself afterwards. The holder is set for the same DPoP-safety reason. + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + + // organizationSubmission tells the resolver "input descriptor id_org_cred was satisfied by the credential at + // $.verifiableCredential[0]". Without this, ResolveVP can't bridge from a descriptor id to a VC, and + // ResolveConstraintsFields has nothing to walk for $.issuer. + organizationSubmission := &pe.PresentationSubmission{ + DescriptorMap: []pe.InputDescriptorMappingObject{ + {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, + }, + } + + // orgPD declares one constraint field with id "delegating_hcp" pointing at $.issuer of the matched + // VC. The id is what makes the value capturable; an unidentified field would be ignored. + fieldID := "delegating_hcp" + orgPD := pe.PresentationDefinition{ + Id: "org_pd", + InputDescriptors: []*pe.InputDescriptor{{ + Id: "id_org_cred", + Constraints: &pe.Constraints{ + Fields: []pe.Field{{Id: &fieldID, Path: []string{"$.issuer"}}}, + }, + }}, + } + // spPD shares the field.id "delegating_hcp" by convention. The sharing is what binds VP2 to a value + // matched in VP1. The PD itself carries no constraints in this test because the wallet mock never + // inspects it; we only care that the credential_selection it receives contains the captured value. + spPD := pe.PresentationDefinition{Id: "sp_pd"} + + ctx := createClientServerTestContext(t) + // Enable the experimental flag so the dispatcher routes us into the two-VP path. + enableJwtBearerClient(t, ctx) + // Advertise jwt-bearer so the dispatcher's "AS supports jwt-bearer" check passes. + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + // The two-VP path always resolves PDs from the local policy backend (no remote PD endpoint for the + // service_provider concept), so this is the single source of truth for both PDs in the request. + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: orgPD, + pe.WalletOwnerServiceProvider: spPD, + }, + }, nil) + // VP1 is built from the HCP wallet (subjectID), VP2 from the SP wallet (spSubjectID) — different + // subject IDs, different DID candidate slices. + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + // First BuildSubmission call: build VP1 against the HCP DID using orgPD. We return the JSON-LD VP + // and its submission so the production code can resolve constraint fields against them. + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(), + orgPD, gomock.Any(), gomock.Any()).Return(organizationVP, organizationSubmission, nil) + // Second BuildSubmission call: build VP2 against the SP DID using spPD. We capture the + // credential_selection argument so the assertion at the bottom can verify the merged field-id + // value made it through the orchestration. + var capturedSPSelection map[string]string + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(), + spPD, gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ []did.DID, _ map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, sel map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + capturedSPSelection = sel + return serviceProviderVP, &pe.PresentationSubmission{}, nil + }) + + _, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + + require.NoError(t, err) + // Payoff: the HCP DID matched against $.issuer in VP1's credential should be captured under + // "delegating_hcp" and forwarded to VP2's wallet — without the EHR caller having to set it. + assert.Equal(t, "did:test:hcp", capturedSPSelection["delegating_hcp"]) + }) + + t.Run("ok with DPoPHeader", func(t *testing.T) { + // DPoP binds the issued access token to a key the SP wallet controls — the proof must be signed with + // the SP DID's key (serviceProviderVP.Holder), not the HCP DID's key. + sp := spSubjectID + spDID := did.MustParseDID("did:test:sp") + spKID := "did:test:sp#1" + organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`) + require.NoError(t, err) + serviceProviderVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:sp","proof":[{"verificationMethod":"did:test:sp#1"}]}`) + require.NoError(t, err) + ctx := createClientServerTestContext(t) + enableJwtBearerClient(t, ctx) + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType} + ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + WalletOwnerMapping: pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"}, + pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"}, + }, + }, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{did.MustParseDID("did:test:hcp")}, nil) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(serviceProviderVP, &pe.PresentationSubmission{}, nil) + // The DPoP signing key must be resolved against the SP DID, not the HCP DID — that's the assertion. + ctx.keyResolver.EXPECT().ResolveKey(spDID, nil, resolver.NutsSigningKeyType).Return(spKID, nil, nil) + ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), spKID).Return("dpop", nil) + + response, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil, &sp) + + require.NoError(t, err) + require.NotNil(t, response) + require.NotNil(t, response.DPoPKid) + assert.Equal(t, spKID, *response.DPoPKid) + }) +} + +func TestApplyCapturedFieldsToSelection(t *testing.T) { + t.Run("adds string-valued captured entries to a nil selection", func(t *testing.T) { + merged := applyCapturedFieldsToSelection(nil, map[string]any{"delegating_hcp": "did:test:hcp"}) + + assert.Equal(t, map[string]string{"delegating_hcp": "did:test:hcp"}, merged) + }) + + t.Run("does not overwrite EHR-supplied selection keys", func(t *testing.T) { + ehrSelection := map[string]string{"delegating_hcp": "did:ehr:override"} + + merged := applyCapturedFieldsToSelection(ehrSelection, map[string]any{"delegating_hcp": "did:test:hcp"}) + + assert.Equal(t, "did:ehr:override", merged["delegating_hcp"]) + }) + + t.Run("skips non-string captured values", func(t *testing.T) { + merged := applyCapturedFieldsToSelection(nil, map[string]any{"int_field": 42, "string_field": "ok"}) + + assert.Equal(t, map[string]string{"string_field": "ok"}, merged) + }) + + t.Run("does not mutate the caller's selection map", func(t *testing.T) { + // The caller (HTTP handler) typically passes a request-derived map. Mutating it would risk leaking + // captured values into anything that retains a reference to that map (logging, caches, retries). + ehrSelection := map[string]string{"caller_key": "caller_value"} + + _ = applyCapturedFieldsToSelection(ehrSelection, map[string]any{"captured_key": "captured_value"}) + + assert.Equal(t, map[string]string{"caller_key": "caller_value"}, ehrSelection) + }) +} + func TestIAMClient_RequestObjectByGet(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) @@ -475,6 +876,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon keyResolver := resolver.NewMockKeyResolver(ctrl) subjectManager := didsubject.NewMockManager(ctrl) wallet := holder.NewMockWallet(ctrl) + policyBackend := policy.NewMockPDPBackend(ctrl) if tlsConfig == nil { tlsConfig = &tls.Config{} } @@ -487,10 +889,11 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon strictMode: false, httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig), }, - jwtSigner: jwtSigner, - keyResolver: keyResolver, + jwtSigner: jwtSigner, + keyResolver: keyResolver, + policyBackend: policyBackend, } - testClient.pdResolver = PresentationDefinitionResolver{pdFetcher: testClient} + testClient.pdResolver = PresentationDefinitionResolver{pdFetcher: testClient, policyBackend: policyBackend} return &clientTestContext{ audit: audit.TestContext(), @@ -500,6 +903,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon keyResolver: keyResolver, wallet: wallet, subjectManager: subjectManager, + policyBackend: policyBackend, } } @@ -511,6 +915,7 @@ type clientTestContext struct { keyResolver *resolver.MockKeyResolver wallet *holder.MockWallet subjectManager *didsubject.MockManager + policyBackend *policy.MockPDPBackend } type clientServerTestContext struct { @@ -527,7 +932,7 @@ type clientServerTestContext struct { credentialIssuerMetadata func(writer http.ResponseWriter) presentationDefinition func(writer http.ResponseWriter) response func(writer http.ResponseWriter) - token func(writer http.ResponseWriter) + token func(writer http.ResponseWriter, request *http.Request) nonce func(writer http.ResponseWriter) credentials func(writer http.ResponseWriter) requestObjectJWT func(writer http.ResponseWriter) @@ -577,7 +982,7 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { _, _ = writer.Write(bytes) return }, - token: func(writer http.ResponseWriter) { + token: func(writer http.ResponseWriter, _ *http.Request) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) @@ -636,7 +1041,7 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { } case "/token": if ctx.token != nil { - ctx.token(writer) + ctx.token(writer, request) return } case "/nonce": @@ -674,4 +1079,3 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext { return ctx } - diff --git a/auth/client/iam/pd_resolver.go b/auth/client/iam/pd_resolver.go index edce53eb21..a5973ac29c 100644 --- a/auth/client/iam/pd_resolver.go +++ b/auth/client/iam/pd_resolver.go @@ -78,33 +78,14 @@ func (r *PresentationDefinitionResolver) resolveRemote(ctx context.Context, scop } func (r *PresentationDefinitionResolver) resolveLocal(ctx context.Context, scope string) (*ResolvedPresentationDefinition, error) { - if r.policyBackend == nil { - return nil, fmt.Errorf("local PD resolution requires a policy backend, but none is configured") - } - match, err := r.policyBackend.FindCredentialProfile(ctx, scope) + profile, resolvedScope, err := loadAndValidateProfile(ctx, r.policyBackend, scope) if err != nil { - return nil, fmt.Errorf("local PD resolution failed: %w", err) - } - if match.ScopePolicy == policy.ScopePolicyProfileOnly && len(match.OtherScopes) > 0 { - return nil, oauth.OAuth2Error{ - Code: oauth.InvalidScope, - Description: "scope policy 'profile-only' does not allow additional scopes", - } - } - // Select the organization PD (default for current single-VP flow). - // TODO: When #4080 adds two-VP support, this resolver will need to return multiple PDs. - pd, ok := match.WalletOwnerMapping[pe.WalletOwnerOrganization] - if !ok { - return nil, fmt.Errorf("no organization presentation definition for scope %q", match.CredentialProfileScope) - } - // For passthrough and dynamic, forward all scopes to the remote AS. - // The client does not evaluate dynamic scopes — the server handles PDP evaluation at token-grant time (PR #4179). - resolvedScope := scope - if match.ScopePolicy == policy.ScopePolicyProfileOnly { - resolvedScope = match.CredentialProfileScope + return nil, err } + // Select the organization PD (default for the single-VP flow). loadAndValidateProfile already verified + // the org PD is present, so the lookup is safe. return &ResolvedPresentationDefinition{ - PresentationDefinition: pd, + PresentationDefinition: profile.WalletOwnerMapping[pe.WalletOwnerOrganization], Scope: resolvedScope, }, nil } diff --git a/auth/client/iam/profile_validation.go b/auth/client/iam/profile_validation.go new file mode 100644 index 0000000000..36ea87c4eb --- /dev/null +++ b/auth/client/iam/profile_validation.go @@ -0,0 +1,59 @@ +/* + * 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 + +import ( + "context" + "fmt" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) + +// loadAndValidateProfile fetches the credential profile for the requested scope, applies the scope policy, +// and verifies that the profile defines an organization PresentationDefinition (required by every local +// flow that fans out from VP1). +// +// Returns the credential profile and the resolved scope to forward to the AS. Returns an oauth.OAuth2Error +// (InvalidScope) when the profile is configured as profile-only but the request carries extra scopes; a +// plain error when the profile cannot be loaded or lacks the organization PD. +func loadAndValidateProfile(ctx context.Context, backend policy.PDPBackend, scope string) (*policy.CredentialProfileMatch, string, error) { + if backend == nil { + return nil, "", fmt.Errorf("local PD resolution requires a policy backend, but none is configured") + } + profile, err := backend.FindCredentialProfile(ctx, scope) + if err != nil { + return nil, "", fmt.Errorf("local PD resolution failed: %w", err) + } + if profile.ScopePolicy == policy.ScopePolicyProfileOnly && len(profile.OtherScopes) > 0 { + return nil, "", oauth.OAuth2Error{ + Code: oauth.InvalidScope, + Description: "scope policy 'profile-only' does not allow additional scopes", + } + } + if _, ok := profile.WalletOwnerMapping[pe.WalletOwnerOrganization]; !ok { + return nil, "", fmt.Errorf("no organization presentation definition for scope %q", profile.CredentialProfileScope) + } + resolvedScope := scope + if profile.ScopePolicy == policy.ScopePolicyProfileOnly { + resolvedScope = profile.CredentialProfileScope + } + return profile, resolvedScope, nil +} diff --git a/auth/client/iam/profile_validation_test.go b/auth/client/iam/profile_validation_test.go new file mode 100644 index 0000000000..ed53214138 --- /dev/null +++ b/auth/client/iam/profile_validation_test.go @@ -0,0 +1,117 @@ +/* + * 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 + +import ( + "context" + "errors" + "testing" + + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestLoadAndValidateProfile(t *testing.T) { + const requested = "first second" + orgPD := pe.PresentationDefinition{Id: "org_pd"} + + t.Run("returns error when no policy backend is configured", func(t *testing.T) { + _, _, err := loadAndValidateProfile(context.Background(), nil, requested) + + assert.ErrorContains(t, err, "local PD resolution requires a policy backend") + }) + + t.Run("wraps the policy backend error", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(nil, errors.New("boom")) + + _, _, err := loadAndValidateProfile(context.Background(), backend, requested) + + assert.ErrorContains(t, err, "local PD resolution failed: boom") + }) + + t.Run("rejects extra scopes when policy is profile-only", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + OtherScopes: []string{"second"}, + ScopePolicy: policy.ScopePolicyProfileOnly, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: orgPD}, + }, nil) + + _, _, err := loadAndValidateProfile(context.Background(), backend, requested) + + var oauthErr oauth.OAuth2Error + require.ErrorAs(t, err, &oauthErr) + assert.Equal(t, oauth.InvalidScope, oauthErr.Code) + assert.Contains(t, oauthErr.Description, "scope policy 'profile-only' does not allow additional scopes") + }) + + t.Run("rejects when the organization PD is missing", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + ScopePolicy: policy.ScopePolicyPassthrough, + WalletOwnerMapping: pe.WalletOwnerMapping{}, // no organization + }, nil) + + _, _, err := loadAndValidateProfile(context.Background(), backend, requested) + + assert.ErrorContains(t, err, `no organization presentation definition for scope "first"`) + }) + + t.Run("collapses to credential profile scope when policy is profile-only and no extras", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), "first").Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + ScopePolicy: policy.ScopePolicyProfileOnly, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: orgPD}, + }, nil) + + profile, resolved, err := loadAndValidateProfile(context.Background(), backend, "first") + + require.NoError(t, err) + assert.Equal(t, "first", resolved) + assert.Equal(t, "first", profile.CredentialProfileScope) + }) + + t.Run("forwards the full input scope when policy is passthrough", func(t *testing.T) { + ctrl := gomock.NewController(t) + backend := policy.NewMockPDPBackend(ctrl) + backend.EXPECT().FindCredentialProfile(gomock.Any(), requested).Return(&policy.CredentialProfileMatch{ + CredentialProfileScope: "first", + OtherScopes: []string{"second"}, + ScopePolicy: policy.ScopePolicyPassthrough, + WalletOwnerMapping: pe.WalletOwnerMapping{pe.WalletOwnerOrganization: orgPD}, + }, nil) + + _, resolved, err := loadAndValidateProfile(context.Background(), backend, requested) + + require.NoError(t, err) + assert.Equal(t, requested, resolved) + }) +} diff --git a/auth/cmd/cmd.go b/auth/cmd/cmd.go index 5a2abf01e5..e0787c6f8c 100644 --- a/auth/cmd/cmd.go +++ b/auth/cmd/cmd.go @@ -47,6 +47,9 @@ const ConfAccessTokenLifeSpan = "auth.accesstokenlifespan" // ConfAuthEndpointEnabled is the config key for enabling the Auth v2 API's Authorization Endpoint const ConfAuthEndpointEnabled = "auth.authorizationendpoint.enabled" +// ConfExperimentalJwtBearerClient toggles the RFC 7523 jwt-bearer two-VP token request flow. +const ConfExperimentalJwtBearerClient = "auth.experimental.jwtbearerclient" + // FlagSet returns the configuration flags supported by this module. func FlagSet() *pflag.FlagSet { flags := pflag.NewFlagSet("auth", pflag.ContinueOnError) @@ -61,6 +64,8 @@ func FlagSet() *pflag.FlagSet { flags.StringSlice(ConfContractValidators, defs.ContractValidators, "sets the different contract validators to use") flags.Bool(ConfAuthEndpointEnabled, defs.AuthorizationEndpoint.Enabled, "enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. "+ "This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature.") + flags.Bool(ConfExperimentalJwtBearerClient, defs.Experimental.JwtBearerClient, "enables the experimental RFC 7523 jwt-bearer two-VP token request flow. "+ + "While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice.") _ = flags.MarkDeprecated("auth.http.timeout", "use httpclient.timeout instead") return flags diff --git a/auth/cmd/cmd_test.go b/auth/cmd/cmd_test.go index 5fe44f4c19..88514b3745 100644 --- a/auth/cmd/cmd_test.go +++ b/auth/cmd/cmd_test.go @@ -46,6 +46,7 @@ func TestFlagSet(t *testing.T) { ConfAuthEndpointEnabled, ConfClockSkew, ConfContractValidators, + ConfExperimentalJwtBearerClient, ConfHTTPTimeout, ConfAutoUpdateIrmaSchemas, ConfIrmaCorsOrigin, diff --git a/auth/config.go b/auth/config.go index 0f30bd3c95..78cf9ea95f 100644 --- a/auth/config.go +++ b/auth/config.go @@ -32,6 +32,15 @@ type Config struct { ContractValidators []string `koanf:"contractvalidators"` AccessTokenLifeSpan int `koanf:"accesstokenlifespan"` AuthorizationEndpoint AuthorizationEndpointConfig `koanf:"authorizationendpoint"` + Experimental ExperimentalConfig `koanf:"experimental"` +} + +// ExperimentalConfig groups feature flags for unstable functionality. +// Anything inside is subject to change without notice and may be removed in a future release. +type ExperimentalConfig struct { + // JwtBearerClient enables the RFC 7523 jwt-bearer two-VP token request flow. + // While disabled (the default), requests carrying a service-provider subject identifier are rejected. + JwtBearerClient bool `koanf:"jwtbearerclient"` } type AuthorizationEndpointConfig struct { diff --git a/auth/oauth/types.go b/auth/oauth/types.go index f039884c92..380909c0ec 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -200,6 +200,10 @@ const ( ScopeParam = "scope" // StateParam is the parameter name for the state parameter. (RFC6749) StateParam = "state" + // ClientAssertionTypeParam is the parameter name for the client_assertion_type parameter. (RFC7521) + ClientAssertionTypeParam = "client_assertion_type" + // ClientAssertionParam is the parameter name for the client_assertion parameter. (RFC7521) + ClientAssertionParam = "client_assertion" // VpTokenParam is the parameter name for the vp_token parameter. (OpenID4VP) VpTokenParam = "vp_token" // WalletMetadataParam is used by the wallet to provide its metadata in an authorization request when RequestURIMethodParam is 'post' @@ -216,6 +220,14 @@ const ( PreAuthorizedCodeGrantType = "urn:ietf:params:oauth:grant-type:pre-authorized_code" // VpTokenGrantType is the grant_type for the vp_token-bearer grant type. (RFC021) VpTokenGrantType = "vp_token-bearer" + // JwtBearerGrantType is the grant_type for the RFC 7523 JWT bearer grant type. + JwtBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" +) + +// client assertion types +const ( + // JwtBearerClientAssertionType is the canonical value of ClientAssertionTypeParam for the RFC 7523 JWT bearer client assertion. + JwtBearerClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" ) // response types diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index f00124c82f..9bef49ba9e 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -56,7 +56,7 @@ type RequestCredentialOpts struct { CredentialConfigurationID string CredentialIdentifier string ProofJWT string - CredentialRequestParams map[string]any + CredentialRequestParams map[string]any } // Client is the OpenID4VCI 1.0 HTTP client interface. diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 8c9575a145..3779a5c5fe 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -524,6 +524,21 @@ components: description: "The type of access token that is preferred, default: DPoP" default: DPoP enum: [ Bearer, DPoP ] + service_provider_subject_id: + type: string + description: | + **Experimental.** Nuts subject identifier of the OAuth client (service provider). + When present, the node uses the RFC 7523 jwt-bearer two-VP token request flow: + VP1 is built from the wallet identified by the path-param `subjectID` (the healthcare + provider) using the `organization` PD; VP2 is built from the wallet identified here + using the `service_provider` PD. Requires `auth.experimental.jwtbearerclient = true`, + an authorization server that advertises `urn:ietf:params:oauth:grant-type:jwt-bearer`, + and a `service_provider` PD configured for the requested credential profile. + + When omitted, the existing single-VP `vp_token-bearer` flow runs unchanged. + + Subject to change without notice. + example: acme-service-provider UserAccessTokenRequest: type: object description: Request for an access token for a user. diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index d2abaa117e..a50875a0bd 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -74,7 +74,11 @@ JSON documents used for policies must have the following structure: Where ``example_scope`` is the scope that the presentation definition is associated with. The ``presentation_definition`` object contains the presentation definition that should be used for the given scope. -The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization`` and ``user``. +The ``wallet_owner_type`` field is used to determine the audience type of the presentation definition, valid values are ``organization``, ``service_provider`` and ``user``. + +The ``service_provider`` block describes the credentials that a service provider acting on behalf of a healthcare provider (the OAuth client in the RFC 7523 ``jwt-bearer`` flow) must present. +It applies only to outbound RFC 7523 token requests initiated by the node. +A profile may define any combination of ``organization``, ``service_provider`` and ``user`` blocks; at least one is required. OAuth2 Token Introspection field mapping ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -126,4 +130,99 @@ You can define the following field in the input descriptor constraint, to have t } } -Only 1 capture group is supported in regular expressions. If multiple capture groups are defined, an error will be returned. \ No newline at end of file +Only 1 capture group is supported in regular expressions. If multiple capture groups are defined, an error will be returned. + +Two-VP flow and cross-VP binding (experimental) +*********************************************** + +.. warning:: + The two-VP flow is **experimental** and gated behind ``auth.experimental.jwtbearerclient = true`` (default ``false``). + The ``service_provider`` PD block, the ``service_provider_subject_id`` API field, and the cross-VP binding mechanism described below are subject to change without notice while the underlying OAuth profile stabilises. + +When the two-VP flow runs +^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default the node uses a single-VP token request (RFC 021 ``vp_token-bearer``). The two-VP RFC 7523 ``jwt-bearer`` flow runs only when **all** of the following hold: + +1. The experimental flag ``auth.experimental.jwtbearerclient`` is ``true`` on the EHR-side node. +2. The EHR caller passes ``service_provider_subject_id`` in the body of ``POST /internal/auth/v2/{subjectID}/request-service-access-token``. +3. The remote authorization server advertises ``urn:ietf:params:oauth:grant-type:jwt-bearer`` in its metadata's ``grant_types_supported``. +4. The credential profile referenced by the request scope has a ``service_provider`` PD configured. + +If conditions (2)-(4) are not all met when ``service_provider_subject_id`` is supplied, the request fails with a clear error rather than silently falling back to the single-VP flow. + +How the two VPs are built +^^^^^^^^^^^^^^^^^^^^^^^^^ + +- **VP1 (organization)** — built from the wallet of the path-param ``subjectID`` (the healthcare provider, HCP), using the credential profile's ``organization`` PD. Sent as the ``assertion`` form parameter (RFC 7521 §4.1, the authorization grant). +- **VP2 (service_provider)** — built from the wallet of ``service_provider_subject_id`` (the OAuth client, the service provider acting on behalf of the HCP), using the credential profile's ``service_provider`` PD. Sent as the ``client_assertion`` form parameter (RFC 7521 §4.2, authenticating the client). + +Each VP is signed with the holder DID's keys from the respective wallet. + +Cross-VP binding via shared ``field.id`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Constraint fields with the same ``id`` across the two PDs implicitly bind a value captured from VP1 into the credential selection for VP2. This lets policy authors express delegation requirements (e.g. *"VP2's delegation credential must be issued by the DID that signed VP1"*) without writing custom matcher code; the binding is realised through standard Presentation Exchange constructs. + +Example: a profile that requires VP2 to include a ``ServiceProviderDelegationCredential`` issued by the same DID as VP1's ``HealthcareProviderCredential``: + +.. code-block:: json + + { + "example_delegated_scope": { + "organization": { + "id": "org_pd", + "input_descriptors": [{ + "id": "hcp_credential", + "constraints": { + "fields": [ + { "path": ["$.type"], "filter": { "type": "string", "const": "HealthcareProviderCredential" } }, + { "id": "delegating_hcp", "path": ["$.issuer"] } + ] + } + }] + }, + "service_provider": { + "id": "sp_pd", + "input_descriptors": [{ + "id": "delegation_credential", + "constraints": { + "fields": [ + { "path": ["$.type"], "filter": { "type": "string", "const": "ServiceProviderDelegationCredential" } }, + { "id": "delegating_hcp", "path": ["$.issuer"] } + ] + } + }] + } + } + } + +How it works at request time: + +1. VP1 is built. The matcher records the value at ``$.issuer`` of the credential that satisfied ``hcp_credential`` and labels it with the field id ``delegating_hcp``. +2. Before VP2 is built, that captured value is additively merged into the ``credential_selection`` map (see below). +3. When VP2 is selected against ``sp_pd``, the wallet only considers ``ServiceProviderDelegationCredential`` candidates whose ``$.issuer`` equals the captured value — i.e. credentials delegated by the exact HCP that signed VP1. + +If the SP wallet has no delegation credential issued by VP1's HCP, the request returns ``pe.ErrNoCredentials`` (HTTP 412 Precondition Failed) and the EHR can show a clear "no delegation on file" error to the user. + +The ``credential_selection`` map +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``credential_selection`` is a key-value map (string keys → string values) used to disambiguate between credentials when multiple satisfy a single input descriptor. Keys must match a constraint field ``id``; values are the literal string the field should equal for selection. + +There are two sources of entries: + +- **EHR-supplied** — the ``credential_selection`` field on the request body. EHRs typically use this for runtime context like a patient or encounter identifier (e.g. ``patient_id``). +- **Server-captured (two-VP only)** — entries populated automatically from VP1's matched constraint fields, as described above. The capture is *additive*: any EHR-supplied key always wins, and only string-typed captured values are merged in. + +The same map is consulted by both single-VP and two-VP flows; the only difference is that the two-VP flow may add entries between VP1 and VP2. + +Required configuration +^^^^^^^^^^^^^^^^^^^^^^ + +To enable the two-VP flow on a node: + +1. Set ``auth.experimental.jwtbearerclient: true`` in the node config (off by default). +2. Provision the service-provider Nuts subject and its wallet via the existing wallet APIs. Its wallet must hold credentials matching the ``service_provider`` PD for any profile that should support the flow. +3. Add a ``service_provider`` PD block to each credential profile that should support the flow. +4. Have the EHR pass ``service_provider_subject_id`` on the access-token request body. \ No newline at end of file diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index a281ac14d6..e6c5f3d304 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -17,6 +17,7 @@ httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. **Auth** auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature. + auth.experimental.jwtbearerclient false enables the experimental RFC 7523 jwt-bearer two-VP token request flow. While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice. **Crypto** crypto.storage Storage to use, 'fs' for file system (for development purposes), 'vaultkv' for HashiCorp Vault KV store, 'azure-keyvault' for Azure Key Vault, 'external' for an external backend (deprecated). crypto.azurekv.hsm false Whether to store the key in a hardware security module (HSM). If true, the Azure Key Vault must be configured for HSM usage. Default: false @@ -69,4 +70,5 @@ tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. + policy.authzen.endpoint Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index 66da41c742..bd0d3f4581 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -8,6 +8,9 @@ Unreleased ## New features * #4063: Enable ``storage.debug`` flag to log go-leia performance issues (full table scans, suboptimal index usage) by @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4064 +* #4078: Allow policy profiles to define a ``service_provider`` PresentationDefinition for the OAuth client (RFC 7523 ``jwt-bearer`` flow) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4226 +* #4078: Add the experimental RFC 7523 ``jwt-bearer`` two-VP token request flow, gated behind ``auth.experimental.jwtbearerclient`` (default ``false``, subject to change) by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4227 +* #4078: Expose the experimental two-VP flow on ``POST /internal/auth/v2/{subjectID}/request-service-access-token`` via the optional ``service_provider_subject_id`` body field by @stevenvegt in https://github.com/nuts-foundation/nuts-node/pull/4228 * #4233: ``request-credential`` API gains an optional ``credential_request_params`` JSON object overlaid on top of the OpenID4VCI Credential Request body sent to the issuer. Lets the wallet talk to issuers that accept additional fields, or to override the credential request entirely. **************** diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 04fab33294..840d47715c 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -180,6 +180,19 @@ type ServiceAccessTokenRequest struct { // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` + // ServiceProviderSubjectId **Experimental.** Nuts subject identifier of the OAuth client (service provider). + // When present, the node uses the RFC 7523 jwt-bearer two-VP token request flow: + // VP1 is built from the wallet identified by the path-param `subjectID` (the healthcare + // provider) using the `organization` PD; VP2 is built from the wallet identified here + // using the `service_provider` PD. Requires `auth.experimental.jwtbearerclient = true`, + // an authorization server that advertises `urn:ietf:params:oauth:grant-type:jwt-bearer`, + // and a `service_provider` PD configured for the requested credential profile. + // + // When omitted, the existing single-VP `vp_token-bearer` flow runs unchanged. + // + // Subject to change without notice. + ServiceProviderSubjectId *string `json:"service_provider_subject_id,omitempty"` + // TokenType The type of access token that is preferred, default: DPoP TokenType *ServiceAccessTokenRequestTokenType `json:"token_type,omitempty"` } diff --git a/policy/local.go b/policy/local.go index d972659ea2..f8f6e93b89 100644 --- a/policy/local.go +++ b/policy/local.go @@ -182,8 +182,8 @@ func (b *LocalPDP) loadFromFile(filename string) error { if profile.ScopePolicy == "" { profile.ScopePolicy = ScopePolicyProfileOnly } - if profile.Organization == nil && profile.User == nil { - return fmt.Errorf("credential profile %q must define at least one of 'organization' or 'user' (file=%s)", scope, filename) + if profile.Organization == nil && profile.ServiceProvider == nil && profile.User == nil { + return fmt.Errorf("credential profile %q must define at least one of 'organization', 'service_provider', or 'user' (file=%s)", scope, filename) } if !profile.ScopePolicy.valid() { return fmt.Errorf("invalid scope_policy %q for scope %q (file=%s)", profile.ScopePolicy, scope, filename) @@ -195,9 +195,10 @@ func (b *LocalPDP) loadFromFile(filename string) error { // credentialProfileConfig holds the configuration for a single credential profile. type credentialProfileConfig struct { - Organization *validatingPresentationDefinition `json:"organization,omitempty"` - User *validatingPresentationDefinition `json:"user,omitempty"` - ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` + Organization *validatingPresentationDefinition `json:"organization,omitempty"` + ServiceProvider *validatingPresentationDefinition `json:"service_provider,omitempty"` + User *validatingPresentationDefinition `json:"user,omitempty"` + ScopePolicy ScopePolicy `json:"scope_policy,omitempty"` } func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { @@ -205,6 +206,9 @@ func (c credentialProfileConfig) toWalletOwnerMapping() pe.WalletOwnerMapping { if c.Organization != nil { m[pe.WalletOwnerOrganization] = pe.PresentationDefinition(*c.Organization) } + if c.ServiceProvider != nil { + m[pe.WalletOwnerServiceProvider] = pe.PresentationDefinition(*c.ServiceProvider) + } if c.User != nil { m[pe.WalletOwnerUser] = pe.PresentationDefinition(*c.User) } diff --git a/policy/local_test.go b/policy/local_test.go index b25e931436..139b12702d 100644 --- a/policy/local_test.go +++ b/policy/local_test.go @@ -21,6 +21,7 @@ package policy import ( "context" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/pe" "testing" "github.com/stretchr/testify/assert" @@ -28,14 +29,41 @@ import ( ) func TestStore_LoadFromFile(t *testing.T) { - t.Run("loads the mapping from the file", func(t *testing.T) { + t.Run("loads an organization-only profile and exposes it via WalletOwnerMapping", func(t *testing.T) { store := LocalPDP{} err := store.loadFromFile("test/definition_mapping.json") require.NoError(t, err) - assert.Len(t, store.mapping, 1) - assert.NotNil(t, store.mapping["example-scope"]) + require.Len(t, store.mapping, 1) + mapping := store.mapping["example-scope"].toWalletOwnerMapping() + assert.Contains(t, mapping, pe.WalletOwnerOrganization) + assert.NotContains(t, mapping, pe.WalletOwnerServiceProvider) + assert.NotContains(t, mapping, pe.WalletOwnerUser) + }) + + t.Run("loads organization, service_provider, and user PDs from a single profile", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/service_provider/with_org_sp_user.json") + + require.NoError(t, err) + mapping := store.mapping["example-scope"].toWalletOwnerMapping() + assert.Equal(t, "pd_organization", mapping[pe.WalletOwnerOrganization].Id) + assert.Equal(t, "pd_service_provider", mapping[pe.WalletOwnerServiceProvider].Id) + assert.Equal(t, "pd_user", mapping[pe.WalletOwnerUser].Id) + }) + + t.Run("loads a profile that defines only a service_provider PD", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/service_provider/service_provider_only.json") + + require.NoError(t, err) + mapping := store.mapping["service-provider-only-scope"].toWalletOwnerMapping() + assert.Equal(t, "pd_service_provider_only", mapping[pe.WalletOwnerServiceProvider].Id) + assert.NotContains(t, mapping, pe.WalletOwnerOrganization) + assert.NotContains(t, mapping, pe.WalletOwnerUser) }) t.Run("returns an error if the file doesn't exist", func(t *testing.T) { @@ -46,13 +74,29 @@ func TestStore_LoadFromFile(t *testing.T) { assert.Error(t, err) }) - t.Run("returns an error if a presentation definition is invalid", func(t *testing.T) { + t.Run("returns an error if the organization PD is invalid", func(t *testing.T) { store := LocalPDP{} err := store.loadFromFile("test/invalid/invalid_definition_mapping.json") assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") }) + + t.Run("returns an error if the service_provider PD is invalid", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/invalid/invalid_service_provider_pd.json") + + assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") + }) + + t.Run("returns an error if no PD is defined for a profile", func(t *testing.T) { + store := LocalPDP{} + + err := store.loadFromFile("test/invalid/no_pds.json") + + assert.ErrorContains(t, err, "must define at least one of 'organization', 'service_provider', or 'user'") + }) } func TestLocalPDP_FindCredentialProfile(t *testing.T) { diff --git a/policy/test/invalid/invalid_service_provider_pd.json b/policy/test/invalid/invalid_service_provider_pd.json new file mode 100644 index 0000000000..5687658bcd --- /dev/null +++ b/policy/test/invalid/invalid_service_provider_pd.json @@ -0,0 +1,7 @@ +{ + "service-provider-malformed-scope": { + "service_provider": { + "id": "pd_service_provider_missing_input_descriptors" + } + } +} diff --git a/policy/test/invalid/no_pds.json b/policy/test/invalid/no_pds.json new file mode 100644 index 0000000000..177d82047c --- /dev/null +++ b/policy/test/invalid/no_pds.json @@ -0,0 +1,5 @@ +{ + "no-pd-scope": { + "scope_policy": "profile_only" + } +} diff --git a/policy/test/service_provider/service_provider_only.json b/policy/test/service_provider/service_provider_only.json new file mode 100644 index 0000000000..ae3c883ecf --- /dev/null +++ b/policy/test/service_provider/service_provider_only.json @@ -0,0 +1,23 @@ +{ + "service-provider-only-scope": { + "service_provider": { + "id": "pd_service_provider_only", + "input_descriptors": [ + { + "id": "id_sp_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "ServiceProviderCredential" + } + } + ] + } + } + ] + } + } +} diff --git a/policy/test/service_provider/with_org_sp_user.json b/policy/test/service_provider/with_org_sp_user.json new file mode 100644 index 0000000000..35d00a0b2b --- /dev/null +++ b/policy/test/service_provider/with_org_sp_user.json @@ -0,0 +1,61 @@ +{ + "example-scope": { + "organization": { + "id": "pd_organization", + "input_descriptors": [ + { + "id": "id_org_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + } + ] + } + } + ] + }, + "service_provider": { + "id": "pd_service_provider", + "input_descriptors": [ + { + "id": "id_sp_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "ServiceProviderCredential" + } + } + ] + } + } + ] + }, + "user": { + "id": "pd_user", + "input_descriptors": [ + { + "id": "id_user_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "EmployeeCredential" + } + } + ] + } + } + ] + } + } +} diff --git a/vcr/pe/policy.go b/vcr/pe/policy.go index 32d067320f..e3c18ef65f 100644 --- a/vcr/pe/policy.go +++ b/vcr/pe/policy.go @@ -29,4 +29,7 @@ const ( WalletOwnerOrganization = WalletOwnerType("organization") // WalletOwnerUser is used in a WalletOwnerMapping when the PresentationDefinition is intended for a user WalletOwnerUser = WalletOwnerType("user") + // WalletOwnerServiceProvider is used in a WalletOwnerMapping when the PresentationDefinition is intended for a + // service provider acting on behalf of an organization (e.g. the OAuth client in the RFC 7523 jwt-bearer flow). + WalletOwnerServiceProvider = WalletOwnerType("service_provider") ) diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 1b982e4f43..f204463f13 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -446,7 +446,7 @@ func matchField(field Field, credential map[string]interface{}) (bool, interface // if path is not found continue value, err := getValueAtPath(path, credential) if err != nil { - return false, nil, err + return false, nil, fmt.Errorf("path %q: %w", path, err) } if value == nil { continue @@ -459,7 +459,7 @@ func matchField(field Field, credential map[string]interface{}) (bool, interface // if filter at path matches return true match, matchedValue, err := matchFilter(*field.Filter, value) if err != nil { - return false, nil, err + return false, nil, fmt.Errorf("path %q: %w", path, err) } if match { return true, matchedValue, nil diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go index 605ac264c9..1241723b5d 100644 --- a/vcr/pe/presentation_submission_test.go +++ b/vcr/pe/presentation_submission_test.go @@ -279,6 +279,42 @@ func TestPresentationSubmissionBuilder_SetCredentialSelector(t *testing.T) { }) } +func TestNewEnvelopeFromVP(t *testing.T) { + t.Run("wraps a parsed VP into an Envelope that PresentationSubmission.Resolve accepts", func(t *testing.T) { + // Caller already has a *VerifiablePresentation in memory (e.g. just built by the wallet); + // NewEnvelopeFromVP must produce an Envelope equivalent to ParseEnvelope([]byte(vp.Raw())) so that + // Resolve sees the same descriptor → credential mapping. + vpRaw := `{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiablePresentation"], + "verifiableCredential": [{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "type": ["VerifiableCredential"], + "id": "urn:vc:42", + "issuer": "did:test:issuer", + "credentialSubject": {"id": "did:test:subject"}, + "proof": {"type": "JsonWebSignature2020"} + }], + "proof": {"type": "JsonWebSignature2020"} + }` + presentation, err := vc.ParseVerifiablePresentation(vpRaw) + require.NoError(t, err) + submission := PresentationSubmission{ + DescriptorMap: []InputDescriptorMappingObject{ + {Id: "id_org_cred", Format: "ldp_vc", Path: "$.verifiableCredential[0]"}, + }, + } + + envelope, err := NewEnvelopeFromVP(*presentation) + require.NoError(t, err) + credentials, err := submission.Resolve(*envelope) + + require.NoError(t, err) + require.Contains(t, credentials, "id_org_cred") + assert.Equal(t, "urn:vc:42", credentials["id_org_cred"].ID.String()) + }) +} + func TestPresentationSubmission_Resolve(t *testing.T) { id1 := ssi.MustParseURI("1") id2 := ssi.MustParseURI("2") diff --git a/vcr/pe/util.go b/vcr/pe/util.go index 75ced72c07..2c8fd21ab1 100644 --- a/vcr/pe/util.go +++ b/vcr/pe/util.go @@ -93,6 +93,21 @@ func ParseEnvelope(envelopeBytes []byte) (*Envelope, error) { }, nil } +// NewEnvelopeFromVP wraps a single Verifiable Presentation in an Envelope without re-parsing the source +// bytes. Convenient for callers that already hold a parsed VP in memory and would otherwise round-trip +// through Raw() + ParseEnvelope just to feed PresentationSubmission.Resolve. +func NewEnvelopeFromVP(presentation vc.VerifiablePresentation) (*Envelope, error) { + asInterface, err := vpAsInterface(presentation) + if err != nil { + return nil, err + } + return &Envelope{ + Presentations: []vc.VerifiablePresentation{presentation}, + asInterface: asInterface, + raw: []byte(presentation.Raw()), + }, nil +} + // parseEnvelopeEntry parses a single Verifiable Presentation in a Presentation Exchange envelope. // It takes into account custom unmarshalling required for JWT VPs. func parseJSONArrayEnvelope(arr []interface{}) (interface{}, []vc.VerifiablePresentation, error) { @@ -129,13 +144,24 @@ func parseJSONObjectOrStringEnvelope(envelopeBytes []byte) (interface{}, *vc.Ver if err != nil { return nil, nil, fmt.Errorf("unable to parse PEX envelope as verifiable presentation: %w", err) } - // TODO: This should be part of go-did library; we need to decode a JWT VP (and maybe later VC) and get the properties - // (in this case as map) without losing original cardinality. - // Part of https://github.com/nuts-foundation/go-did/issues/85 + asInterface, err := vpAsInterface(*presentation) + if err != nil { + return nil, nil, err + } + return asInterface, presentation, nil +} + +// vpAsInterface returns the JSON-as-interface representation of vp suitable for jsonpath traversal, +// extracting the inner "vp" claim for JWT presentations so callers see the same shape regardless of format. +// TODO: This should be part of go-did library; we need to decode a JWT VP (and maybe later VC) and get the +// properties (in this case as map) without losing original cardinality. +// Part of https://github.com/nuts-foundation/go-did/issues/85 +func vpAsInterface(presentation vc.VerifiablePresentation) (interface{}, error) { + raw := []byte(presentation.Raw()) if presentation.Format() == vc.JWTPresentationProofFormat { - token, err := jwt.Parse(envelopeBytes, jwt.WithVerify(false), jwt.WithValidate(false)) + token, err := jwt.Parse(raw, jwt.WithVerify(false), jwt.WithValidate(false)) if err != nil { - return nil, nil, fmt.Errorf("unable to parse PEX envelope as JWT verifiable presentation: %w", err) + return nil, fmt.Errorf("unable to parse PEX envelope as JWT verifiable presentation: %w", err) } asMap := make(map[string]interface{}) // use the 'vp' claim as base Verifiable Presentation properties @@ -146,15 +172,15 @@ func parseJSONObjectOrStringEnvelope(envelopeBytes []byte) (interface{}, *vc.Ver if jti, ok := token.Get(jwt.JwtIDKey); ok { asMap["id"] = jti } - return asMap, presentation, nil + return asMap, nil } // For other formats, we can just parse the JSON to get the interface{} for JSON Path to work on var asMap interface{} - if err := json.Unmarshal(envelopeBytes, &asMap); err != nil { + if err := json.Unmarshal(raw, &asMap); err != nil { // Can't actually fail? - return nil, nil, err + return nil, err } - return asMap, presentation, nil + return asMap, nil } // tryParseJSONArray tries to parse the given bytes as a JSON array.