From 5bd999409435a047619456504562e3bd7ade55f6 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:54:31 +0200 Subject: [PATCH 01/11] Initialize branch for PR From c20838641db05561247d25fdfafb3c4a34d62c2c Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 1 May 2026 15:55:30 +0200 Subject: [PATCH 02/11] Initialize branch for PR From 0df279ad270c84e0c6dbbd8484386f4c1c644acc Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 19:09:39 +0200 Subject: [PATCH 03/11] Add service_provider_subject_id to ServiceAccessTokenRequest Threads the new optional OpenAPI field through the handler to the IAM client's existing serviceProviderSubjectID parameter (added in #4227). The two-VP dispatch and feature gating already live in the IAM client; the handler's job here is just to forward the value. Named service_provider_subject_id rather than client_id (which the PRD body suggested) to avoid overloading with the OAuth client_id form parameter and to match the convention adopted in #4226 (service_provider PD block) and the internal serviceProviderSubjectID parameter. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/api.go | 2 +- auth/api/iam/api_test.go | 17 +++++++++++++++++ auth/api/iam/generated.go | 22 +++++++++++++++++++--- docs/_static/auth/v2.yaml | 15 +++++++++++++++ e2e-tests/browser/client/iam/generated.go | 22 +++++++++++++++++++--- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 3f833d26b..7289dd1ca 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -781,7 +781,7 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestServiceAccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, nil) + 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 bfac8c130..8a99eccab 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1007,6 +1007,23 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { assert.NotEqual(t, token1, token2) }) + t.Run("ok - service_provider_subject_id 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 just to forward the value verbatim. + ctx := newTestClient(t) + spSubject := "acme-service-provider" + bodyWithSP := &RequestServiceAccessTokenJSONRequestBody{ + AuthorizationServer: verifierURL.String(), + Scope: "first second", + ServiceProviderSubjectId: &spSubject, + } + response := &oauth.TokenResponse{AccessToken: "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) + + _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithSP}) + + require.NoError(t, err) + }) t.Run("self-asserted credentials", func(t *testing.T) { response := &oauth.TokenResponse{ AccessToken: "token", diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 859cff5ef..c48663bc2 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -106,7 +106,7 @@ type ExtendedTokenIntrospectionResponse struct { // PresentationSubmissions Mapping of Presentation Definition IDs that were fulfilled to Presentation Submissions. PresentationSubmissions *map[string]PresentationSubmission `json:"presentation_submissions,omitempty"` - // Scope granted scopes + // Scope Granted scopes, as a space-separated list. Scope *string `json:"scope,omitempty"` Vps *[]VerifiablePresentation `json:"vps,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` @@ -135,12 +135,15 @@ type ServiceAccessTokenRequest struct { AuthorizationServer string `json:"authorization_server"` // CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple - // credentials matching a single input descriptor. Each key must match a field id declared + // credentials matching a single input descriptor. Each key must match a field ID declared // in the Presentation Definition's input descriptor constraints. The value narrows the // match to credentials where that field equals the given value. // // The selection must narrow to exactly one credential per input descriptor. // Zero matches or multiple matches will result in an error. + // + // When omitted and multiple credentials match an input descriptor, + // the first matching credential is used. CredentialSelection *map[string]string `json:"credential_selection,omitempty"` // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. @@ -158,6 +161,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.jwt_bearer_client = 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"` } @@ -197,7 +213,7 @@ type UserAccessTokenRequestTokenType string // UserDetails Claims about the authorized user. type UserDetails struct { - // Id Machine-readable identifier, uniquely identifying the user in the issuing system. + // Id Machine-readable identifier, uniquely identifying the user in the issuing system. The format is not specified; it could be a username, email address, employee number, etc. Id string `json:"id"` // Name Human-readable name of the user. diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 01cf6ba82..1dd561633 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -515,6 +515,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.jwt_bearer_client = 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/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 51c1f3177..c93cb3dda 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -107,7 +107,7 @@ type ExtendedTokenIntrospectionResponse struct { // PresentationSubmissions Mapping of Presentation Definition IDs that were fulfilled to Presentation Submissions. PresentationSubmissions *map[string]PresentationSubmission `json:"presentation_submissions,omitempty"` - // Scope granted scopes + // Scope Granted scopes, as a space-separated list. Scope *string `json:"scope,omitempty"` Vps *[]VerifiablePresentation `json:"vps,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` @@ -129,12 +129,15 @@ type ServiceAccessTokenRequest struct { AuthorizationServer string `json:"authorization_server"` // CredentialSelection Optional key-value mapping for credential selection when the wallet contains multiple - // credentials matching a single input descriptor. Each key must match a field id declared + // credentials matching a single input descriptor. Each key must match a field ID declared // in the Presentation Definition's input descriptor constraints. The value narrows the // match to credentials where that field equals the given value. // // The selection must narrow to exactly one credential per input descriptor. // Zero matches or multiple matches will result in an error. + // + // When omitted and multiple credentials match an input descriptor, + // the first matching credential is used. CredentialSelection *map[string]string `json:"credential_selection,omitempty"` // Credentials Additional credentials to present (if required by the authorizer), in addition to those in the requester's wallet. @@ -152,6 +155,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.jwt_bearer_client = 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"` } @@ -191,7 +207,7 @@ type UserAccessTokenRequestTokenType string // UserDetails Claims about the authorized user. type UserDetails struct { - // Id Machine-readable identifier, uniquely identifying the user in the issuing system. + // Id Machine-readable identifier, uniquely identifying the user in the issuing system. The format is not specified; it could be a username, email address, employee number, etc. Id string `json:"id"` // Name Human-readable name of the user. From b465d4637d265931e0ddb4f2a12da47758898b24 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 19:16:34 +0200 Subject: [PATCH 04/11] Rename subjectID to organizationSubjectID inside requestJwtBearerAccessToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @reinkrul: in the two-VP context, the parameter is specifically the organization (HCP) subject — distinguishing it from the service-provider subject in the same signature makes the call sites self-documenting. The dispatcher and the single-VP path keep their generic subjectID since they don't share a signature with the SP subject. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 15d51f273..e692f1602 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -345,7 +345,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI // 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, subjectID string, serviceProviderSubjectID string, +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) @@ -368,7 +368,7 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje Format: metadata.VPFormatsSupported, Nonce: nutsCrypto.GenerateNonce(), } - organizationVP, organizationSubmission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params) + organizationVP, organizationSubmission, err := c.buildSubmissionForSubject(ctx, organizationSubjectID, orgPD, additionalCredentials, credentialSelection, params) if err != nil { return nil, err } From 71e896e47edb6a2ae233a3a9334a73810370582e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 19:18:29 +0200 Subject: [PATCH 05/11] Refactor iam.NewClient to accept a ClientConfig struct The previous constructor took 9 positional arguments, including two booleans separated by a duration. A struct makes the call site self-documenting and means future config additions (e.g. the grant-types-enabled list tracked under #4231) become a one-line struct change instead of an N-call-site signature break. The only call site is auth/auth.go. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/auth.go | 12 +++++++- auth/client/iam/openid4vp.go | 58 ++++++++++++++++++++++-------------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/auth/auth.go b/auth/auth.go index 57f7d7260..98e33c211 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -129,7 +129,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, auth.config.Experimental.JwtBearerClient) + 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, + }) } // Configure the Auth struct by creating a validator and create an Irma server diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index e692f1602..156f80c0d 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -70,31 +70,45 @@ type OpenID4VPClient struct { 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, - experimentalJwtBearerClient bool) *OpenID4VPClient { +// ClientConfig groups the dependencies and toggles needed to construct an OpenID4VPClient. +// All fields are required unless explicitly noted. +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 OpenID4VPClient configured with the given dependencies. +func NewClient(cfg ClientConfig) *OpenID4VPClient { httpClient := HTTPClient{ - strictMode: strictMode, - httpClient: client.NewWithCache(httpClientTimeout), - keyResolver: keyResolver, + strictMode: cfg.StrictMode, + httpClient: client.NewWithCache(cfg.HTTPClientTimeout), + keyResolver: cfg.KeyResolver, } - client := &OpenID4VPClient{ + c := &OpenID4VPClient{ httpClient: httpClient, - keyResolver: keyResolver, - jwtSigner: jwtSigner, - ldDocumentLoader: ldDocumentLoader, - subjectManager: subjectManager, - strictMode: strictMode, - wallet: wallet, - policyBackend: policyBackend, - experimentalJwtBearerClient: experimentalJwtBearerClient, - } - client.pdResolver = PresentationDefinitionResolver{ - pdFetcher: client, - policyBackend: policyBackend, - } - return client + 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) { From c19105f05a40199214d71f7e93e26b857c37466b Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Mon, 4 May 2026 19:21:22 +0200 Subject: [PATCH 06/11] Use lightweight test context for the feature-disabled gate test The experimental-flag gate in RequestServiceAccessToken returns synchronously before any HTTP call, so the TLS server fixture set up by createClientServerTestContext was dead weight for this one test. createClientTestContext gives just the client and the mocks, no TLS server, no JSON metadata fixtures. The other two failure-mode tests (AS-doesn't-advertise, no service_provider PD) genuinely need the AS metadata HTTP fixture and correctly stay on createClientServerTestContext. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 4c1339568..db6f5038d 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -423,11 +423,13 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { scopes := "first second" t.Run("rejects the request when the experimental jwt-bearer feature is disabled", func(t *testing.T) { - // The default zero value of experimentalJwtBearerClient is false; we leave it alone so the gate fires. + // 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 := createClientServerTestContext(t) + ctx := createClientTestContext(t, nil) - _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp) + _, 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") From c28d8031dffb96ba4d8f8cac11e59e0b52665f1f Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 5 May 2026 11:09:37 +0200 Subject: [PATCH 07/11] Add tests for SP-side wallet failures in the two-VP flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing single-VP error tests (BuildSubmission returning pe.ErrNoCredentials, DID-method mismatch) for the second wallet trip in the jwt-bearer flow: - SP wallet returns DIDs whose methods are not in the AS's DIDMethodsSupported list — filterDIDsByMethods returns ErrPreconditionFailed. - SP wallet has matching DIDs but BuildSubmission cannot satisfy the service_provider PD — pe.ErrNoCredentials must propagate so the API layer can map it to 412 Precondition Failed. Carry-over from #4227 self-review. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp_test.go | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index db6f5038d..0b33ed45d 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -472,6 +472,70 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { // 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 default test fixture advertises method "test", + // so returning a did:web from the SP wallet exercises the filter rejection. + 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.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") From e01498dc2723d3d59405df8b6ca5433f3e6d9d05 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 5 May 2026 11:32:35 +0200 Subject: [PATCH 08/11] Validate service_provider_subject_id in the handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAPI field is *string; an explicit empty string would have routed into the two-VP flow with a meaningless subject and surfaced as a misleading 412 'did method mismatch' from a downstream ListDIDs("") call. Reject empty up front with a clear InvalidInputError, and check that a non-nil subject actually exists locally — same treatment as the path-param subjectID. Adds three sub-tests under "service_provider_subject_id": - ok - threads through to IAM client (also tightened to assert the response body, not just NoError) - empty string is rejected up front - unknown subject returns 400 Carry-over from #4228 self-review. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/api.go | 11 +++++++ auth/api/iam/api_test.go | 69 ++++++++++++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 7289dd1ca..101c9dec9 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -728,6 +728,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) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 8a99eccab..3ae48a2b2 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1007,22 +1007,63 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { assert.NotEqual(t, token1, token2) }) - t.Run("ok - service_provider_subject_id 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 just to forward the value verbatim. - ctx := newTestClient(t) - spSubject := "acme-service-provider" - bodyWithSP := &RequestServiceAccessTokenJSONRequestBody{ - AuthorizationServer: verifierURL.String(), - Scope: "first second", - ServiceProviderSubjectId: &spSubject, - } - response := &oauth.TokenResponse{AccessToken: "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) + 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) - _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithSP}) + tokenResponse, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithSP}) - require.NoError(t, err) + 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{ From dbfd43593271299818841de7bf244af0a3bef064 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 5 May 2026 11:35:15 +0200 Subject: [PATCH 09/11] Address self-review nits: docs and test pinning - Rephrase ClientConfig doc: 'all required unless noted' was inaccurate for bool/duration zero-valued scalars. Now distinguishes interface fields (required) from scalars (zero value is valid). - Add release-notes entry for the new service_provider_subject_id API field, parallel to the existing #4226 / #4227 lines. - Pin DIDMethodsSupported explicitly in the SP method-mismatch test so the assertion doesn't silently flip if the default fixture changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/client/iam/openid4vp.go | 5 ++++- auth/client/iam/openid4vp_test.go | 5 +++-- docs/pages/release_notes.rst | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 156f80c0d..8abe8e463 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -71,7 +71,10 @@ type OpenID4VPClient struct { } // ClientConfig groups the dependencies and toggles needed to construct an OpenID4VPClient. -// All fields are required unless explicitly noted. +// 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 diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 0b33ed45d..025eaebc2 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -475,8 +475,8 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { 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 default test fixture advertises method "test", - // so returning a did:web from the SP wallet exercises the filter rejection. + // 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") @@ -486,6 +486,7 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) { 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{ diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index f9003f4aa..60dcae549 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -10,6 +10,7 @@ Unreleased * #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 **************** Peanut (v6.2.1) From c9cd139a448f8497bb320b7ff21014c0d30db26d Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Tue, 5 May 2026 13:16:43 +0200 Subject: [PATCH 10/11] Document the experimental two-VP flow and cross-VP binding in policy docs Adds a new top-level section to docs/pages/deployment/policy.rst that explains, for policy authors and node operators: - when the two-VP RFC 7523 jwt-bearer flow triggers (all four conditions: experimental flag, service_provider_subject_id body field, AS metadata advertises jwt-bearer, profile has a service_provider PD) - how VP1 (organization) and VP2 (service_provider) are built and which subject's wallet/PD each uses - cross-VP binding via shared field.id, with a worked example showing a delegating_hcp constraint in both PDs that ties VP2's delegation credential to VP1's HCP issuer - the credential_selection map and how server-captured entries are additively merged with EHR-supplied ones - the four-step required configuration The section is prefaced by a warning that the entire mechanism is experimental and subject to change while the underlying OAuth profile stabilises. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/pages/deployment/policy.rst | 97 +++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index 728596346..453619370 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -130,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.jwt_bearer_client = 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.jwt_bearer_client`` 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.jwt_bearer_client: 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 From 7db8c21aec54604dbd2d0acafdc5222c9301c04c Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 8 May 2026 07:30:38 +0200 Subject: [PATCH 11/11] Update doc references to the renamed jwtbearerclient flag Follow-up to the rename in 1c932255 (#4227): the OpenAPI description for service_provider_subject_id and the new policy.rst section both still referenced the old auth.experimental.jwt_bearer_client name. Regenerated bindings. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/api/iam/generated.go | 2 +- docs/_static/auth/v2.yaml | 2 +- docs/pages/deployment/policy.rst | 6 +++--- e2e-tests/browser/client/iam/generated.go | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index c48663bc2..d1c258a9b 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -165,7 +165,7 @@ type ServiceAccessTokenRequest struct { // 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.jwt_bearer_client = true`, + // 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. // diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 1dd561633..c2d2d878e 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -522,7 +522,7 @@ components: 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.jwt_bearer_client = true`, + 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. diff --git a/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst index 453619370..a50875a0b 100644 --- a/docs/pages/deployment/policy.rst +++ b/docs/pages/deployment/policy.rst @@ -136,7 +136,7 @@ Two-VP flow and cross-VP binding (experimental) *********************************************** .. warning:: - The two-VP flow is **experimental** and gated behind ``auth.experimental.jwt_bearer_client = true`` (default ``false``). + 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 @@ -144,7 +144,7 @@ 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.jwt_bearer_client`` is ``true`` on the EHR-side node. +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. @@ -222,7 +222,7 @@ Required configuration To enable the two-VP flow on a node: -1. Set ``auth.experimental.jwt_bearer_client: true`` in the node config (off by default). +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/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index c93cb3dda..ebd70e989 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -159,7 +159,7 @@ type ServiceAccessTokenRequest struct { // 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.jwt_bearer_client = true`, + // 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. //