diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index a59f44b9ea..12cbd375a1 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -288,6 +288,24 @@ func (r Wrapper) validateAsNutsFingerprint(ctx context.Context, fingerprint stri return 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 6d33e041a9..038c8539dd 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" @@ -214,6 +215,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/generated.go b/auth/api/iam/generated.go index dcc1c883b4..40af422a51 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -39,6 +39,11 @@ 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 { Code string `form:"code" json:"code"` @@ -149,6 +154,9 @@ type ServerInterface interface { // Returns the did:web version of a Nuts DID document // (GET /iam/{did}/did.json) GetWebDID(ctx echo.Context, did 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 to request access- or refresh tokens. // (POST /iam/{did}/token) HandleTokenRequest(ctx echo.Context, did string) error @@ -222,6 +230,31 @@ func (w *ServerInterfaceWrapper) GetWebDID(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 "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 +} + // HandleTokenRequest converts echo context to params. func (w *ServerInterfaceWrapper) HandleTokenRequest(ctx echo.Context) error { var err error @@ -301,6 +334,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/.well-known/oauth-authorization-server/iam/:did", wrapper.GetOAuthAuthorizationServerMetadata) router.GET(baseURL+"/iam/:did/authorize", wrapper.HandleAuthorizeRequest) router.GET(baseURL+"/iam/:did/did.json", wrapper.GetWebDID) + router.GET(baseURL+"/iam/:did/presentation_definition", wrapper.PresentationDefinition) router.POST(baseURL+"/iam/:did/token", wrapper.HandleTokenRequest) router.GET(baseURL+"/iam/:id/oauth-client", wrapper.GetOAuthClientMetadata) router.POST(baseURL+"/internal/auth/v2/:did/request-access-token", wrapper.RequestAccessToken) @@ -412,6 +446,32 @@ func (response GetWebDID404Response) VisitGetWebDIDResponse(w http.ResponseWrite return nil } +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 HandleTokenRequestRequestObject struct { Did string `json:"did"` Body *HandleTokenRequestFormdataRequestBody @@ -536,6 +596,9 @@ type StrictServerInterface interface { // Returns the did:web version of a Nuts DID document // (GET /iam/{did}/did.json) GetWebDID(ctx context.Context, request GetWebDIDRequestObject) (GetWebDIDResponseObject, 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 to request access- or refresh tokens. // (POST /iam/{did}/token) HandleTokenRequest(ctx context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) @@ -635,6 +698,32 @@ func (sh *strictHandler) GetWebDID(ctx echo.Context, did string) error { 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 +} + // HandleTokenRequest operation middleware func (sh *strictHandler) HandleTokenRequest(ctx echo.Context, did string) error { var request HandleTokenRequestRequestObject 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/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 95383f3e6d..a401cd0906 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -124,6 +124,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 @@ -333,6 +372,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: