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.