diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 3f833d26b..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) @@ -781,7 +792,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..3ae48a2b2 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1007,6 +1007,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", diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 859cff5ef..d1c258a9b 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.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"` } @@ -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/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 15d51f273..8abe8e463 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -70,31 +70,48 @@ 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. +// 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 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) { @@ -345,7 +362,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 +385,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 } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 4c1339568..025eaebc2 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") @@ -470,6 +472,71 @@ 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 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") diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index 01cf6ba82..c2d2d878e 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.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 728596346..a50875a0b 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.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/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) diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 51c1f3177..ebd70e989 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.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"` } @@ -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.