From 3da08a9a12d408579b48598eba94549bdc08ea73 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Fri, 15 Sep 2023 15:25:11 +0200 Subject: [PATCH] added presentation_definition endpoint to the metadata added implementation of the presentation definition endpoint test fix added API client for presentation definition --- auth/api/iam/api.go | 18 ++++ auth/api/iam/api_test.go | 40 +++++++++ auth/api/iam/client.go | 43 ++++++++- auth/api/iam/client_test.go | 74 +++++++++++++++ auth/api/iam/generated.go | 89 +++++++++++++++++++ auth/api/iam/metadata.go | 7 +- auth/api/iam/metadata_test.go | 7 +- auth/api/iam/openid4vp_test.go | 2 +- .../test/presentation_definition_mapping.json | 2 +- auth/api/iam/types.go | 7 ++ codegen/configs/auth_iam.yaml | 1 + docs/_static/auth/iam.yaml | 43 +++++++++ 12 files changed, 322 insertions(+), 11 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index c174d05521..6932852252 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -251,6 +251,24 @@ func (r Wrapper) OAuthClientMetadata(ctx context.Context, request OAuthClientMet return OAuthClientMetadata200JSONResponse(clientMetadata(*identity)), nil } +func (r Wrapper) PresentationDefinition(_ 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 + // map all scopes to a presentation definition + presentationDefinitions := make([]PresentationDefinition, 0, len(request.Params.Scope)) + for _, scope := range request.Params.Scope { + presentationDefinition := r.auth.PresentationDefinitions().ByScope(scope) + if presentationDefinition == nil { + return nil, core.InvalidInputError("unsupported scope: %s", scope) + } + presentationDefinitions = append(presentationDefinitions, *presentationDefinition) + } + + return PresentationDefinition200JSONResponse(presentationDefinitions), nil +} func createSession(params map[string]string, ownDID did.DID) *Session { session := &Session{ diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 61bd6bc3ae..6e34b9a5eb 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -25,6 +25,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/pe" vdr "github.com/nuts-foundation/nuts-node/vdr/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -154,6 +155,45 @@ func TestWrapper_GetOAuthClientMetadata(t *testing.T) { assert.Nil(t, res) }) } +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") + + t.Run("ok", func(t *testing.T) { + test := newTestClient(t) + test.authnServices.EXPECT().PresentationDefinitions().Return(&definitionResolver) + + response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.ID, Params: PresentationDefinitionParams{Scope: []string{"test"}}}) + + require.NoError(t, err) + require.NotNil(t, response) + definitions := []PresentationDefinition(response.(PresentationDefinition200JSONResponse)) + assert.Len(t, definitions, 1) + }) + + t.Run("ok - missing scope", func(t *testing.T) { + test := newTestClient(t) + + response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.ID, Params: PresentationDefinitionParams{}}) + + require.NoError(t, err) + require.NotNil(t, response) + definitions := []PresentationDefinition(response.(PresentationDefinition200JSONResponse)) + assert.Len(t, definitions, 0) + }) + + t.Run("error - unknown scope", func(t *testing.T) { + test := newTestClient(t) + test.authnServices.EXPECT().PresentationDefinitions().Return(&definitionResolver) + + response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.ID, Params: PresentationDefinitionParams{Scope: []string{"unknown"}}}) + + assert.EqualError(t, err, "unsupported scope: unknown") + assert.Nil(t, response) + }) +} // statusCodeFrom returns the statuscode if err is core.HTTPStatusCodeError, or 0 if it isn't func statusCodeFrom(err error) int { diff --git a/auth/api/iam/client.go b/auth/api/iam/client.go index 801214b215..0468df575a 100644 --- a/auth/api/iam/client.go +++ b/auth/api/iam/client.go @@ -27,6 +27,7 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/didweb" "io" "net/http" + "net/url" ) // HTTPClient holds the server address and other basic settings for the http client @@ -55,9 +56,9 @@ func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, webDI return nil, err } - request := &http.Request{ - Method: "GET", - URL: metadataURL, + request, err := http.NewRequest(http.MethodGet, metadataURL.String(), nil) + if err != nil { + return nil, err } response, err := hb.httpClient.Do(request.WithContext(ctx)) if err != nil { @@ -80,3 +81,39 @@ func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, webDI return &metadata, nil } + +// PresentationDefinition retrieves the presentation definition for the given web DID and scope(s). +// We pass the endpoint url for the presentation definition endpoint because we already retrieved the metadata in a previous step. +// The scopes are evaluated as raw query params and encoded if needed. +func (hb HTTPClient) PresentationDefinition(ctx context.Context, definitionEndpoint string, scopes []string) ([]PresentationDefinition, error) { + presentationDefinitionURL, err := url.Parse(definitionEndpoint) + if err != nil { + return nil, err + } + presentationDefinitionURL.RawQuery = url.Values{"scope": scopes}.Encode() + + // create a GET request with scope query param + request, err := http.NewRequest(http.MethodGet, presentationDefinitionURL.String(), nil) + if err != nil { + return nil, err + } + response, err := hb.httpClient.Do(request.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("failed to call endpoint: %w", err) + } + if err = core.TestResponseCode(http.StatusOK, response); err != nil { + return nil, err + } + + definitions := make([]PresentationDefinition, 0) + var data []byte + + if data, err = io.ReadAll(response.Body); err != nil { + return nil, fmt.Errorf("unable to read response: %w", err) + } + if err = json.Unmarshal(data, &definitions); err != nil { + return nil, fmt.Errorf("unable to unmarshal response: %w, %s", err, string(data)) + } + + return definitions, nil +} diff --git a/auth/api/iam/client_test.go b/auth/api/iam/client_test.go index d5ec821202..5e5816f998 100644 --- a/auth/api/iam/client_test.go +++ b/auth/api/iam/client_test.go @@ -97,6 +97,80 @@ func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { }) } +func TestHTTPClient_PresentationDefinition(t *testing.T) { + ctx := context.Background() + definitions := []PresentationDefinition{ + { + Id: "123", + }, + } + + t.Run("ok", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: definitions} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"test"}) + + require.NoError(t, err) + require.NotNil(t, definitions) + assert.Equal(t, definitions, response) + require.NotNil(t, handler.Request) + }) + t.Run("ok - multiple scopes", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: definitions} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"first", "second"}) + + require.NoError(t, err) + require.NotNil(t, definitions) + assert.Equal(t, definitions, response) + require.NotNil(t, handler.Request) + assert.Equal(t, url.Values{"scope": []string{"first", "second"}}, handler.Request.URL.Query()) + }) + + t.Run("error - not found", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusNotFound} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"test"}) + + require.Error(t, err) + assert.EqualError(t, err, "server returned HTTP 404 (expected: 200)") + assert.Nil(t, response) + }) + t.Run("error - invalid URL", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusNotFound} + _, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, ":", []string{"test"}) + + require.Error(t, err) + assert.EqualError(t, err, "parse \":\": missing protocol scheme") + assert.Nil(t, response) + }) + t.Run("error - unknown host", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusNotFound} + _, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, "http://localhost", []string{"test"}) + + require.Error(t, err) + assert.ErrorContains(t, err, "connection refused") + assert.Nil(t, response) + }) + t.Run("error - invalid response", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"test"}) + + require.Error(t, err) + assert.EqualError(t, err, "unable to unmarshal response") + assert.Nil(t, response) + }) +} + func testServerAndClient(t *testing.T, handler http.Handler) (*httptest.Server, *HTTPClient) { tlsServer := http2.TestTLSServer(t, handler) return tlsServer, &HTTPClient{ diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 1aff918daa..fff2f9f763 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -34,6 +34,11 @@ type TokenResponse struct { TokenType string `json:"token_type"` } +// 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"` @@ -143,6 +148,9 @@ 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. + // (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 @@ -181,6 +189,31 @@ 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)) + } + + // 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 @@ -299,6 +332,7 @@ 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) @@ -345,6 +379,32 @@ 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 PresentationDefinition404Response struct { +} + +func (response PresentationDefinition404Response) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + type HandleAuthorizeRequestRequestObject struct { Id string `json:"id"` Params HandleAuthorizeRequestParams @@ -530,6 +590,9 @@ 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. + // (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) @@ -584,6 +647,32 @@ 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 diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go index 21977ea927..8f40164fe0 100644 --- a/auth/api/iam/metadata.go +++ b/auth/api/iam/metadata.go @@ -58,9 +58,10 @@ func authorizationServerMetadata(identity url.URL) OAuthAuthorizationServerMetad TokenEndpoint: identity.JoinPath("token").String(), GrantTypesSupported: grantTypesSupported, PreAuthorizedGrantAnonymousAccessSupported: true, - VPFormats: vpFormatsSupported, - VPFormatsSupported: vpFormatsSupported, - ClientIdSchemesSupported: clientIdSchemesSupported, + PresentationDefinitionEndpoint: identity.JoinPath("presentation_definition").String(), + VPFormats: vpFormatsSupported, + VPFormatsSupported: vpFormatsSupported, + ClientIdSchemesSupported: clientIdSchemesSupported, } } diff --git a/auth/api/iam/metadata_test.go b/auth/api/iam/metadata_test.go index 2a9965c5d2..be8a8c2b64 100644 --- a/auth/api/iam/metadata_test.go +++ b/auth/api/iam/metadata_test.go @@ -83,9 +83,10 @@ func Test_authorizationServerMetadata(t *testing.T) { TokenEndpoint: identity + "/token", GrantTypesSupported: []string{"authorization_code", "vp_token", "urn:ietf:params:oauth:grant-type:pre-authorized_code"}, PreAuthorizedGrantAnonymousAccessSupported: true, - VPFormats: vpFormatsSupported, - VPFormatsSupported: vpFormatsSupported, - ClientIdSchemesSupported: []string{"did"}, + PresentationDefinitionEndpoint: identity + "/presentation_definition", + VPFormats: vpFormatsSupported, + VPFormatsSupported: vpFormatsSupported, + ClientIdSchemesSupported: []string{"did"}, } assert.Equal(t, expected, authorizationServerMetadata(*identityURL)) } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 734a380f40..63a2d69680 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -101,7 +101,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { mockVDR.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) params := map[string]string{ - "scope": "eOverdracht-overdrachtsbericht", + "scope": "test", "response_type": "code", "response_mode": "direct_post", "client_metadata_uri": "https://example.com/client_metadata.xml", diff --git a/auth/api/iam/test/presentation_definition_mapping.json b/auth/api/iam/test/presentation_definition_mapping.json index 4b64377e17..e7a73acc52 100644 --- a/auth/api/iam/test/presentation_definition_mapping.json +++ b/auth/api/iam/test/presentation_definition_mapping.json @@ -1 +1 @@ -{"eOverdracht-overdrachtsbericht":{}} \ No newline at end of file +{"test":{}} \ No newline at end of file diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go index 4a85bce4d5..880e7c63fa 100644 --- a/auth/api/iam/types.go +++ b/auth/api/iam/types.go @@ -20,6 +20,7 @@ package iam import ( "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/types" ) @@ -29,6 +30,9 @@ type DIDDocument = did.Document // DIDDocumentMetadata is an alias type DIDDocumentMetadata = types.DocumentMetadata +// PresentationDefinition is an alias +type PresentationDefinition = pe.PresentationDefinition + const ( // responseTypeParam is the name of the response_type parameter. // Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.1 @@ -200,6 +204,9 @@ type OAuthAuthorizationServerMetadata struct { // See https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-oauth-20-authorization-serv PreAuthorizedGrantAnonymousAccessSupported bool `json:"pre-authorized_grant_anonymous_access_supported,omitempty"` + // PresentationDefinitionEndpoint defines the URL of the authorization server's presentation definition endpoint. + PresentationDefinitionEndpoint string `json:"presentation_definition_endpoint,omitempty"` + // PresentationDefinitionUriSupported specifies whether the Wallet supports the transfer of presentation_definition by reference, with true indicating support. // If omitted, the default value is true. (hence pointer, or add custom unmarshalling) PresentationDefinitionUriSupported *bool `json:"presentation_definition_uri_supported,omitempty"` diff --git a/codegen/configs/auth_iam.yaml b/codegen/configs/auth_iam.yaml index c2d29e3163..f660069bb6 100644 --- a/codegen/configs/auth_iam.yaml +++ b/codegen/configs/auth_iam.yaml @@ -9,3 +9,4 @@ output-options: - DIDDocument - OAuthAuthorizationServerMetadata - OAuthClientMetadata + - PresentationDefinition diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml index ab3091aed0..2fe48daa3f 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -123,6 +123,45 @@ paths: schema: type: string format: uri + "/iam/{did}/presentation_definition": + get: + summary: Used by relying parties to obtain a presentation definition for desired scopes. + description: | + Specified by https://identity.foundation/presentation-exchange/spec/v2.0.0/ + The presentation definition is a JSON object that describes the desired verifiable credentials and presentation formats. + A presentation definition is matched against a wallet. If verifiable credentials matching the definition are found, + a presentation can created and submitted together with a presentation submission. + The API returns an array of definitions, one per scope/backend combination if applicable. + operationId: presentationDefinition + tags: + - oauth2 + parameters: + - name: did + in: path + required: true + schema: + type: string + example: did:nuts:123 + - name: scope + in: query + required: true + schema: + type: array + description: The scope for which a presentation definition is requested. Multiple scopes can be specified by separating them with a space. + items: + type: string + example: patient:x:read + responses: + "200": + description: Authorization request accepted, user is asked for consent. + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/PresentationDefinition" + "404": + description: Unknown DID # TODO: What format to use? (codegenerator breaks on aliases) # See issue https://github.com/nuts-foundation/nuts-node/issues/2365 # create aliases for the specced path @@ -332,6 +371,10 @@ components: OAuth2 Client Metadata Contain properties from several specifications and may grow over time type: object + PresentationDefinition: + description: | + A presentation definition is a JSON object that describes the desired verifiable credentials and presentation formats. + type: object ErrorResponse: type: object required: