From 5b3991b6770cf5235217fbac33cea48fab8bff55 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Mon, 8 Jan 2024 14:40:01 +0100 Subject: [PATCH] Add policy backend router (#2647) * generated client code for policy api, implemented presentation_definition call * added authorized client API and tests --- README.rst | 5 +- auth/api/auth/v1/api_test.go | 5 - auth/api/iam/api.go | 26 ++- auth/api/iam/api_test.go | 32 ++- auth/api/iam/generated.go | 213 +++++++++--------- auth/api/iam/openid4vp.go | 45 ++-- auth/api/iam/openid4vp_test.go | 52 ++--- auth/api/iam/s2s_vptoken.go | 15 +- auth/api/iam/s2s_vptoken_test.go | 91 ++++++-- auth/api/iam/session.go | 16 +- auth/auth.go | 57 ++--- auth/cmd/cmd.go | 4 - auth/cmd/cmd_test.go | 1 - auth/config.go | 13 +- auth/interface.go | 3 - auth/mock.go | 15 -- cmd/root.go | 6 +- cmd/root_test.go | 2 +- codegen/configs/policy_client_v1.yaml | 1 + docs/_static/auth/iam.yaml | 7 +- docs/pages/deployment/cli-reference.rst | 2 + docs/pages/deployment/server_options.rst | 7 +- .../oauth-flow/rfc021/docker-compose.yml | 2 +- e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml | 3 +- makefile | 1 + policy/api/v1/client/client.go | 2 +- policy/api/v1/client/generated.go | 28 --- policy/api/v1/client/types.go | 32 ++- policy/cmd.go | 31 +++ vcr/pe/test.go => policy/config.go | 19 +- policy/interface.go | 43 ++++ vcr/pe/store.go => policy/local.go | 75 ++++-- vcr/pe/store_test.go => policy/local_test.go | 31 +-- policy/mock.go | 72 ++++++ policy/policy.go | 93 ++++++++ policy/policy_test.go | 127 +++++++++++ policy/remote.go | 39 ++++ .../test/definition_mapping.json | 0 .../invalid}/invalid_definition_mapping.json | 0 vcr/pe/presentation_definition.go | 15 ++ vcr/pe/presentation_definition_test.go | 12 + 41 files changed, 860 insertions(+), 383 deletions(-) create mode 100644 policy/cmd.go rename vcr/pe/test.go => policy/config.go (64%) create mode 100644 policy/interface.go rename vcr/pe/store.go => policy/local.go (53%) rename vcr/pe/store_test.go => policy/local_test.go (66%) create mode 100644 policy/mock.go create mode 100644 policy/policy.go create mode 100644 policy/policy_test.go create mode 100644 policy/remote.go rename {vcr/pe => policy}/test/definition_mapping.json (100%) rename {vcr/pe/test => policy/test/invalid}/invalid_definition_mapping.json (100%) diff --git a/README.rst b/README.rst index b8ab5f4975..55404b0c7d 100644 --- a/README.rst +++ b/README.rst @@ -227,7 +227,7 @@ The following options can be configured on the server: http.default.auth.type Whether to enable authentication for the default interface, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode. http.default.cors.origin [] When set, enables CORS from the specified origins on the default HTTP interface. **JSONLD** - jsonld.contexts.localmapping [https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. + jsonld.contexts.localmapping [https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. jsonld.contexts.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. **Network** network.bootstrapnodes [] List of bootstrap nodes (':') which the node initially connect to. @@ -260,6 +260,9 @@ The following options can be configured on the server: vcr.openid4vci.definitionsdir Directory with the additional credential definitions the node could issue (experimental, may change without notice). vcr.openid4vci.enabled true Enable issuing and receiving credentials over OpenID4VCI. vcr.openid4vci.timeout 30s Time-out for OpenID4VCI HTTP client operations. + **policy** + policy.address The address of a remote policy server. Mutual exclusive with policy.directory. + policy.directory Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. Mutual exclusive with policy.address. ==================================== =============================================================================================================================================================================================================================================================================================================== ================================================================================================================================================================================================================================================================================================================================ This table is automatically generated using the configuration flags in the core and engines. When they're changed diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index 7d4491fc13..a56cf91a45 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -35,7 +35,6 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -93,10 +92,6 @@ func (m *mockAuthClient) PublicURL() *url.URL { return nil } -func (m *mockAuthClient) PresentationDefinitions() *pe.DefinitionResolver { - return &pe.DefinitionResolver{} -} - func createContext(t *testing.T) *TestContext { t.Helper() ctrl := gomock.NewController(t) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 707b7c579c..910fee1470 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -31,6 +31,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vdr" @@ -62,11 +63,12 @@ type Wrapper struct { vcr vcr.VCR vdr vdr.VDR auth auth.AuthenticationServices + policyBackend policy.PDPBackend templates *template.Template storageEngine storage.Engine } -func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, storageEngine storage.Engine) *Wrapper { +func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, storageEngine storage.Engine, policyBackend policy.PDPBackend) *Wrapper { templates := template.New("oauth2 templates") _, err := templates.ParseFS(assets, "assets/*.html") if err != nil { @@ -75,6 +77,7 @@ func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInsta return &Wrapper{ storageEngine: storageEngine, auth: authInstance, + policyBackend: policyBackend, vcr: vcrInstance, vdr: vdrInstance, templates: templates, @@ -156,7 +159,7 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ Description: "missing required parameters", } } - return r.handleS2SAccessTokenRequest(*ownDID, *request.Body.Scope, *request.Body.PresentationSubmission, *request.Body.Assertion) + return r.handleS2SAccessTokenRequest(ctx, *ownDID, *request.Body.Scope, *request.Body.PresentationSubmission, *request.Body.Assertion) default: return nil, oauth.OAuth2Error{ Code: oauth.UnsupportedGrantType, @@ -306,7 +309,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho case responseTypeVPIDToken: // Options: // - OpenID4VP+SIOP flow, vp_token is sent in Authorization Response - return r.handlePresentationRequest(params, session) + return r.handlePresentationRequest(ctx, params, session) default: // TODO: This should be a redirect? redirectURI, _ := url.Parse(session.RedirectURI) @@ -350,18 +353,21 @@ func (r Wrapper) OAuthClientMetadata(ctx context.Context, request OAuthClientMet return OAuthClientMetadata200JSONResponse(clientMetadata(*r.identityURL(request.Id))), nil } -func (r Wrapper) PresentationDefinition(_ context.Context, request PresentationDefinitionRequestObject) (PresentationDefinitionResponseObject, error) { +func (r Wrapper) PresentationDefinition(ctx context.Context, request PresentationDefinitionRequestObject) (PresentationDefinitionResponseObject, error) { if len(request.Params.Scope) == 0 { return PresentationDefinition200JSONResponse(PresentationDefinition{}), nil } - // todo: only const scopes supported, scopes with variable arguments not supported yet - // todo: we only take the first scope as main scope, when backends are introduced we need to use all scopes and send them as one to the backend. - scopes := strings.Split(request.Params.Scope, " ") - presentationDefinition := r.auth.PresentationDefinitions().ByScope(scopes[0]) - if presentationDefinition == nil { + authorizer, err := r.idToOwnedDID(ctx, request.Id) + if err != nil { + return nil, err + } + + presentationDefinition, err := r.policyBackend.PresentationDefinition(ctx, *authorizer, request.Params.Scope) + if err != nil { return nil, oauth.OAuth2Error{ - Code: oauth.InvalidScope, + Code: oauth.InvalidScope, + Description: err.Error(), } } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 4dfa7585cb..7bd85af959 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -38,6 +38,7 @@ import ( oauthServices "github.com/nuts-foundation/nuts-node/auth/services/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -171,13 +172,14 @@ func TestWrapper_GetOAuthClientMetadata(t *testing.T) { func TestWrapper_PresentationDefinition(t *testing.T) { webDID := did.MustParseDID("did:web:example.com:iam:123") ctx := audit.TestContext() - definitionResolver := pe.DefinitionResolver{} - _ = definitionResolver.LoadFromFile("test/presentation_definition_mapping.json") + presentationDefinition := pe.PresentationDefinition{Id: "test"} t.Run("ok", func(t *testing.T) { test := newTestClient(t) + test.policy.EXPECT().PresentationDefinition(gomock.Any(), webDID, "eOverdracht-overdrachtsbericht").Return(&presentationDefinition, nil) + test.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) - response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.ID, Params: PresentationDefinitionParams{Scope: "eOverdracht-overdrachtsbericht"}}) + response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Id: "123", Params: PresentationDefinitionParams{Scope: "eOverdracht-overdrachtsbericht"}}) require.NoError(t, err) require.NotNil(t, response) @@ -188,7 +190,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("ok - missing scope", func(t *testing.T) { test := newTestClient(t) - response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.ID, Params: PresentationDefinitionParams{}}) + response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Id: "123", Params: PresentationDefinitionParams{}}) require.NoError(t, err) require.NotNil(t, response) @@ -198,12 +200,25 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("error - unknown scope", func(t *testing.T) { test := newTestClient(t) + test.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) + test.policy.EXPECT().PresentationDefinition(gomock.Any(), webDID, "unknown").Return(nil, policy.ErrNotFound) - response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.ID, Params: PresentationDefinitionParams{Scope: "unknown"}}) + response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Id: "123", Params: PresentationDefinitionParams{Scope: "unknown"}}) require.Error(t, err) assert.Nil(t, response) - assert.Equal(t, string(oauth.InvalidScope), err.Error()) + assert.Equal(t, "invalid_scope - not found", err.Error()) + }) + + t.Run("error - unknown ID", func(t *testing.T) { + test := newTestClient(t) + test.vdr.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(false, nil) + + response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Id: "notdid", Params: PresentationDefinitionParams{Scope: "eOverdracht-overdrachtsbericht"}}) + + require.Error(t, err) + assert.Nil(t, response) + assert.Equal(t, "invalid_request - issuer DID not owned by the server", err.Error()) }) } @@ -405,6 +420,7 @@ type testCtx struct { client *Wrapper authnServices *auth.MockAuthenticationServices vdr *vdr.MockVDR + policy *policy.MockBackend resolver *resolver.MockDIDResolver relyingParty *oauthServices.MockRelyingParty vcVerifier *verifier.MockVerifier @@ -419,7 +435,7 @@ func newTestClient(t testing.TB) *testCtx { storageEngine := storage.NewTestStorageEngine(t) authnServices := auth.NewMockAuthenticationServices(ctrl) authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() - authnServices.EXPECT().PresentationDefinitions().Return(pe.TestDefinitionResolver(t)).AnyTimes() + policyInstance := policy.NewMockBackend(ctrl) mockResolver := resolver.NewMockDIDResolver(ctrl) relyingPary := oauthServices.NewMockRelyingParty(ctrl) vcVerifier := verifier.NewMockVerifier(ctrl) @@ -436,6 +452,7 @@ func newTestClient(t testing.TB) *testCtx { return &testCtx{ ctrl: ctrl, authnServices: authnServices, + policy: policyInstance, relyingParty: relyingPary, vcVerifier: vcVerifier, resolver: mockResolver, @@ -447,6 +464,7 @@ func newTestClient(t testing.TB) *testCtx { vdr: mockVDR, vcr: mockVCR, storageEngine: storageEngine, + policyBackend: policyInstance, }, } } diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 8ed32173de..eb46a661b8 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -95,16 +95,16 @@ type TokenIntrospectionResponse struct { // TokenIntrospectionResponseAssuranceLevel Assurance level of the identity of the End-User. type TokenIntrospectionResponseAssuranceLevel string -// PresentationDefinitionParams defines parameters for PresentationDefinition. -type PresentationDefinitionParams struct { - Scope string `form:"scope" json:"scope"` -} - // HandleAuthorizeRequestParams defines parameters for HandleAuthorizeRequest. type HandleAuthorizeRequestParams struct { Params *map[string]string `form:"params,omitempty" json:"params,omitempty"` } +// PresentationDefinitionParams defines parameters for PresentationDefinition. +type PresentationDefinitionParams struct { + Scope string `form:"scope" json:"scope"` +} + // HandleTokenRequestFormdataBody defines parameters for HandleTokenRequest. type HandleTokenRequestFormdataBody struct { Assertion *string `form:"assertion,omitempty" json:"assertion,omitempty"` @@ -123,7 +123,6 @@ type RequestAccessTokenJSONBody struct { Scope string `json:"scope"` // UserID The ID of the user for which this access token is requested. - // It's handled as opaque ID and is scoped to the requester DID. UserID *string `json:"userID,omitempty"` Verifier string `json:"verifier"` } @@ -142,9 +141,6 @@ type ServerInterface interface { // Get the OAuth2 Authorization Server metadata // (GET /.well-known/oauth-authorization-server/iam/{id}) OAuthAuthorizationServerMetadata(ctx echo.Context, id string) error - // Used by relying parties to obtain a presentation definition for desired scopes as specified by Nuts RFC021. - // (GET /iam/{did}/presentation_definition) - PresentationDefinition(ctx echo.Context, did string, params PresentationDefinitionParams) error // Used by resource owners to initiate the authorization code flow. // (GET /iam/{id}/authorize) HandleAuthorizeRequest(ctx echo.Context, id string, params HandleAuthorizeRequestParams) error @@ -154,13 +150,16 @@ type ServerInterface interface { // Get the OAuth2 Client metadata // (GET /iam/{id}/oauth-client) OAuthClientMetadata(ctx echo.Context, id string) error + // Used by relying parties to obtain a presentation definition for desired scopes as specified by Nuts RFC021. + // (GET /iam/{id}/presentation_definition) + PresentationDefinition(ctx echo.Context, id string, params PresentationDefinitionParams) error // Used by to request access- or refresh tokens. // (POST /iam/{id}/token) HandleTokenRequest(ctx echo.Context, id string) error // Introspection endpoint to retrieve information from an Access Token as described by RFC7662 // (POST /internal/auth/v2/accesstoken/introspect) IntrospectAccessToken(ctx echo.Context) error - // Requests an access token using the vp_token-bearer grant. + // Start the authorization flow to get an access token from a remote authorization server. // (POST /internal/auth/v2/{did}/request-access-token) RequestAccessToken(ctx echo.Context, did string) error } @@ -188,33 +187,6 @@ func (w *ServerInterfaceWrapper) OAuthAuthorizationServerMetadata(ctx echo.Conte return err } -// PresentationDefinition converts echo context to params. -func (w *ServerInterfaceWrapper) PresentationDefinition(ctx echo.Context) error { - var err error - // ------------- Path parameter "did" ------------- - var did string - - err = runtime.BindStyledParameterWithLocation("simple", false, "did", runtime.ParamLocationPath, ctx.Param("did"), &did) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter did: %s", err)) - } - - ctx.Set(JwtBearerAuthScopes, []string{}) - - // Parameter object where we will unmarshal all parameters from the context - var params PresentationDefinitionParams - // ------------- Required query parameter "scope" ------------- - - err = runtime.BindQueryParameter("form", true, true, "scope", ctx.QueryParams(), ¶ms.Scope) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter scope: %s", err)) - } - - // Invoke the callback with all the unmarshaled arguments - err = w.Handler.PresentationDefinition(ctx, did, params) - return err -} - // HandleAuthorizeRequest converts echo context to params. func (w *ServerInterfaceWrapper) HandleAuthorizeRequest(ctx echo.Context) error { var err error @@ -278,6 +250,33 @@ func (w *ServerInterfaceWrapper) OAuthClientMetadata(ctx echo.Context) error { return err } +// PresentationDefinition converts echo context to params. +func (w *ServerInterfaceWrapper) PresentationDefinition(ctx echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithLocation("simple", false, "id", runtime.ParamLocationPath, ctx.Param("id"), &id) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params PresentationDefinitionParams + // ------------- Required query parameter "scope" ------------- + + err = runtime.BindQueryParameter("form", true, true, "scope", ctx.QueryParams(), ¶ms.Scope) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter scope: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.PresentationDefinition(ctx, id, params) + return err +} + // HandleTokenRequest converts echo context to params. func (w *ServerInterfaceWrapper) HandleTokenRequest(ctx echo.Context) error { var err error @@ -354,10 +353,10 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL } router.GET(baseURL+"/.well-known/oauth-authorization-server/iam/:id", wrapper.OAuthAuthorizationServerMetadata) - router.GET(baseURL+"/iam/:did/presentation_definition", wrapper.PresentationDefinition) router.GET(baseURL+"/iam/:id/authorize", wrapper.HandleAuthorizeRequest) router.GET(baseURL+"/iam/:id/did.json", wrapper.GetWebDID) router.GET(baseURL+"/iam/:id/oauth-client", wrapper.OAuthClientMetadata) + router.GET(baseURL+"/iam/:id/presentation_definition", wrapper.PresentationDefinition) router.POST(baseURL+"/iam/:id/token", wrapper.HandleTokenRequest) router.POST(baseURL+"/internal/auth/v2/accesstoken/introspect", wrapper.IntrospectAccessToken) router.POST(baseURL+"/internal/auth/v2/:did/request-access-token", wrapper.RequestAccessToken) @@ -402,45 +401,6 @@ func (response OAuthAuthorizationServerMetadatadefaultApplicationProblemPlusJSON return json.NewEncoder(w).Encode(response.Body) } -type PresentationDefinitionRequestObject struct { - Did string `json:"did"` - Params PresentationDefinitionParams -} - -type PresentationDefinitionResponseObject interface { - VisitPresentationDefinitionResponse(w http.ResponseWriter) error -} - -type PresentationDefinition200JSONResponse PresentationDefinition - -func (response PresentationDefinition200JSONResponse) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - - return json.NewEncoder(w).Encode(response) -} - -type PresentationDefinitiondefaultApplicationProblemPlusJSONResponse struct { - Body struct { - // Detail A human-readable explanation specific to this occurrence of the problem. - Detail string `json:"detail"` - - // Status HTTP statuscode - Status float32 `json:"status"` - - // Title A short, human-readable summary of the problem type. - Title string `json:"title"` - } - StatusCode int -} - -func (response PresentationDefinitiondefaultApplicationProblemPlusJSONResponse) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(response.StatusCode) - - return json.NewEncoder(w).Encode(response.Body) -} - type HandleAuthorizeRequestRequestObject struct { Id string `json:"id"` Params HandleAuthorizeRequestParams @@ -546,6 +506,45 @@ func (response OAuthClientMetadatadefaultApplicationProblemPlusJSONResponse) Vis return json.NewEncoder(w).Encode(response.Body) } +type PresentationDefinitionRequestObject struct { + Id string `json:"id"` + Params PresentationDefinitionParams +} + +type PresentationDefinitionResponseObject interface { + VisitPresentationDefinitionResponse(w http.ResponseWriter) error +} + +type PresentationDefinition200JSONResponse PresentationDefinition + +func (response PresentationDefinition200JSONResponse) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PresentationDefinitiondefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int +} + +func (response PresentationDefinitiondefaultApplicationProblemPlusJSONResponse) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type HandleTokenRequestRequestObject struct { Id string `json:"id"` Body *HandleTokenRequestFormdataRequestBody @@ -668,9 +667,6 @@ type StrictServerInterface interface { // Get the OAuth2 Authorization Server metadata // (GET /.well-known/oauth-authorization-server/iam/{id}) OAuthAuthorizationServerMetadata(ctx context.Context, request OAuthAuthorizationServerMetadataRequestObject) (OAuthAuthorizationServerMetadataResponseObject, error) - // Used by relying parties to obtain a presentation definition for desired scopes as specified by Nuts RFC021. - // (GET /iam/{did}/presentation_definition) - PresentationDefinition(ctx context.Context, request PresentationDefinitionRequestObject) (PresentationDefinitionResponseObject, error) // Used by resource owners to initiate the authorization code flow. // (GET /iam/{id}/authorize) HandleAuthorizeRequest(ctx context.Context, request HandleAuthorizeRequestRequestObject) (HandleAuthorizeRequestResponseObject, error) @@ -680,13 +676,16 @@ type StrictServerInterface interface { // Get the OAuth2 Client metadata // (GET /iam/{id}/oauth-client) OAuthClientMetadata(ctx context.Context, request OAuthClientMetadataRequestObject) (OAuthClientMetadataResponseObject, error) + // Used by relying parties to obtain a presentation definition for desired scopes as specified by Nuts RFC021. + // (GET /iam/{id}/presentation_definition) + PresentationDefinition(ctx context.Context, request PresentationDefinitionRequestObject) (PresentationDefinitionResponseObject, error) // Used by to request access- or refresh tokens. // (POST /iam/{id}/token) HandleTokenRequest(ctx context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) // Introspection endpoint to retrieve information from an Access Token as described by RFC7662 // (POST /internal/auth/v2/accesstoken/introspect) IntrospectAccessToken(ctx context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) - // Requests an access token using the vp_token-bearer grant. + // Start the authorization flow to get an access token from a remote authorization server. // (POST /internal/auth/v2/{did}/request-access-token) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) } @@ -728,32 +727,6 @@ func (sh *strictHandler) OAuthAuthorizationServerMetadata(ctx echo.Context, id s return nil } -// PresentationDefinition operation middleware -func (sh *strictHandler) PresentationDefinition(ctx echo.Context, did string, params PresentationDefinitionParams) error { - var request PresentationDefinitionRequestObject - - request.Did = did - request.Params = params - - handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.PresentationDefinition(ctx.Request().Context(), request.(PresentationDefinitionRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "PresentationDefinition") - } - - response, err := handler(ctx, request) - - if err != nil { - return err - } else if validResponse, ok := response.(PresentationDefinitionResponseObject); ok { - return validResponse.VisitPresentationDefinitionResponse(ctx.Response()) - } else if response != nil { - return fmt.Errorf("unexpected response type: %T", response) - } - return nil -} - // HandleAuthorizeRequest operation middleware func (sh *strictHandler) HandleAuthorizeRequest(ctx echo.Context, id string, params HandleAuthorizeRequestParams) error { var request HandleAuthorizeRequestRequestObject @@ -830,6 +803,32 @@ func (sh *strictHandler) OAuthClientMetadata(ctx echo.Context, id string) error return nil } +// PresentationDefinition operation middleware +func (sh *strictHandler) PresentationDefinition(ctx echo.Context, id string, params PresentationDefinitionParams) error { + var request PresentationDefinitionRequestObject + + request.Id = id + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.PresentationDefinition(ctx.Request().Context(), request.(PresentationDefinitionRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PresentationDefinition") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(PresentationDefinitionResponseObject); ok { + return validResponse.VisitPresentationDefinitionResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // HandleTokenRequest operation middleware func (sh *strictHandler) HandleTokenRequest(ctx echo.Context, id string) error { var request HandleTokenRequestRequestObject diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 2c92bb7bc6..cef85def7f 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -39,6 +39,7 @@ import ( "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didweb" ) @@ -181,11 +182,12 @@ func (r Wrapper) sendPresentationRequest(ctx context.Context, response http.Resp // handlePresentationRequest handles an Authorization Request as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html. // It is handled by a wallet, called by a verifier who wants the wallet to present one or more verifiable credentials. -func (r *Wrapper) handlePresentationRequest(params map[string]string, session *OAuthSession) (HandleAuthorizeRequestResponseObject, error) { - ctx := context.TODO() - // Presentation definition is always derived from the scope. - // Later on, we might support presentation_definition and/or presentation_definition_uri parameters instead of scope as well. - if err := assertParamNotPresent(params, presentationDefParam, presentationDefUriParam); err != nil { +func (r *Wrapper) handlePresentationRequest(ctx context.Context, params map[string]string, session *OAuthSession) (HandleAuthorizeRequestResponseObject, error) { + // Todo: for compatibility, we probably need to support presentation_definition and/or presentation_definition_uri. + if err := assertParamNotPresent(params, presentationDefUriParam); err != nil { + return nil, err + } + if err := assertParamPresent(params, presentationDefParam); err != nil { return nil, err } if err := assertParamPresent(params, scopeParam); err != nil { @@ -211,16 +213,15 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *O } } - // TODO: This is the easiest for now, but is this the way? - // For compatibility, we probably need to support presentation_definition and/or presentation_definition_uri. - presentationDefinition := r.auth.PresentationDefinitions().ByScope(params[scopeParam]) - if presentationDefinition == nil { + presentationDefinition, err := pe.ParsePresentationDefinition([]byte(params[presentationDefParam])) + if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.InvalidRequest, Description: fmt.Sprintf("unsupported scope for presentation exchange: %s", params[scopeParam]), RedirectURI: session.redirectURI(), } } + session.PresentationDefinition = *presentationDefinition // Render HTML templateParams := struct { @@ -234,8 +235,6 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *O RequiresUserIdentity: strings.Contains(session.ResponseType, "id_token"), } - // TODO: https://github.com/nuts-foundation/nuts-node/issues/2357 - // TODO: Retrieve presentation definition credentials, err := r.vcr.Wallet().List(ctx, session.OwnDID) if err != nil { return nil, err @@ -304,27 +303,11 @@ func (r Wrapper) handlePresentationRequestAccept(c echo.Context) error { return fmt.Errorf("invalid session: %w", err) } - // TODO: Change to loading from wallet - credentialIDs, ok := session.ServerState["openid4vp_credentials"].([]string) - if !ok { - return errors.New("invalid session (missing credentials in session)") - } - var credentials []vc.VerifiableCredential - for _, id := range credentialIDs { - credentialID, _ := ssi.ParseURI(id) - if credentialID == nil { - continue // should be impossible - } - cred, err := r.vcr.Resolve(*credentialID, nil) - if err != nil { - return err - } - credentials = append(credentials, *cred) - } - presentationDefinition := r.auth.PresentationDefinitions().ByScope(session.Scope) - if presentationDefinition == nil { - return fmt.Errorf("unsupported scope for presentation exchange: %s", session.Scope) + credentials, err := r.vcr.Wallet().List(c.Request().Context(), session.OwnDID) + if err != nil { + return err } + presentationDefinition := session.PresentationDefinition // TODO: Options (including format) resultParams := map[string]string{} submissionBuilder := presentationDefinition.PresentationSubmissionBuilder() diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 248e3072fa..db20b8db81 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -26,11 +26,11 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" - "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -101,7 +101,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { } func TestWrapper_sendPresentationRequest(t *testing.T) { - instance := New(nil, nil, nil, nil) + instance := New(nil, nil, nil, nil, nil) redirectURI, _ := url.Parse("https://example.com/redirect") verifierID, _ := url.Parse("https://example.com/verifier") @@ -153,21 +153,22 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { mockVDR := vdr.NewMockVDR(ctrl) mockVCR := vcr.NewMockVCR(ctrl) mockWallet := holder.NewMockWallet(ctrl) + mockPolicy := policy.NewMockBackend(ctrl) mockVCR.EXPECT().Wallet().Return(mockWallet) mockAuth := auth.NewMockAuthenticationServices(ctrl) - mockAuth.EXPECT().PresentationDefinitions().Return(pe.TestDefinitionResolver(t)) mockWallet.EXPECT().List(gomock.Any(), holderDID).Return(walletCredentials, nil) mockVDR.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) - instance := New(mockAuth, mockVCR, mockVDR, storage.NewTestStorageEngine(t)) + instance := New(mockAuth, mockVCR, mockVDR, storage.NewTestStorageEngine(t), mockPolicy) params := map[string]string{ - "scope": "eOverdracht-overdrachtsbericht", - "response_type": "code", - "response_mode": "direct_post", - "client_metadata_uri": "https://example.com/client_metadata.xml", + "scope": "eOverdracht-overdrachtsbericht", + "response_type": "code", + "response_mode": "direct_post", + "client_metadata_uri": "https://example.com/client_metadata.xml", + "presentation_definition": `{"id":"1","input_descriptors":[]}`, } - response, err := instance.handlePresentationRequest(params, createSession(params, holderDID)) + response, err := instance.handlePresentationRequest(context.Background(), params, createSession(params, holderDID)) require.NoError(t, err) httpResponse := &stubResponseWriter{} @@ -175,36 +176,17 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { require.Equal(t, http.StatusOK, httpResponse.statusCode) assert.Contains(t, httpResponse.body.String(), "") }) - t.Run("unsupported scope", func(t *testing.T) { - ctrl := gomock.NewController(t) - peStore := &pe.DefinitionResolver{} - _ = peStore.LoadFromFile("test/presentation_definition_mapping.json") - mockAuth := auth.NewMockAuthenticationServices(ctrl) - mockAuth.EXPECT().PresentationDefinitions().Return(peStore) - instance := New(mockAuth, nil, nil, nil) - - params := map[string]string{ - "scope": "unsupported", - "response_type": "code", - "response_mode": "direct_post", - "client_metadata_uri": "https://example.com/client_metadata.xml", - } - - response, err := instance.handlePresentationRequest(params, createSession(params, holderDID)) - - requireOAuthError(t, err, oauth.InvalidRequest, "unsupported scope for presentation exchange: unsupported") - assert.Nil(t, response) - }) t.Run("invalid response_mode", func(t *testing.T) { - instance := New(nil, nil, nil, nil) + instance := New(nil, nil, nil, nil, nil) params := map[string]string{ - "scope": "eOverdracht-overdrachtsbericht", - "response_type": "code", - "response_mode": "invalid", - "client_metadata_uri": "https://example.com/client_metadata.xml", + "scope": "eOverdracht-overdrachtsbericht", + "response_type": "code", + "response_mode": "invalid", + "client_metadata_uri": "https://example.com/client_metadata.xml", + "presentation_definition": "{}", } - response, err := instance.handlePresentationRequest(params, createSession(params, holderDID)) + response, err := instance.handlePresentationRequest(context.Background(), params, createSession(params, holderDID)) requireOAuthError(t, err, oauth.InvalidRequest, "response_mode must be direct_post") assert.Nil(t, response) diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index a66b24457c..abbdbb6951 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -45,7 +45,7 @@ const s2sMaxClockSkew = 5 * time.Second // handleS2SAccessTokenRequest handles the /token request with vp_token bearer grant type, intended for service-to-service exchanges. // It performs cheap checks first (parameter presence and validity, matching VCs to the presentation definition), then the more expensive ones (checking signatures). -func (r Wrapper) handleS2SAccessTokenRequest(issuer did.DID, scope string, submissionJSON string, assertionJSON string) (HandleTokenRequestResponseObject, error) { +func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, issuer did.DID, scope string, submissionJSON string, assertionJSON string) (HandleTokenRequestResponseObject, error) { pexEnvelope, err := pe.ParseEnvelope([]byte(assertionJSON)) if err != nil { return nil, oauth.OAuth2Error{ @@ -76,7 +76,7 @@ func (r Wrapper) handleS2SAccessTokenRequest(issuer did.DID, scope string, submi return nil, err } } - credentialMap, definition, err := r.validatePresentationSubmission(scope, submission, pexEnvelope) + credentialMap, definition, err := r.validatePresentationSubmission(ctx, issuer, scope, submission, pexEnvelope) if err != nil { return nil, err } @@ -161,12 +161,13 @@ func (r Wrapper) createS2SAccessToken(issuer did.DID, issueTime time.Time, prese // validatePresentationSubmission checks if the presentation submission is valid for the given scope: // 1. Resolve presentation definition for the requested scope // 2. Check submission against presentation and definition -func (r Wrapper) validatePresentationSubmission(scope string, submission *pe.PresentationSubmission, pexEnvelope *pe.Envelope) (map[string]vc.VerifiableCredential, *PresentationDefinition, error) { - definition := r.auth.PresentationDefinitions().ByScope(scope) - if definition == nil { +func (r Wrapper) validatePresentationSubmission(ctx context.Context, authorizer did.DID, scope string, submission *pe.PresentationSubmission, pexEnvelope *pe.Envelope) (map[string]vc.VerifiableCredential, *PresentationDefinition, error) { + definition, err := r.policyBackend.PresentationDefinition(ctx, authorizer, scope) + if err != nil { return nil, nil, oauth.OAuth2Error{ - Code: oauth.InvalidScope, - Description: fmt.Sprintf("unsupported scope for presentation exchange: %s", scope), + Code: oauth.InvalidScope, + InternalError: err, + Description: fmt.Sprintf("unsupported scope (%s) for presentation exchange: %s", scope, err.Error()), } } diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index a1866469b3..3d5f3d4ef1 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -19,11 +19,13 @@ package iam import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "encoding/json" "errors" + "github.com/nuts-foundation/nuts-node/policy" "go.uber.org/mock/gomock" "net/http" "testing" @@ -94,6 +96,36 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { require.NoError(t, err) issuerDIDDocument.AddAssertionMethod(verificationMethod) + var definition pe.PresentationDefinition + require.NoError(t, json.Unmarshal([]byte(` +{ + "format": { + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020" + ] + } + }, + "input_descriptors": [ + { + "id": "1", + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + } + ] + } + } + ] +}`), &definition)) + var submission pe.PresentationSubmission require.NoError(t, json.Unmarshal([]byte(` { @@ -117,8 +149,9 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("JSON-LD VP", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, requestedScope).Return(&definition, nil) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) require.IsType(t, HandleTokenRequest200JSONResponse{}, resp) @@ -134,7 +167,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { require.NoError(t, token.Remove(jwt.ExpirationKey)) }, verifiableCredential) - _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.EqualError(t, err, "invalid_request - presentation is missing creation or expiration date") }) @@ -144,7 +177,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { require.NoError(t, token.Remove(jwt.NotBeforeKey)) }, verifiableCredential) - _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.EqualError(t, err, "invalid_request - presentation is missing creation or expiration date") }) @@ -154,7 +187,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour))) }, verifiableCredential) - _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.EqualError(t, err, "invalid_request - presentation is valid for too long (max 5s)") }) @@ -163,9 +196,10 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { require.NoError(t, token.Set(jwt.AudienceKey, issuerDID.String())) }, verifiableCredential) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, requestedScope).Return(&definition, nil) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) require.IsType(t, HandleTokenRequest200JSONResponse{}, resp) @@ -177,7 +211,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("VP is not valid JSON", func(t *testing.T) { ctx := newTestClient(t) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, "[true, false]") + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, "[true, false]") assert.EqualError(t, err, "invalid_request - assertion parameter is invalid: unable to parse PEX envelope as verifiable presentation: invalid JWT") assert.Nil(t, resp) @@ -189,7 +223,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { secondPresentation := test.CreateJSONLDPresentation(t, secondSubjectID, proofVisitor, credential.JWTNutsOrganizationCredential(t, secondSubjectID)) assertionJSON, _ := json.Marshal([]VerifiablePresentation{presentation, secondPresentation}) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, string(assertionJSON)) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, string(assertionJSON)) assert.EqualError(t, err, "invalid_request - not all presentations have the same credential subject ID") assert.Nil(t, resp) }) @@ -197,70 +231,74 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("replay attack (nonce is reused)", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, requestedScope).Return(&definition, nil).Times(2) - _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - presentation nonce has already been used") assert.Nil(t, resp) }) t.Run("JSON-LD VP is missing nonce", func(t *testing.T) { ctx := newTestClient(t) - + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, requestedScope).Return(&definition, nil) proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { proof.Domain = &issuerDIDStr proof.Nonce = nil }) presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - presentation has invalid proof or nonce") assert.Nil(t, resp) }) t.Run("JSON-LD VP has empty nonce", func(t *testing.T) { ctx := newTestClient(t) - + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, requestedScope).Return(&definition, nil) proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { proof.Domain = &issuerDIDStr proof.Nonce = new(string) }) presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - presentation has invalid proof or nonce") assert.Nil(t, resp) }) t.Run("JWT VP is missing nonce", func(t *testing.T) { ctx := newTestClient(t) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, requestedScope).Return(&definition, nil) presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerDID.String()) _ = token.Remove("nonce") }, verifiableCredential) - _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce") }) t.Run("JWT VP has empty nonce", func(t *testing.T) { ctx := newTestClient(t) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, requestedScope).Return(&definition, nil) presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerDID.String()) _ = token.Set("nonce", "") }, verifiableCredential) - _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce") }) t.Run("JWT VP nonce is not a string", func(t *testing.T) { ctx := newTestClient(t) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, requestedScope).Return(&definition, nil) presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { _ = token.Set(jwt.AudienceKey, issuerDID.String()) _ = token.Set("nonce", true) }, verifiableCredential) - _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce") }) @@ -270,7 +308,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { ctx := newTestClient(t) presentation := test.CreateJWTPresentation(t, *subjectDID, nil, verifiableCredential) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - expected: did:web:example.com:iam:123, got: [] - presentation audience/domain is missing or does not match") assert.Nil(t, resp) @@ -281,7 +319,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { require.NoError(t, token.Set(jwt.AudienceKey, "did:example:other")) }, verifiableCredential) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - expected: did:web:example.com:iam:123, got: [did:example:other] - presentation audience/domain is missing or does not match") assert.Nil(t, resp) @@ -290,8 +328,9 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("VP verification fails", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid")) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, requestedScope).Return(&definition, nil) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - invalid - presentation(s) or contained credential(s) are invalid") assert.Nil(t, resp) @@ -303,7 +342,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { CredentialSubject: []interface{}{map[string]string{}}, }) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, `invalid_request - unable to get subject DID from VC: credential subjects have no ID`) assert.Nil(t, resp) @@ -318,7 +357,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { } verifiablePresentationJSON, _ := verifiablePresentation.MarshalJSON() - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, string(verifiablePresentationJSON)) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, string(verifiablePresentationJSON)) assert.EqualError(t, err, `invalid_request - presentation signer is not credential subject`) assert.Nil(t, resp) @@ -327,17 +366,18 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("submission is not valid JSON", func(t *testing.T) { ctx := newTestClient(t) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, "not-a-valid-submission", presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, "not-a-valid-submission", presentation.Raw()) assert.EqualError(t, err, `invalid_request - invalid presentation submission: invalid character 'o' in literal null (expecting 'u')`) assert.Nil(t, resp) }) t.Run("unsupported scope", func(t *testing.T) { ctx := newTestClient(t) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, "everything").Return(nil, policy.ErrNotFound) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, "everything", submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, "everything", submissionJSON, presentation.Raw()) - assert.EqualError(t, err, `invalid_scope - unsupported scope for presentation exchange: everything`) + assert.EqualError(t, err, `invalid_scope - not found - unsupported scope (everything) for presentation exchange: not found`) assert.Nil(t, resp) }) t.Run("re-evaluation of presentation definition yields different credentials", func(t *testing.T) { @@ -355,8 +395,9 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, otherVerifiableCredential) ctx := newTestClient(t) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), issuerDID, requestedScope).Return(&definition, nil) - resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - presentation submission doesn't match presentation definition - presentation submission does not conform to Presentation Definition") assert.Nil(t, resp) }) diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 19311840ba..12531c011c 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -24,15 +24,15 @@ import ( "net/url" ) -// OAuthSession is the session object for an OAuth2.0 flow (request/authorize/token). type OAuthSession struct { - ClientID string - Scope string - OwnDID did.DID - ClientState string - RedirectURI string - ServerState map[string]interface{} - ResponseType string + ClientID string + Scope string + OwnDID did.DID + ClientState string + RedirectURI string + ServerState map[string]interface{} + ResponseType string + PresentationDefinition PresentationDefinition } // UserSession is the session object for handling the user browser session. diff --git a/auth/auth.go b/auth/auth.go index 4b7ed93c22..db9d23668d 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -20,8 +20,6 @@ package auth import ( "errors" - "fmt" - "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/url" @@ -45,20 +43,19 @@ var _ AuthenticationServices = (*Auth)(nil) // Auth is the main struct of the Auth service type Auth struct { - config Config - jsonldManager jsonld.JSONLD - authzServer oauth.AuthorizationServer - relyingParty oauth.RelyingParty - verifier oauth.Verifier - contractNotary services.ContractNotary - serviceResolver didman.CompoundServiceResolver - keyStore crypto.KeyStore - vcr vcr.VCR - pkiProvider pki.Provider - shutdownFunc func() - vdrInstance vdr.VDR - publicURL *url.URL - presentationDefinitions *pe.DefinitionResolver + config Config + jsonldManager jsonld.JSONLD + authzServer oauth.AuthorizationServer + relyingParty oauth.RelyingParty + verifier oauth.Verifier + contractNotary services.ContractNotary + serviceResolver didman.CompoundServiceResolver + keyStore crypto.KeyStore + vcr vcr.VCR + pkiProvider pki.Provider + shutdownFunc func() + vdrInstance vdr.VDR + publicURL *url.URL } // Name returns the name of the module. @@ -86,22 +83,17 @@ func (auth *Auth) ContractNotary() services.ContractNotary { return auth.contractNotary } -func (auth *Auth) PresentationDefinitions() *pe.DefinitionResolver { - return auth.presentationDefinitions -} - // NewAuthInstance accepts a Config with several Nuts Engines and returns an instance of Auth func NewAuthInstance(config Config, vdrInstance vdr.VDR, vcr vcr.VCR, keyStore crypto.KeyStore, serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider) *Auth { return &Auth{ - config: config, - jsonldManager: jsonldManager, - vdrInstance: vdrInstance, - keyStore: keyStore, - vcr: vcr, - pkiProvider: pkiProvider, - serviceResolver: serviceResolver, - shutdownFunc: func() {}, - presentationDefinitions: &pe.DefinitionResolver{}, + config: config, + jsonldManager: jsonldManager, + vdrInstance: vdrInstance, + keyStore: keyStore, + vcr: vcr, + pkiProvider: pkiProvider, + serviceResolver: serviceResolver, + shutdownFunc: func() {}, } } @@ -159,13 +151,6 @@ func (auth *Auth) Configure(config core.ServerConfig) error { return err } - // load presentation definitions - if auth.config.PresentationExchangeMappingFile != "" { - if err := auth.presentationDefinitions.LoadFromFile(auth.config.PresentationExchangeMappingFile); err != nil { - return fmt.Errorf("failed to load presentation exchange mapping file: %w", err) - } - } - accessTokenLifeSpan := time.Duration(auth.config.AccessTokenLifeSpan) * time.Second auth.authzServer = oauth.NewAuthorizationServer(auth.vdrInstance.Resolver(), auth.vcr, auth.vcr.Verifier(), auth.serviceResolver, auth.keyStore, auth.contractNotary, auth.jsonldManager, accessTokenLifeSpan) diff --git a/auth/cmd/cmd.go b/auth/cmd/cmd.go index d9c8f8cdd3..8bef2a4631 100644 --- a/auth/cmd/cmd.go +++ b/auth/cmd/cmd.go @@ -44,8 +44,6 @@ const ConfAccessTokenLifeSpan = "auth.accesstokenlifespan" // ConfV2APIEnabled enables experimental v2 API endpoints const ConfV2APIEnabled = "auth.v2apienabled" -const ConfPresentationExchangeMappingFile = "auth.presentationexchangemappingfile" - // FlagSet returns the configuration flags supported by this module. func FlagSet() *pflag.FlagSet { flags := pflag.NewFlagSet("auth", pflag.ContinueOnError) @@ -58,9 +56,7 @@ func FlagSet() *pflag.FlagSet { flags.Int(ConfAccessTokenLifeSpan, defs.AccessTokenLifeSpan, "defines how long (in seconds) an access token is valid. Uses default in strict mode.") flags.StringSlice(ConfContractValidators, defs.ContractValidators, "sets the different contract validators to use") flags.Bool(ConfV2APIEnabled, defs.V2APIEnabled, "enables experimental v2 API endpoints") - flags.String(ConfPresentationExchangeMappingFile, defs.PresentationExchangeMappingFile, "sets the path to the presentation exchange mapping file") _ = flags.MarkHidden(ConfV2APIEnabled) - _ = flags.MarkHidden(ConfPresentationExchangeMappingFile) return flags } diff --git a/auth/cmd/cmd_test.go b/auth/cmd/cmd_test.go index 6df25bb351..dfdd20ed34 100644 --- a/auth/cmd/cmd_test.go +++ b/auth/cmd/cmd_test.go @@ -48,7 +48,6 @@ func TestFlagSet(t *testing.T) { ConfHTTPTimeout, ConfAutoUpdateIrmaSchemas, ConfIrmaSchemeManager, - ConfPresentationExchangeMappingFile, ConfV2APIEnabled, }, keys) } diff --git a/auth/config.go b/auth/config.go index 3f64eba2da..af96d9b25c 100644 --- a/auth/config.go +++ b/auth/config.go @@ -27,13 +27,12 @@ import ( // Config holds all the configuration params type Config struct { - Irma IrmaConfig `koanf:"irma"` - HTTPTimeout int `koanf:"http.timeout"` - ClockSkew int `koanf:"clockskew"` - ContractValidators []string `koanf:"contractvalidators"` - AccessTokenLifeSpan int `koanf:"accesstokenlifespan"` - V2APIEnabled bool `koanf:"v2apienabled"` - PresentationExchangeMappingFile string `koanf:"presentationexchangemappingfile"` + Irma IrmaConfig `koanf:"irma"` + HTTPTimeout int `koanf:"http.timeout"` + ClockSkew int `koanf:"clockskew"` + ContractValidators []string `koanf:"contractvalidators"` + AccessTokenLifeSpan int `koanf:"accesstokenlifespan"` + V2APIEnabled bool `koanf:"v2apienabled"` } type IrmaConfig struct { diff --git a/auth/interface.go b/auth/interface.go index 1749b017a1..c19f897d44 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -21,7 +21,6 @@ package auth import ( "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/oauth" - "github.com/nuts-foundation/nuts-node/vcr/pe" "net/url" ) @@ -43,6 +42,4 @@ type AuthenticationServices interface { V2APIEnabled() bool // PublicURL returns the public URL of the node. PublicURL() *url.URL - // PresentationDefinitions returns the DefinitionResolver for mapping scopes to presentation definitions - PresentationDefinitions() *pe.DefinitionResolver } diff --git a/auth/mock.go b/auth/mock.go index bc101c2836..89ef4f95c4 100644 --- a/auth/mock.go +++ b/auth/mock.go @@ -15,7 +15,6 @@ import ( services "github.com/nuts-foundation/nuts-node/auth/services" oauth "github.com/nuts-foundation/nuts-node/auth/services/oauth" - pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -70,20 +69,6 @@ func (mr *MockAuthenticationServicesMockRecorder) ContractNotary() *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContractNotary", reflect.TypeOf((*MockAuthenticationServices)(nil).ContractNotary)) } -// PresentationDefinitions mocks base method. -func (m *MockAuthenticationServices) PresentationDefinitions() *pe.DefinitionResolver { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PresentationDefinitions") - ret0, _ := ret[0].(*pe.DefinitionResolver) - return ret0 -} - -// PresentationDefinitions indicates an expected call of PresentationDefinitions. -func (mr *MockAuthenticationServicesMockRecorder) PresentationDefinitions() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinitions", reflect.TypeOf((*MockAuthenticationServices)(nil).PresentationDefinitions)) -} - // PublicURL mocks base method. func (m *MockAuthenticationServices) PublicURL() *url.URL { m.ctrl.T.Helper() diff --git a/cmd/root.go b/cmd/root.go index e33ce818fc..6700da48c1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -57,6 +57,7 @@ import ( networkAPI "github.com/nuts-foundation/nuts-node/network/api/v1" networkCmd "github.com/nuts-foundation/nuts-node/network/cmd" "github.com/nuts-foundation/nuts-node/pki" + "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" storageCmd "github.com/nuts-foundation/nuts-node/storage/cmd" "github.com/nuts-foundation/nuts-node/vcr" @@ -200,6 +201,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { statusEngine := status.NewStatusEngine(system) metricsEngine := core.NewMetricsEngine() goldenHammer := golden_hammer.New(vdrInstance, didmanInstance) + policyInstance := policy.NewRouter(pkiInstance) // Register HTTP routes system.RegisterRoutes(&core.LandingPage{}) @@ -219,7 +221,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { system.RegisterRoutes(statusEngine.(core.Routable)) system.RegisterRoutes(metricsEngine.(core.Routable)) system.RegisterRoutes(&authAPIv1.Wrapper{Auth: authInstance, CredentialResolver: credentialInstance}) - system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance, storageInstance)) + system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance, storageInstance, policyInstance)) system.RegisterRoutes(&authMeansAPI.Wrapper{Auth: authInstance}) system.RegisterRoutes(&didmanAPI.Wrapper{Didman: didmanInstance}) system.RegisterRoutes(&discoveryAPI.Wrapper{Server: discoveryInstance}) @@ -242,6 +244,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { system.RegisterEngine(discoveryInstance) system.RegisterEngine(didmanInstance) system.RegisterEngine(goldenHammer) + system.RegisterEngine(policyInstance) // HTTP engine MUST be registered last, because when started it dispatches HTTP calls to the registered routes. // Registering is last makes sure all engines are started and ready to accept requests. system.RegisterEngine(httpServerInstance) @@ -341,6 +344,7 @@ func serverConfigFlags() *pflag.FlagSet { set.AddFlagSet(pki.FlagSet()) set.AddFlagSet(goldenHammerCmd.FlagSet()) set.AddFlagSet(discoveryCmd.FlagSet()) + set.AddFlagSet(policy.FlagSet()) return set } diff --git a/cmd/root_test.go b/cmd/root_test.go index 9f12c828d5..aee2367ab3 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -163,7 +163,7 @@ func Test_CreateSystem(t *testing.T) { system.VisitEngines(func(engine core.Engine) { numEngines++ }) - assert.Equal(t, 16, numEngines) + assert.Equal(t, 17, numEngines) } func Test_ClientCommand_ErrorHandlers(t *testing.T) { diff --git a/codegen/configs/policy_client_v1.yaml b/codegen/configs/policy_client_v1.yaml index 5bd811ccf1..ebf9d6498d 100644 --- a/codegen/configs/policy_client_v1.yaml +++ b/codegen/configs/policy_client_v1.yaml @@ -9,3 +9,4 @@ output-options: exclude-schemas: - PresentationDefinition - PresentationSubmission + - AuthorizedRequest diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml index 04c3ac6de6..7bbdd3b049 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -113,7 +113,7 @@ paths: schema: type: string format: uri - "/iam/{did}/presentation_definition": + "/iam/{id}/presentation_definition": get: summary: Used by relying parties to obtain a presentation definition for desired scopes as specified by Nuts RFC021. description: | @@ -125,12 +125,13 @@ paths: tags: - oauth2 parameters: - - name: did + - name: id in: path required: true + description: the id part of the web DID schema: type: string - example: did:nuts:123 + example: EwVMYK2ugaMvRHUbGFBhuyF423JuNQbtpes35eHhkQic - name: scope in: query required: true diff --git a/docs/pages/deployment/cli-reference.rst b/docs/pages/deployment/cli-reference.rst index dc1fe5f6f5..48d83080c6 100755 --- a/docs/pages/deployment/cli-reference.rst +++ b/docs/pages/deployment/cli-reference.rst @@ -60,6 +60,8 @@ The following options apply to the server commands below: --network.v2.gossipinterval int Interval (in milliseconds) that specifies how often the node should gossip its new hashes to other nodes. (default 5000) --pki.maxupdatefailhours int Maximum number of hours that a denylist update can fail (default 4) --pki.softfail Do not reject certificates if their revocation status cannot be established when softfail is true (default true) + --policy.address string The address of a remote policy server. Mutual exclusive with policy.directory. + --policy.directory string Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. Mutual exclusive with policy.address. --storage.bbolt.backup.directory string Target directory for BBolt database backups. --storage.bbolt.backup.interval duration Interval, formatted as Golang duration (e.g. 10m, 1h) at which BBolt database backups will be performed. --storage.redis.address string Redis database server address. This can be a simple 'host:port' or a Redis connection URL with scheme, auth and other options. diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index 68ebd6645b..9a6fc972fe 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -53,8 +53,8 @@ http.default.auth.type Whether to enable authentication for the default interface, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode. http.default.cors.origin [] When set, enables CORS from the specified origins on the default HTTP interface. **JSONLD** - jsonld.contexts.localmapping [https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. - jsonld.contexts.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. + jsonld.contexts.localmapping [https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. + jsonld.contexts.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. **Network** network.bootstrapnodes [] List of bootstrap nodes (':') which the node initially connect to. network.connectiontimeout 5000 Timeout before an outbound connection attempt times out (in milliseconds). @@ -86,4 +86,7 @@ vcr.openid4vci.definitionsdir Directory with the additional credential definitions the node could issue (experimental, may change without notice). vcr.openid4vci.enabled true Enable issuing and receiving credentials over OpenID4VCI. vcr.openid4vci.timeout 30s Time-out for OpenID4VCI HTTP client operations. + **policy** + policy.address The address of a remote policy server. Mutual exclusive with policy.directory. + policy.directory Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. Mutual exclusive with policy.address. ==================================== =============================================================================================================================================================================================================================================================================================================== ================================================================================================================================================================================================================================================================================================================================ diff --git a/e2e-tests/oauth-flow/rfc021/docker-compose.yml b/e2e-tests/oauth-flow/rfc021/docker-compose.yml index df15b5c394..ff22a27e89 100644 --- a/e2e-tests/oauth-flow/rfc021/docker-compose.yml +++ b/e2e-tests/oauth-flow/rfc021/docker-compose.yml @@ -15,7 +15,7 @@ services: # So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs. - "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro" - "../../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro" - - "./node-A/presentationexchangemapping.json:/opt/nuts/presentationexchangemapping.json:ro" + - "./node-A/presentationexchangemapping.json:/opt/nuts/policies/presentationexchangemapping.json:ro" healthcheck: interval: 1s # Make test run quicker by checking health status more often nodeA: diff --git a/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml b/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml index d256ee0ae4..9668078709 100644 --- a/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml +++ b/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml @@ -8,11 +8,12 @@ http: address: :1323 auth: v2apienabled: true - presentationexchangemappingfile: /opt/nuts/presentationexchangemapping.json contractvalidators: - dummy irma: autoupdateschemas: false +policy: + directory: /opt/nuts/policies tls: truststorefile: /opt/nuts/truststore.pem certfile: /opt/nuts/certificate-and-key.pem diff --git a/makefile b/makefile index f6d6d1a711..0c3317d69c 100644 --- a/makefile +++ b/makefile @@ -35,6 +35,7 @@ gen-mocks: mockgen -destination=network/transport/v2/senders_mock.go -package=v2 -source=network/transport/v2/senders.go mockgen -destination=network/transport/v2/gossip/mock.go -package=gossip -source=network/transport/v2/gossip/manager.go mockgen -destination=pki/mock.go -package=pki -source=pki/interface.go + mockgen -destination=policy/mock.go -package=policy -source=policy/interface.go mockgen -destination=storage/mock.go -package=storage -source=storage/interface.go mockgen -destination=vcr/types/mock.go -package=types -source=vcr/types/interface.go mockgen -destination=vcr/mock.go -package=vcr -source=vcr/interface.go diff --git a/policy/api/v1/client/client.go b/policy/api/v1/client/client.go index d8cfb3ade5..8dfac5a4bf 100644 --- a/policy/api/v1/client/client.go +++ b/policy/api/v1/client/client.go @@ -22,10 +22,10 @@ import ( "context" "crypto/tls" "fmt" - "github.com/nuts-foundation/go-did/did" "net/http" "time" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vcr/pe" ) diff --git a/policy/api/v1/client/generated.go b/policy/api/v1/client/generated.go index ad20e5c8cd..13de280957 100644 --- a/policy/api/v1/client/generated.go +++ b/policy/api/v1/client/generated.go @@ -16,34 +16,6 @@ import ( "github.com/oapi-codegen/runtime" ) -// AuthorizedRequest The request contains all params involved with the request. -// It might be the case that the caller mapped credential fields to additional params. -type AuthorizedRequest struct { - // Audience The audience of the access token. This is the identifier (DID) of the authorizer and issuer of the access token. - Audience string `json:"audience"` - - // ClientId The client ID of the client that requested the resource (DID). - ClientId string `json:"client_id"` - - // PresentationSubmission A presentation submission is a JSON object that maps requirements from the Presentation Definition to the verifiable presentations that were used to request an access token. - // Specified at https://identity.foundation/presentation-exchange/spec/v2.0.0/ - // A JSON schema is available at https://identity.foundation/presentation-exchange/#json-schema - PresentationSubmission PresentationSubmission `json:"presentation_submission"` - - // RequestMethod The method of the resource request. - RequestMethod string `json:"request_method"` - - // RequestUrl The URL of the resource request. - RequestUrl string `json:"request_url"` - - // Scope The scope used in the authorization request. - Scope string `json:"scope"` - - // Vps The verifiable presentations that were used to request the access token. - // The verifiable presentations could be in JWT format or in JSON format. - Vps []interface{} `json:"vps"` -} - // AuthorizedResponse The response indicates if the access token grants access to the requested resource. // If the access token grants access, the response will be 200 with a boolean value set to true. // If the access token does not grant access, the response will be 200 with a boolean value set to false. diff --git a/policy/api/v1/client/types.go b/policy/api/v1/client/types.go index b9aad6779b..9f0a5df900 100644 --- a/policy/api/v1/client/types.go +++ b/policy/api/v1/client/types.go @@ -18,10 +18,40 @@ package client -import "github.com/nuts-foundation/nuts-node/vcr/pe" +import ( + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) // PresentationDefinition is a type alias for the PresentationDefinition from the nuts-node/vcr/pe package. type PresentationDefinition = pe.PresentationDefinition // PresentationSubmission is a type alias for the PresentationSubmission from the nuts-node/vcr/pe package. type PresentationSubmission = pe.PresentationSubmission + +// AuthorizedRequest contains the information about the request +type AuthorizedRequest struct { + // Audience contains the audience of the access token. This is the identifier (DID) of the authorizer and issuer of the access token. + Audience string `json:"audience"` + + // ClientId contains the client ID of the client that requested the resource (DID). + ClientId string `json:"client_id"` + + // PresentationSubmission contains a JSON object that maps requirements from the Presentation Definition to the verifiable presentations that were used to request an access token. + // Specified at https://identity.foundation/presentation-exchange/spec/v2.0.0/ + // A JSON schema is available at https://identity.foundation/presentation-exchange/#json-schema + PresentationSubmission pe.PresentationSubmission `json:"presentation_submission"` + + // RequestMethod contains the HTTP method of the resource request. + RequestMethod string `json:"request_method"` + + // RequestUrl contains URL of the resource request. + RequestUrl string `json:"request_url"` + + // Scope contains the scope used in the authorization request. + Scope string `json:"scope"` + + // Vps contains the verifiable presentations that were used to request the access token. + // The verifiable presentations could be in JWT format or in JSON format. + Vps []vc.VerifiablePresentation `json:"vps"` +} diff --git a/policy/cmd.go b/policy/cmd.go new file mode 100644 index 0000000000..e900e845a2 --- /dev/null +++ b/policy/cmd.go @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2022 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 policy + +import ( + "github.com/spf13/pflag" +) + +// FlagSet contains flags relevant for JSON-LD +func FlagSet() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("policy", pflag.ContinueOnError) + flagSet.String("policy.directory", "", "Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. Mutual exclusive with policy.address.") + flagSet.String("policy.address", "", "The address of a remote policy server. Mutual exclusive with policy.directory.") + return flagSet +} diff --git a/vcr/pe/test.go b/policy/config.go similarity index 64% rename from vcr/pe/test.go rename to policy/config.go index 7930e11657..452e9c6ed4 100644 --- a/vcr/pe/test.go +++ b/policy/config.go @@ -16,15 +16,14 @@ * */ -package pe +package policy -import ( - "github.com/stretchr/testify/require" - "testing" -) - -func TestDefinitionResolver(t testing.TB) *DefinitionResolver { - peStore := &DefinitionResolver{} - require.NoError(t, peStore.LoadFromFile("test/presentation_definition_mapping.json")) - return peStore +type Config struct { + // Directory is the directory where the policy files are stored + // policy files include a scope to presentation definition mapping + Directory string `koanf:"directory"` + // Address is the address of the policy server + Address string `koanf:"address"` + // HTTPTimeout is the client timeout for http requests + HTTPTimeout int `koanf:"http.timeout"` } diff --git a/policy/interface.go b/policy/interface.go new file mode 100644 index 0000000000..df40fa0cdf --- /dev/null +++ b/policy/interface.go @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2023 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 policy + +import ( + "context" + "errors" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/policy/api/v1/client" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) + +// ModuleName is the name of the policy module +const ModuleName = "policy" + +var ErrNotFound = errors.New("not found") + +// PDPBackend is the interface for the policy backend +// Both the remote and local policy backend implement this interface +type PDPBackend interface { + // PresentationDefinition returns the PresentationDefinition for the given scope + // scopes are space delimited. It's up to the backend to decide how to handle this + PresentationDefinition(ctx context.Context, authorizer did.DID, scope string) (*pe.PresentationDefinition, error) + + // Authorized returns true if the policy backends authorizes the given request information fall within the policy definition + Authorized(ctx context.Context, requestInfo client.AuthorizedRequest) (bool, error) +} diff --git a/vcr/pe/store.go b/policy/local.go similarity index 53% rename from vcr/pe/store.go rename to policy/local.go index 11343cd326..8b03014236 100644 --- a/vcr/pe/store.go +++ b/policy/local.go @@ -16,25 +16,75 @@ * */ -package pe +package policy import ( + "context" "encoding/json" "fmt" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/policy/api/v1/client" + "github.com/nuts-foundation/nuts-node/vcr/pe" v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" "io" "os" + "strings" ) -// DefinitionResolver is a store for presentation definitions -// It loads a file with the mapping from oauth scope to presentation definition -type DefinitionResolver struct { +// localPDP is a backend for presentation definitions +// It loads a file with the mapping from oauth scope to presentation definition. +// It allows access when the requester can present a submission according to the Presentation Definition. It does not do any additional authorization checks. +type localPDP struct { // mapping holds the oauth scope to presentation definition mapping mapping map[string]validatingPresentationDefinition } +func (b *localPDP) PresentationDefinition(_ context.Context, _ did.DID, scope string) (*pe.PresentationDefinition, error) { + mapping, ok := b.mapping[scope] + if !ok { + return nil, ErrNotFound + } + result := pe.PresentationDefinition(mapping) + return &result, nil +} + +func (b *localPDP) Authorized(_ context.Context, _ client.AuthorizedRequest) (bool, error) { + return true, nil +} + +// loadFromDirectory traverses all .json files in the given directory and loads them +func (s *localPDP) loadFromDirectory(directory string) error { + // open the directory + dir, err := os.Open(directory) + if err != nil { + return err + } + defer dir.Close() + + // read all the files in the directory + files, err := dir.Readdir(0) + if err != nil { + return err + } + + // load all the files + for _, file := range files { + if file.IsDir() { + continue + } + if !strings.HasSuffix(file.Name(), ".json") { + continue + } + err := s.loadFromFile(fmt.Sprintf("%s/%s", directory, file.Name())) + if err != nil { + return err + } + } + return nil +} + // LoadFromFile loads the mapping from the given file -func (s *DefinitionResolver) LoadFromFile(filename string) error { +func (s *localPDP) loadFromFile(filename string) error { // read the bytes from the file reader, err := os.Open(filename) if err != nil { @@ -56,23 +106,12 @@ func (s *DefinitionResolver) LoadFromFile(filename string) error { return nil } -// ByScope returns the presentation definition for the given scope. -// Returns nil if it doesn't exist or if no mappings are loaded. -func (s *DefinitionResolver) ByScope(scope string) *PresentationDefinition { - mapping, ok := s.mapping[scope] - if !ok { - return nil - } - result := PresentationDefinition(mapping) - return &result -} - // validatingPresentationDefinition is an alias for PresentationDefinition that validates the JSON on unmarshal. -type validatingPresentationDefinition PresentationDefinition +type validatingPresentationDefinition pe.PresentationDefinition func (v *validatingPresentationDefinition) UnmarshalJSON(data []byte) error { if err := v2.Validate(data, v2.PresentationDefinition); err != nil { return err } - return json.Unmarshal(data, (*PresentationDefinition)(v)) + return json.Unmarshal(data, (*pe.PresentationDefinition)(v)) } diff --git a/vcr/pe/store_test.go b/policy/local_test.go similarity index 66% rename from vcr/pe/store_test.go rename to policy/local_test.go index ebb9ae40cb..c356d78110 100644 --- a/vcr/pe/store_test.go +++ b/policy/local_test.go @@ -16,20 +16,22 @@ * */ -package pe +package policy import ( + "context" "testing" + "github.com/nuts-foundation/go-did/did" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestStore_LoadFromFile(t *testing.T) { t.Run("loads the mapping from the file", func(t *testing.T) { - store := DefinitionResolver{} + store := localPDP{} - err := store.LoadFromFile("test/definition_mapping.json") + err := store.loadFromFile("test/definition_mapping.json") require.NoError(t, err) assert.Len(t, store.mapping, 1) @@ -37,38 +39,39 @@ func TestStore_LoadFromFile(t *testing.T) { }) t.Run("returns an error if the file doesn't exist", func(t *testing.T) { - store := DefinitionResolver{} + store := localPDP{} - err := store.LoadFromFile("test/doesntexist.json") + err := store.loadFromFile("test/doesntexist.json") assert.Error(t, err) }) t.Run("returns an error if a presentation definition is invalid", func(t *testing.T) { - store := DefinitionResolver{} + store := localPDP{} - err := store.LoadFromFile("test/invalid_definition_mapping.json") + err := store.loadFromFile("test/invalid/invalid_definition_mapping.json") assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") }) } func TestStore_ByScope(t *testing.T) { - t.Run("returns nil if the scope doesn't exist", func(t *testing.T) { - store := DefinitionResolver{} + t.Run("err - not found", func(t *testing.T) { + store := localPDP{} - result := store.ByScope("eOverdracht-overdrachtsbericht2") + _, err := store.PresentationDefinition(context.Background(), did.DID{}, "eOverdracht-overdrachtsbericht2") - assert.Nil(t, result) + assert.Equal(t, ErrNotFound, err) }) t.Run("returns the presentation definition if the scope exists", func(t *testing.T) { - store := DefinitionResolver{} - err := store.LoadFromFile("test/definition_mapping.json") + store := localPDP{} + err := store.loadFromFile("test/definition_mapping.json") require.NoError(t, err) - result := store.ByScope("eOverdracht-overdrachtsbericht") + result, err := store.PresentationDefinition(context.Background(), did.DID{}, "eOverdracht-overdrachtsbericht") + require.NoError(t, err) assert.NotNil(t, result) }) } diff --git a/policy/mock.go b/policy/mock.go new file mode 100644 index 0000000000..cc32c7811f --- /dev/null +++ b/policy/mock.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: policy/interface.go +// +// Generated by this command: +// +// mockgen -destination=policy/mock.go -package=policy -source=policy/interface.go +// +// Package policy is a generated GoMock package. +package policy + +import ( + context "context" + reflect "reflect" + + did "github.com/nuts-foundation/go-did/did" + client "github.com/nuts-foundation/nuts-node/policy/api/v1/client" + pe "github.com/nuts-foundation/nuts-node/vcr/pe" + gomock "go.uber.org/mock/gomock" +) + +// MockBackend is a mock of PDPBackend interface. +type MockBackend struct { + ctrl *gomock.Controller + recorder *MockBackendMockRecorder +} + +// MockBackendMockRecorder is the mock recorder for MockBackend. +type MockBackendMockRecorder struct { + mock *MockBackend +} + +// NewMockBackend creates a new mock instance. +func NewMockBackend(ctrl *gomock.Controller) *MockBackend { + mock := &MockBackend{ctrl: ctrl} + mock.recorder = &MockBackendMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBackend) EXPECT() *MockBackendMockRecorder { + return m.recorder +} + +// Authorized mocks base method. +func (m *MockBackend) Authorized(ctx context.Context, requestInfo client.AuthorizedRequest) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Authorized", ctx, requestInfo) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Authorized indicates an expected call of Authorized. +func (mr *MockBackendMockRecorder) Authorized(ctx, requestInfo any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authorized", reflect.TypeOf((*MockBackend)(nil).Authorized), ctx, requestInfo) +} + +// PresentationDefinition mocks base method. +func (m *MockBackend) PresentationDefinition(ctx context.Context, authorizer did.DID, scope string) (*pe.PresentationDefinition, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PresentationDefinition", ctx, authorizer, scope) + ret0, _ := ret[0].(*pe.PresentationDefinition) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PresentationDefinition indicates an expected call of PresentationDefinition. +func (mr *MockBackendMockRecorder) PresentationDefinition(ctx, authorizer, scope any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockBackend)(nil).PresentationDefinition), ctx, authorizer, scope) +} diff --git a/policy/policy.go b/policy/policy.go new file mode 100644 index 0000000000..913c9e9a98 --- /dev/null +++ b/policy/policy.go @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2023 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 policy + +import ( + "context" + "errors" + "fmt" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/pki" + "github.com/nuts-foundation/nuts-node/policy/api/v1/client" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "net/url" + "time" +) + +// NewRouter creates a new policy backend router that can forward requests to the correct backend +func NewRouter(pkiInstance pki.Provider) *Router { + return &Router{ + pkiInstance: pkiInstance, + } +} + +type Router struct { + backend PDPBackend + config Config + pkiInstance pki.Provider +} + +func (b *Router) Name() string { + return ModuleName +} + +func (b *Router) Configure(config core.ServerConfig) error { + // if both directory and address are set, return error + if b.config.Directory != "" && b.config.Address != "" { + return errors.New("both policy.directory and policy.address are set, please choose one") + } + + // if address is set use remote backend, otherwise use local backend + if b.config.Address != "" { + _, err := url.Parse(b.config.Address) + if err != nil { + return fmt.Errorf("failed to parse policy.address: %w", err) + } + tlsConfig, err := b.pkiInstance.CreateTLSConfig(config.TLS) + if err != nil { + return err + } + b.backend = &remote{ + address: b.config.Address, + client: client.NewHTTPClient(config.Strictmode, time.Duration(b.config.HTTPTimeout)*time.Second, tlsConfig), + } + } + if b.config.Directory != "" { + backend := &localPDP{} + if err := backend.loadFromDirectory(b.config.Directory); err != nil { + return fmt.Errorf("failed to load policy from directory: %w", err) + } + b.backend = backend + } + + return nil +} + +func (b *Router) Config() interface{} { + return &b.config +} + +func (b *Router) PresentationDefinition(ctx context.Context, authorizer did.DID, scope string) (*pe.PresentationDefinition, error) { + return b.backend.PresentationDefinition(ctx, authorizer, scope) +} + +func (b *Router) Authorized(ctx context.Context, requestInfo client.AuthorizedRequest) (bool, error) { + return b.backend.Authorized(ctx, requestInfo) +} diff --git a/policy/policy_test.go b/policy/policy_test.go new file mode 100644 index 0000000000..1cfb00be5e --- /dev/null +++ b/policy/policy_test.go @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2023 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 policy + +import ( + "context" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/pki" + "github.com/nuts-foundation/nuts-node/policy/api/v1/client" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "testing" + + "github.com/nuts-foundation/nuts-node/core" + "github.com/stretchr/testify/assert" +) + +func TestRouter_Configure(t *testing.T) { + t.Run("ok - directory is set", func(t *testing.T) { + router := Router{} + + cfg := router.Config().(*Config) + cfg.Directory = "test" + err := router.Configure(core.ServerConfig{}) + + assert.NoError(t, err) + _, ok := router.backend.(*localPDP) + assert.True(t, ok) + }) + t.Run("ok - address is set", func(t *testing.T) { + ctrl := gomock.NewController(t) + pki := pki.NewMockProvider(ctrl) + pki.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil) + router := Router{ + pkiInstance: pki, + } + + cfg := router.Config().(*Config) + cfg.Address = "http://localhost:8080" + err := router.Configure(core.ServerConfig{}) + + assert.NoError(t, err) + _, ok := router.backend.(*remote) + assert.True(t, ok) + }) + t.Run("err - both directory and address are set", func(t *testing.T) { + router := Router{} + + cfg := router.Config().(*Config) + cfg.Directory = "test" + cfg.Address = "test" + err := router.Configure(core.ServerConfig{}) + + assert.EqualError(t, err, "both policy.directory and policy.address are set, please choose one") + }) + + t.Run("err - directory doesn't exist", func(t *testing.T) { + router := Router{} + + cfg := router.Config().(*Config) + cfg.Directory = "unknown" + err := router.Configure(core.ServerConfig{}) + + assert.EqualError(t, err, "failed to load policy from directory: open unknown: no such file or directory") + }) + + t.Run("err - address is invalid", func(t *testing.T) { + router := Router{} + + cfg := router.Config().(*Config) + cfg.Address = "://" + err := router.Configure(core.ServerConfig{}) + + assert.EqualError(t, err, "failed to parse policy.address: parse \"://\": missing protocol scheme") + }) +} + +func TestRouter_Name(t *testing.T) { + router := Router{} + + assert.Equal(t, ModuleName, router.Name()) +} + +func TestRouterForwarding(t *testing.T) { + ctrl := gomock.NewController(t) + ctx := context.Background() + testDID := did.MustParseDID("did:web:example.com:test") + presentationDefinition := pe.PresentationDefinition{} + router := Router{ + backend: NewMockBackend(ctrl), + } + + t.Run("Authorized", func(t *testing.T) { + router.backend.(*MockBackend).EXPECT().Authorized(ctx, gomock.Any()).Return(true, nil) + + result, err := router.Authorized(ctx, client.AuthorizedRequest{}) + + require.NoError(t, err) + assert.True(t, result) + }) + + t.Run("PresentationDefinition", func(t *testing.T) { + router.backend.(*MockBackend).EXPECT().PresentationDefinition(ctx, testDID, "test").Return(&presentationDefinition, nil) + + result, err := router.PresentationDefinition(ctx, testDID, "test") + + require.NoError(t, err) + assert.Equal(t, presentationDefinition, *result) + }) +} diff --git a/policy/remote.go b/policy/remote.go new file mode 100644 index 0000000000..144bdc7317 --- /dev/null +++ b/policy/remote.go @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 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 policy + +import ( + "context" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/policy/api/v1/client" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) + +type remote struct { + address string + client client.HTTPClient +} + +func (b remote) PresentationDefinition(ctx context.Context, authorizer did.DID, scope string) (*pe.PresentationDefinition, error) { + return b.client.PresentationDefinition(ctx, b.address, authorizer, scope) +} + +func (b remote) Authorized(ctx context.Context, requestInfo client.AuthorizedRequest) (bool, error) { + return b.client.Authorized(ctx, b.address, requestInfo) +} diff --git a/vcr/pe/test/definition_mapping.json b/policy/test/definition_mapping.json similarity index 100% rename from vcr/pe/test/definition_mapping.json rename to policy/test/definition_mapping.json diff --git a/vcr/pe/test/invalid_definition_mapping.json b/policy/test/invalid/invalid_definition_mapping.json similarity index 100% rename from vcr/pe/test/invalid_definition_mapping.json rename to policy/test/invalid/invalid_definition_mapping.json diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 9774e06e63..410fe93460 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -24,6 +24,7 @@ import ( "fmt" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" + v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" "strings" "github.com/PaesslerAG/jsonpath" @@ -34,6 +35,20 @@ import ( // ErrUnsupportedFilter is returned when a filter uses unsupported features. var ErrUnsupportedFilter = errors.New("unsupported filter") +// ParsePresentationDefinition validates the given JSON and parses it into a PresentationDefinition. +// It returns an error if the JSON is invalid or doesn't match the JSON schema for a PresentationDefinition. +func ParsePresentationDefinition(raw []byte) (*PresentationDefinition, error) { + if err := v2.Validate(raw, v2.PresentationDefinition); err != nil { + return nil, err + } + var result PresentationDefinition + err := json.Unmarshal(raw, &result) + if err != nil { + return nil, err + } + return &result, nil +} + // Candidate is a struct that holds the result of a match between an input descriptor and a VC // A non-matching VC also leads to a Candidate, but without a VC. type Candidate struct { diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index e8e5515d37..e15f5a066a 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -88,6 +88,18 @@ func definitions() testDefinitions { return result } +func TestParsePresentationDefinition(t *testing.T) { + t.Run("ok", func(t *testing.T) { + definition, err := ParsePresentationDefinition([]byte(`{"id": "1", "input_descriptors":[]}`)) + require.NoError(t, err) + assert.Equal(t, "1", definition.Id) + }) + t.Run("missing id", func(t *testing.T) { + _, err := ParsePresentationDefinition([]byte(`{"input_descriptors":[]}`)) + assert.ErrorContains(t, err, `missing properties: "id"`) + }) +} + func TestMatch(t *testing.T) { jsonldVC := credential.ValidNutsOrganizationCredential(t) jwtVC := credential.JWTNutsOrganizationCredential(t, did.MustParseDID("did:web:example.com"))