diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go
index 6b8125bf0..3f833d26b 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().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, nil)
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 5424ab8e1..bfac8c130 100644
--- a/auth/api/iam/api_test.go
+++ b/auth/api/iam/api_test.go
@@ -886,7 +886,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
@@ -907,7 +907,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)
@@ -946,7 +946,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)
@@ -963,7 +963,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})
@@ -972,7 +972,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)
@@ -981,7 +981,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})
@@ -997,8 +997,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)
@@ -1023,7 +1023,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/auth.go b/auth/auth.go
index e869f35f8..57f7d7260 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -129,7 +129,7 @@ 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(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout, auth.config.Experimental.JwtBearerClient)
}
// Configure the Auth struct by creating a validator and create an Irma server
diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go
index 5ccf9caaf..e91e1e1dd 100644
--- a/auth/client/iam/interface.go
+++ b/auth/client/iam/interface.go
@@ -43,11 +43,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)
// OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer.
// oauthIssuer is the URL of the issuer as specified by RFC 8414 (OAuth 2.0 Authorization Server Metadata).
diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go
index b6ad933a6..dc22ba048 100644
--- a/auth/client/iam/mock.go
+++ b/auth/client/iam/mock.go
@@ -193,19 +193,19 @@ 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)
}
// VerifiableCredentials mocks base method.
diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go
index c0b90ef73..15d51f273 100644
--- a/auth/client/iam/openid4vp.go
+++ b/auth/client/iam/openid4vp.go
@@ -51,35 +51,44 @@ 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
}
// 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 {
+ ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration,
+ experimentalJwtBearerClient bool) *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,
+ httpClient: httpClient,
+ keyResolver: keyResolver,
+ jwtSigner: jwtSigner,
+ ldDocumentLoader: ldDocumentLoader,
+ subjectManager: subjectManager,
+ strictMode: strictMode,
+ wallet: wallet,
+ policyBackend: policyBackend,
+ experimentalJwtBearerClient: experimentalJwtBearerClient,
}
client.pdResolver = PresentationDefinitionResolver{
pdFetcher: client,
@@ -242,14 +251,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 {
@@ -260,45 +291,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
}
@@ -316,22 +314,13 @@ 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
- }
- dpopHeader, dpopKid, err = c.dpop(ctx, *subjectDID, *request)
- if err != nil {
- return nil, fmt.Errorf("failed to create DPoP header: %w", err)
- }
+ dpopHeader, dpopKid, err := c.signDPoPHeader(ctx, useDPoP, *subjectDID, metadata.TokenEndpoint)
+ 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))
- token, err := iamClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader)
+ 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
@@ -340,7 +329,108 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID
AccessToken: token.AccessToken,
ExpiresIn: token.ExpiresIn,
TokenType: token.TokenType,
- Scope: &scopes,
+ Scope: &resolved.Scope,
+ }
+ if dpopKid != "" {
+ tokenResponse.DPoPKid = &dpopKid
+ }
+ return &tokenResponse, nil
+}
+
+// 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, subjectID 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),
+ }
+ }
+ 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, subjectID, 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
+ }
+ dpopHeader, dpopKid, err := c.signDPoPHeader(ctx, useDPoP, *spDID, metadata.TokenEndpoint)
+ 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())
+ token, err := c.httpClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader)
+ if err != nil {
+ return nil, err
+ }
+ tokenResponse := oauth.TokenResponse{
+ AccessToken: token.AccessToken,
+ ExpiresIn: token.ExpiresIn,
+ TokenType: token.TokenType,
+ Scope: &resolvedScope,
}
if dpopKid != "" {
tokenResponse.DPoPKid = &dpopKid
@@ -348,6 +438,72 @@ 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
+}
+
func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) {
iamClient := c.httpClient
rsp, err := iamClient.OpenIdCredentialIssuerMetadata(ctx, oauthIssuerURI)
@@ -366,6 +522,23 @@ func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialE
return rsp, 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 68203d791..4c1339568 100644
--- a/auth/client/iam/openid4vp_test.go
+++ b/auth/client/iam/openid4vp_test.go
@@ -40,6 +40,7 @@ import (
"github.com/nuts-foundation/nuts-node/audit"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"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"
@@ -236,7 +237,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")
@@ -253,7 +254,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)
@@ -265,7 +266,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)
@@ -275,7 +276,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)
@@ -312,7 +313,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)
@@ -326,7 +327,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)
@@ -340,7 +341,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)
@@ -348,7 +349,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
@@ -366,7 +367,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{}))
@@ -376,7 +377,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)
@@ -390,7 +391,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)
@@ -401,12 +402,345 @@ 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 default zero value of experimentalJwtBearerClient is false; we leave it alone so the gate fires.
+ sp := spSubjectID
+ ctx := createClientServerTestContext(t)
+
+ _, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), 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("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)
@@ -474,6 +808,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{}
}
@@ -486,10 +821,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(),
@@ -499,6 +835,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon
keyResolver: keyResolver,
wallet: wallet,
subjectManager: subjectManager,
+ policyBackend: policyBackend,
}
}
@@ -510,6 +847,7 @@ type clientTestContext struct {
keyResolver *resolver.MockKeyResolver
wallet *holder.MockWallet
subjectManager *didsubject.MockManager
+ policyBackend *policy.MockPDPBackend
}
type clientServerTestContext struct {
@@ -526,7 +864,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)
credentials func(writer http.ResponseWriter)
requestObjectJWT func(writer http.ResponseWriter)
}
@@ -575,7 +913,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"}`))
@@ -628,7 +966,7 @@ func createClientServerTestContext(t *testing.T) *clientServerTestContext {
}
case "/token":
if ctx.token != nil {
- ctx.token(writer)
+ ctx.token(writer, request)
return
}
case "/credentials":
diff --git a/auth/client/iam/pd_resolver.go b/auth/client/iam/pd_resolver.go
index edce53eb2..a5973ac29 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 000000000..36ea87c4e
--- /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 000000000..ed5321413
--- /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 5a2abf01e..e0787c6f8 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 5fe44f4c1..88514b374 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 0f30bd3c9..78cf9ea95 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 c0a6d769d..567b5459d 100644
--- a/auth/oauth/types.go
+++ b/auth/oauth/types.go
@@ -189,6 +189,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'
@@ -205,6 +209,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/docs/pages/deployment/policy.rst b/docs/pages/deployment/policy.rst
index d2abaa117..728596346 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
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst
index a281ac14d..e6c5f3d30 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 b4bcede5f..f9003f4aa 100644
--- a/docs/pages/release_notes.rst
+++ b/docs/pages/release_notes.rst
@@ -8,6 +8,8 @@ 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
****************
Peanut (v6.2.1)
diff --git a/policy/local.go b/policy/local.go
index b8f6d02a0..7ea5abd54 100644
--- a/policy/local.go
+++ b/policy/local.go
@@ -190,8 +190,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)
@@ -203,9 +203,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 {
@@ -213,6 +214,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 b25e93143..139b12702 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 000000000..5687658bc
--- /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 000000000..177d82047
--- /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 000000000..ae3c883ec
--- /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 000000000..35d00a0b2
--- /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 32d067320..e3c18ef65 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 1b982e4f4..f204463f1 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 614777ff0..787e3169c 100644
--- a/vcr/pe/presentation_submission_test.go
+++ b/vcr/pe/presentation_submission_test.go
@@ -277,6 +277,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 75ced72c0..2c8fd21ab 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.