diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index c3affbcf97..249333b0a8 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -29,7 +29,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/core/to" "html/template" "net/http" "net/url" @@ -37,6 +36,8 @@ import ( "strings" "time" + "github.com/nuts-foundation/nuts-node/core/to" + "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jwt" @@ -234,6 +235,15 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ Code: oauth.UnsupportedGrantType, Description: "not implemented yet", } + case oauth.JWTBearerGrantType: + // NL Generic Functions Authentication flow + if request.Body.Assertion == nil || request.Body.Scope == nil || + request.Body.ClientId == nil || request.Body.ClientAssertion == nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "missing required parameters", + } + } case oauth.VpTokenGrantType: // Nuts RFC021 vp_token bearer flow if request.Body.PresentationSubmission == nil || request.Body.Scope == nil || request.Body.Assertion == nil || request.Body.ClientId == nil { @@ -774,7 +784,11 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS useDPoP = false } clientID := r.subjectToBaseURL(request.SubjectID) - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials) + var policyId string + if request.Body.PolicyId != nil { + policyId = *request.Body.PolicyId + } + tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, policyId, useDPoP, credentials) 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 ed973f375f..bb227c73d1 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -873,7 +873,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).Return(response, nil).Times(2) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(response, nil).Times(2) token, err := ctx.client.RequestServiceAccessToken(nil, request) // Test call to check cache is bypassed @@ -894,7 +894,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).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(response, nil) token, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -933,7 +933,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).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil) otherToken, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -950,7 +950,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).Return(&oauth.TokenResponse{}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", false, nil).Return(&oauth.TokenResponse{}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -959,7 +959,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).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) @@ -968,7 +968,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).Return(nil, pe.ErrNoCredentials) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(nil, pe.ErrNoCredentials) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}) @@ -984,8 +984,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).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil) token1, err := ctx.client.RequestServiceAccessToken(nil, request) require.NoError(t, err) @@ -1010,7 +1010,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).Return(response, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, *body.Credentials).Return(response, nil) _, err := ctx.client.RequestServiceAccessToken(nil, request) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 5dbe21544d..8428f6e6e1 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -146,6 +146,12 @@ type ServiceAccessTokenRequest struct { // - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature) Credentials *[]VerifiableCredential `json:"credentials,omitempty"` + // PolicyId (Optional) The ID of the policy to use when requesting the access token. + // If set the presentation definition is resolved from the policy with this ID. + // This allows you to specify scopes that don't resolve to a presentation definition automatically. + // If not set, the scope is used to resolve the presentation definition. + PolicyId *string `json:"policy_id,omitempty"` + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` @@ -289,6 +295,7 @@ type HandleAuthorizeResponseFormdataBody struct { // HandleTokenRequestFormdataBody defines parameters for HandleTokenRequest. type HandleTokenRequestFormdataBody struct { Assertion *string `form:"assertion,omitempty" json:"assertion,omitempty"` + ClientAssertion *string `form:"client_assertion,omitempty" json:"client_assertion,omitempty"` ClientId *string `form:"client_id,omitempty" json:"client_id,omitempty"` Code *string `form:"code,omitempty" json:"code,omitempty"` CodeVerifier *string `form:"code_verifier,omitempty" json:"code_verifier,omitempty"` diff --git a/auth/auth.go b/auth/auth.go index f135335c01..6244e478f6 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -21,7 +21,13 @@ package auth import ( "crypto/tls" "errors" + "net/url" + "path" + "slices" + "time" + "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/didjwk" "github.com/nuts-foundation/nuts-node/vdr/didkey" @@ -30,10 +36,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/didx509" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/url" - "path" - "slices" - "time" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/notary" @@ -58,6 +60,7 @@ type Auth struct { relyingParty oauth.RelyingParty contractNotary services.ContractNotary serviceResolver didman.CompoundServiceResolver + policyBackend policy.PDPBackend keyStore crypto.KeyStore vcr vcr.VCR pkiProvider pki.Provider @@ -100,12 +103,13 @@ func (auth *Auth) ContractNotary() services.ContractNotary { // NewAuthInstance accepts a Config with several Nuts Engines and returns an instance of Auth func NewAuthInstance(config Config, vdrInstance vdr.VDR, subjectManager didsubject.Manager, vcr vcr.VCR, keyStore crypto.KeyStore, - serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider) *Auth { + serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider, policyBackend policy.PDPBackend) *Auth { return &Auth{ config: config, jsonldManager: jsonldManager, vdrInstance: vdrInstance, subjectManager: subjectManager, + policyBackend: policyBackend, keyStore: keyStore, vcr: vcr, pkiProvider: pkiProvider, @@ -126,7 +130,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.strictMode, auth.httpClientTimeout) + return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout) } // Configure the Auth struct by creating a validator and create an Irma server diff --git a/auth/auth_test.go b/auth/auth_test.go index 968ea61ef8..baf0fe4840 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -19,6 +19,8 @@ package auth import ( + "testing" + "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/jsonld" @@ -28,7 +30,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "testing" ) func TestAuth_Configure(t *testing.T) { @@ -47,7 +48,7 @@ func TestAuth_Configure(t *testing.T) { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() - i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock) + i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock, nil) require.NoError(t, i.Configure(tlsServerConfig)) }) @@ -61,7 +62,7 @@ func TestAuth_Configure(t *testing.T) { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() - i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock) + i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock, nil) require.NoError(t, i.Configure(tlsServerConfig)) }) @@ -119,7 +120,7 @@ func TestAuth_IAMClient(t *testing.T) { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() - i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, jsonld.NewTestJSONLDManager(t), pkiMock) + i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, jsonld.NewTestJSONLDManager(t), pkiMock, nil) assert.NotNil(t, i.IAMClient()) }) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 5016708d9d..19fa3082c6 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -44,7 +44,7 @@ type Client interface { // 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. - RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool, + RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, policyId string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) // OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer. diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index add1a059cd..1d49452ebb 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -194,18 +194,18 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet } // RequestRFC021AccessToken mocks base method. -func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { +func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes, policyId string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials) + ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials) 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 any) *gomock.Call { +func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials 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) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials) } // VerifiableCredentials mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 0f9e370601..d3f54e62b2 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -24,16 +24,18 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/http/client" - "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "github.com/piprate/json-gold/ld" "maps" "net/http" "net/url" "slices" "time" + "github.com/nuts-foundation/nuts-node/http/client" + "github.com/nuts-foundation/nuts-node/policy" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/piprate/json-gold/ld" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" @@ -60,11 +62,12 @@ type OpenID4VPClient struct { wallet holder.Wallet ldDocumentLoader ld.DocumentLoader subjectManager didsubject.Manager + policyBackend policy.PDPBackend } // NewClient returns an implementation of Holder func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectManager didsubject.Manager, jwtSigner nutsCrypto.JWTSigner, - ldDocumentLoader ld.DocumentLoader, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { + ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { return &OpenID4VPClient{ httpClient: HTTPClient{ strictMode: strictMode, @@ -77,6 +80,7 @@ func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectMa subjectManager: subjectManager, strictMode: strictMode, wallet: wallet, + policyBackend: policyBackend, } } @@ -235,24 +239,44 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd } func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string, - useDPoP bool, additionalCredentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { + policyId string, useDPoP bool, additionalCredentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) { iamClient := c.httpClient metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL) if err != nil { return nil, err } - // get the presentation definition from the verifier - parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, c.strictMode) - if err != nil { - return nil, err + // if no policyId is provided, use the scopes as policyId + if policyId == "" { + policyId = scopes } - presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{ - "scope": scopes, - }) - presentationDefinition, err := c.PresentationDefinition(ctx, presentationDefinitionURL.String()) - if err != nil { + // LSPxNuts: get the presentation definition from local definitions, if available + var presentationDefinition *pe.PresentationDefinition + presentationDefinitionMap, err := c.policyBackend.PresentationDefinitions(ctx, policyId) + if errors.Is(err, policy.ErrNotFound) { + // not found locally, get from verifier + // get the presentation definition from the verifier + parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, c.strictMode) + if err != nil { + return nil, err + } + presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{ + "scope": policyId, + }) + presentationDefinition, err = c.PresentationDefinition(ctx, presentationDefinitionURL.String()) + if err != nil { + return nil, err + } + } else if err != nil { return nil, err + } else { + // found locally + if len(presentationDefinitionMap) != 1 { + return nil, fmt.Errorf("expected exactly one presentation definition for policy/scope '%s', found %d", policyId, len(presentationDefinitionMap)) + } + for _, pd := range presentationDefinitionMap { + presentationDefinition = &pd + } } params := holder.BuildParams{ @@ -309,10 +333,16 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID presentationSubmission, _ := json.Marshal(submission) data := url.Values{} data.Set(oauth.ClientIDParam, clientID) - data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType) data.Set(oauth.AssertionParam, assertion) - data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) data.Set(oauth.ScopeParam, scopes) + if slices.Contains(metadata.GrantTypesSupported, oauth.JWTBearerGrantType) { + // use JWT bearer grant type (e.g. authenticating at LSP GtK) + data.Set(oauth.GrantTypeParam, oauth.JWTBearerGrantType) + } else { + // use VP token grant type (as per Nuts RFC021) as default and fallback + data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType) + data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) + } // create DPoP header var dpopHeader string diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index e5c4ef6840..27bed126f3 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -24,16 +24,18 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/http/client" - test2 "github.com/nuts-foundation/nuts-node/test" - "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vdr/didsubject" "net/http" "net/http/httptest" "net/url" "testing" "time" + "github.com/nuts-foundation/nuts-node/http/client" + "github.com/nuts-foundation/nuts-node/policy" + test2 "github.com/nuts-foundation/nuts-node/test" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -252,20 +254,36 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(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()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) + + assert.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + }) + t.Run("ok with policy ID that differs from scope", func(t *testing.T) { + ctx := createClientServerTestContext(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()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), "some-policy").Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "some-policy", false, nil) assert.NoError(t, err) require.NotNil(t, response) assert.Equal(t, "token", response.AccessToken) assert.Equal(t, "bearer", response.TokenType) + assert.Equal(t, "first second", *response.Scope) }) t.Run("no DID fulfills the Presentation Definition", func(t *testing.T) { ctx := createClientServerTestContext(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()).Return(nil, nil, pe.ErrNoCredentials) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.ErrorIs(t, err, pe.ErrNoCredentials) assert.Nil(t, response) @@ -274,8 +292,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.authzServerMetadata.DIDMethodsSupported = []string{"other"} ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrPreconditionFailed) @@ -285,6 +304,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { t.Run("with additional credentials", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) credentials := []vc.VerifiableCredential{ { Context: []ssi.URI{ @@ -312,7 +332,22 @@ 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) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, credentials) + + assert.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + }) + t.Run("grant_type urn:ietf:params:oauth:grant-type:jwt-bearer", func(t *testing.T) { + ctx := createClientServerTestContext(t) + // Set the authorization server to support JWT Bearer grant type + ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JWTBearerGrantType} + 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()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.NoError(t, err) require.NotNil(t, response) @@ -325,14 +360,27 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), primaryKID).Return("dpop", nil) 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()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", true, nil) assert.NoError(t, err) require.NotNil(t, response) assert.Equal(t, "token", response.AccessToken) assert.Equal(t, "bearer", response.TokenType) }) + t.Run("with Presentation Definition from local policy backend", func(t *testing.T) { + ctx := createClientServerTestContext(t) + pd := pe.PresentationDefinition{Name: "pd-id"} + ctx.clientTestContext.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(pe.WalletOwnerMapping{ + pe.WalletOwnerOrganization: pd, + }, nil) + 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(), pd, gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) + assert.NoError(t, err) + require.NotNil(t, response) + }) t.Run("error - access denied", func(t *testing.T) { oauthError := oauth.OAuth2Error{ Code: "invalid_scope", @@ -347,8 +395,9 @@ 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()).Return(createdVP, &pe.PresentationSubmission{}, nil) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) oauthError, ok := err.(oauth.OAuth2Error) @@ -357,6 +406,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) t.Run("error - failed to get presentation definition", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) ctx.presentationDefinition = func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusBadRequest) @@ -365,7 +415,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { return } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.True(t, errors.As(err, &oauth.OAuth2Error{})) @@ -375,7 +425,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) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrInvalidClientCall) @@ -383,13 +433,14 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) t.Run("error - faulty presentation definition", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) ctx.presentationDefinition = func(writer http.ResponseWriter) { writer.Header().Add("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) _, _ = writer.Write([]byte("{")) } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) require.Error(t, err) assert.ErrorIs(t, err, ErrBadGateway) @@ -397,10 +448,11 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) t.Run("error - failed to build vp", func(t *testing.T) { ctx := createClientServerTestContext(t) + ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound) 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()).Return(nil, nil, assert.AnError) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil) assert.Error(t, err) }) @@ -477,6 +529,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon tlsConfig = &tls.Config{} } tlsConfig.InsecureSkipVerify = true + policyBackend := policy.NewMockPDPBackend(ctrl) return &clientTestContext{ audit: audit.TestContext(), @@ -488,13 +541,15 @@ 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, }, jwtSigner: jwtSigner, keyResolver: keyResolver, wallet: wallet, subjectManager: subjectManager, + policyBackend: policyBackend, } } @@ -506,6 +561,7 @@ type clientTestContext struct { keyResolver *resolver.MockKeyResolver wallet *holder.MockWallet subjectManager *didsubject.MockManager + policyBackend *policy.MockPDPBackend } type clientServerTestContext struct { diff --git a/auth/oauth/types.go b/auth/oauth/types.go index c0a6d769d2..f7f09c4703 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -21,9 +21,10 @@ package oauth import ( "encoding/json" + "net/url" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/nuts-node/core" - "net/url" ) // this file contains constants, variables and helper functions for OAuth related code @@ -205,6 +206,8 @@ 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 jwt-bearer grant type. (RFC7523) + JWTBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" ) // response types diff --git a/auth/test.go b/auth/test.go index 4142046cdf..448668ad5c 100644 --- a/auth/test.go +++ b/auth/test.go @@ -19,9 +19,11 @@ package auth import ( + "testing" + + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/didsubject" - "testing" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/pki" @@ -44,5 +46,5 @@ func testInstance(t *testing.T, cfg Config) *Auth { vdrInstance := vdr.NewMockVDR(ctrl) vdrInstance.EXPECT().Resolver().AnyTimes() subjectManager := didsubject.NewMockManager(ctrl) - return NewAuthInstance(cfg, vdrInstance, subjectManager, vcrInstance, cryptoInstance, nil, nil, pkiMock) + return NewAuthInstance(cfg, vdrInstance, subjectManager, vcrInstance, cryptoInstance, nil, nil, pkiMock, policy.NewMockPDPBackend(ctrl)) } diff --git a/cmd/root.go b/cmd/root.go index a14feab268..e3dc33dca8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -199,11 +199,11 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { credentialInstance := vcr.NewVCRInstance(cryptoInstance, vdrInstance, networkInstance, jsonld, eventManager, storageInstance, pkiInstance) didmanInstance := didman.NewDidmanInstance(vdrInstance, credentialInstance, jsonld) discoveryInstance := discovery.New(storageInstance, credentialInstance, vdrInstance, vdrInstance) - authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance) + policyInstance := policy.New() + authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance, policyInstance) statusEngine := status.NewStatusEngine(system) metricsEngine := core.NewMetricsEngine() goldenHammer := golden_hammer.New(vdrInstance, didmanInstance) - policyInstance := policy.New() // Register HTTP routes didKeyResolver := resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()} diff --git a/docs/_static/auth/iam.partial.yaml b/docs/_static/auth/iam.partial.yaml index e8520fab41..282f959e23 100644 --- a/docs/_static/auth/iam.partial.yaml +++ b/docs/_static/auth/iam.partial.yaml @@ -32,6 +32,8 @@ paths: type: string client_id: type: string + client_assertion: + type: string assertion: type: string presentation_submission: diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index c032a1ff64..7e2c653248 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -410,6 +410,13 @@ components: used to locate the OAuth2 Authorization Server metadata. type: string example: https://example.com/oauth2 + policy_id: + type: string + description: | + (Optional) The ID of the policy to use when requesting the access token. + If set the presentation definition is resolved from the policy with this ID. + This allows you to specify scopes that don't resolve to a presentation definition automatically. + If not set, the scope is used to resolve the presentation definition. scope: type: string description: The scope that will be the service for which this access token can be used. diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 9ed0f8cbac..259b8a8b99 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -140,6 +140,12 @@ type ServiceAccessTokenRequest struct { // - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature) Credentials *[]VerifiableCredential `json:"credentials,omitempty"` + // PolicyId (Optional) The ID of the policy to use when requesting the access token. + // If set the presentation definition is resolved from the policy with this ID. + // This allows you to specify scopes that don't resolve to a presentation definition automatically. + // If not set, the scope is used to resolve the presentation definition. + PolicyId *string `json:"policy_id,omitempty"` + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"`