diff --git a/auth/api/auth/v1/client/client.go b/auth/api/auth/v1/client/client.go index 5ea160f107..b727a71454 100644 --- a/auth/api/auth/v1/client/client.go +++ b/auth/api/auth/v1/client/client.go @@ -74,7 +74,7 @@ func (h HTTPClient) CreateAccessToken(ctx context.Context, endpointURL url.URL, return nil, err } - if err := core.TestResponseCode(http.StatusOK, response); err != nil { + if err = core.TestResponseCode(http.StatusOK, response); err != nil { rse := err.(core.HttpError) // Cut off the response body to 100 characters max to prevent logging of large responses responseBodyString := string(rse.ResponseBody) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 8c9a9b8db5..9f94cf722a 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -196,7 +196,6 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho // OAuthAuthorizationServerMetadata returns the Authorization Server's metadata func (r Wrapper) OAuthAuthorizationServerMetadata(ctx context.Context, request OAuthAuthorizationServerMetadataRequestObject) (OAuthAuthorizationServerMetadataResponseObject, error) { ownDID := idToDID(request.Id) - owned, err := r.vdr.IsOwner(ctx, ownDID) if err != nil { if resolver.IsFunctionalResolveError(err) { @@ -245,6 +244,23 @@ 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 + // 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 { + return PresentationDefinition400JSONResponse{ + Code: "invalid_scope", + }, nil + } + + return PresentationDefinition200JSONResponse(*presentationDefinition), 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 fd56e2a2c8..fa9119ce76 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -21,6 +21,11 @@ package iam import ( "context" "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "github.com/labstack/echo/v4" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -28,15 +33,12 @@ import ( "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "net/http" - "net/http/httptest" - "net/url" - "testing" ) var nutsDID = did.MustParseDID("did:nuts:123") @@ -159,6 +161,46 @@ 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: "eOverdracht-overdrachtsbericht"}}) + + require.NoError(t, err) + require.NotNil(t, response) + _, ok := response.(PresentationDefinition200JSONResponse) + assert.True(t, ok) + }) + + 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) + _, ok := response.(PresentationDefinition200JSONResponse) + assert.True(t, ok) + }) + + 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: "unknown"}}) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, InvalidScope, (response.(PresentationDefinition400JSONResponse)).Code) + }) +} func TestWrapper_HandleAuthorizeRequest(t *testing.T) { t.Run("missing redirect_uri", func(t *testing.T) { diff --git a/auth/api/iam/client.go b/auth/api/iam/client.go index 801214b215..2478da77fa 100644 --- a/auth/api/iam/client.go +++ b/auth/api/iam/client.go @@ -27,6 +27,8 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/didweb" "io" "net/http" + "net/url" + "strings" ) // HTTPClient holds the server address and other basic settings for the http client @@ -55,9 +57,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 +82,41 @@ func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, webDI return &metadata, nil } + +// PresentationDefinition retrieves the presentation definition from the presentation definition endpoint (as specified by RFC021) for the given scope. +func (hb HTTPClient) PresentationDefinition(ctx context.Context, definitionEndpoint string, scopes []string) (*PresentationDefinition, error) { + presentationDefinitionURL, err := core.ParsePublicURL(definitionEndpoint, hb.config.Strictmode) + if err != nil { + return nil, err + } + presentationDefinitionURL.RawQuery = url.Values{"scope": []string{strings.Join(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 httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil { + rse := httpErr.(core.HttpError) + if ok, oauthErr := TestOAuthErrorCode(rse.ResponseBody, InvalidScope); ok { + return nil, oauthErr + } + return nil, httpErr + } + + var presentationDefinition PresentationDefinition + 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, &presentationDefinition); err != nil { + return nil, fmt.Errorf("unable to unmarshal response: %w", err) + } + + return &presentationDefinition, nil +} diff --git a/auth/api/iam/client_test.go b/auth/api/iam/client_test.go index d5ec821202..cff6e5d166 100644 --- a/auth/api/iam/client_test.go +++ b/auth/api/iam/client_test.go @@ -97,6 +97,87 @@ func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { }) } +func TestHTTPClient_PresentationDefinition(t *testing.T) { + ctx := context.Background() + definition := PresentationDefinition{ + Id: "123", + } + + t.Run("ok", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: definition} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"test"}) + + require.NoError(t, err) + require.NotNil(t, definition) + assert.Equal(t, definition, *response) + require.NotNil(t, handler.Request) + }) + t.Run("ok - multiple scopes", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: definition} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"first", "second"}) + + require.NoError(t, err) + require.NotNil(t, definition) + assert.Equal(t, definition, *response) + require.NotNil(t, handler.Request) + assert.Equal(t, url.Values{"scope": []string{"first second"}}, handler.Request.URL.Query()) + }) + t.Run("error - invalid_scope", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusBadRequest, ResponseData: OAuth2Error{Code: InvalidScope}} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"test"}) + + require.Error(t, err) + assert.EqualError(t, err, "invalid_scope") + assert.Nil(t, response) + }) + 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: invalid character '}' looking for beginning of value") + 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/error.go b/auth/api/iam/error.go index a70c005052..41001fd554 100644 --- a/auth/api/iam/error.go +++ b/auth/api/iam/error.go @@ -19,6 +19,7 @@ package iam import ( + "encoding/json" "errors" "github.com/labstack/echo/v4" "github.com/nuts-foundation/nuts-node/core" @@ -41,6 +42,8 @@ const ( UnsupportedResponseType ErrorCode = "unsupported_response_type" // ServerError is returned when the Authorization Server encounters an unexpected condition that prevents it from fulfilling the request. ServerError ErrorCode = "server_error" + // InvalidScope is returned when the requested scope is invalid, unknown or malformed. + InvalidScope = ErrorCode("invalid_scope") ) // Make sure the error implements core.HTTPStatusCodeError, so the HTTP request logger can log the correct status code. @@ -125,3 +128,13 @@ func (p oauth2ErrorWriter) Write(echoContext echo.Context, _ int, _ string, err redirectURI.RawQuery = query.Encode() return echoContext.Redirect(http.StatusFound, redirectURI.String()) } + +// TestOAuthErrorCode tests if the response is an OAuth2 error with the given code. +// Also returns the unmarshalled OAuth2Error +func TestOAuthErrorCode(responseBody []byte, code ErrorCode) (bool, OAuth2Error) { + var oauthErr OAuth2Error + if err := json.Unmarshal(responseBody, &oauthErr); err != nil { + return false, oauthErr + } + return oauthErr.Code == code, oauthErr +} diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 4acaf152bb..0af21dc2ab 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -28,6 +28,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"` @@ -137,6 +142,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 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 @@ -175,6 +183,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 @@ -293,6 +326,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) @@ -339,6 +373,41 @@ 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 PresentationDefinition400JSONResponse ErrorResponse + +func (response PresentationDefinition400JSONResponse) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + 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 @@ -524,6 +593,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 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) @@ -578,6 +650,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 cf4f193ea3..1dae09d602 100644 --- a/auth/api/iam/metadata.go +++ b/auth/api/iam/metadata.go @@ -52,9 +52,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..c0e9e5fa8e 100644 --- a/auth/api/iam/metadata_test.go +++ b/auth/api/iam/metadata_test.go @@ -52,8 +52,8 @@ func TestIssuerIdToWellKnown(t *testing.T) { assert.Nil(t, u) }) t.Run("no IP allowed", func(t *testing.T) { - issuer := "http://127.0.0.1/iam/id" - u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, false) + issuer := "https://127.0.0.1/iam/id" + u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, true) assert.ErrorContains(t, err, "hostname is IP") assert.Nil(t, u) }) @@ -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/test/presentation_definition_mapping.json b/auth/api/iam/test/presentation_definition_mapping.json index 75e79eca18..319765aca3 100644 --- a/auth/api/iam/test/presentation_definition_mapping.json +++ b/auth/api/iam/test/presentation_definition_mapping.json @@ -3,4 +3,4 @@ "id": "eOverdracht", "input_descriptors": [] } -} \ No newline at end of file +} diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go index 76c128ba28..e633488398 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/resolver" ) @@ -32,6 +33,9 @@ type DIDDocumentMetadata = resolver.DocumentMetadata // ErrorResponse is an alias type ErrorResponse = OAuth2Error +// 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 @@ -203,6 +207,10 @@ 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. + // See https://nuts-foundation.gitbook.io/drafts/rfc/rfc021-vp_token-grant-type + 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/auth/auth_test.go b/auth/auth_test.go index e980e2fc3d..21f1d9a9ae 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -134,7 +134,6 @@ func TestAuth_Configure(t *testing.T) { {false, "", "invalid auth.publicurl: must provide url"}, {false, ":invalid", "invalid auth.publicurl: parse \":invalid\": missing protocol scheme"}, - {false, "https://127.0.0.1", "invalid auth.publicurl: hostname is IP"}, {false, "something://nuts.nl", "invalid auth.publicurl: scheme must be http or https"}, } authCfg := TestConfig() diff --git a/codegen/configs/auth_iam.yaml b/codegen/configs/auth_iam.yaml index 98b1f763ee..0766286caa 100644 --- a/codegen/configs/auth_iam.yaml +++ b/codegen/configs/auth_iam.yaml @@ -10,3 +10,4 @@ output-options: - OAuthAuthorizationServerMetadata - OAuthClientMetadata - ErrorResponse + - PresentationDefinition diff --git a/core/url.go b/core/url.go index e0322cd61e..22544b0892 100644 --- a/core/url.go +++ b/core/url.go @@ -67,7 +67,7 @@ func ParsePublicURLWithScheme(input string, allowReserved bool, allowedSchemes . if len(allowedSchemes) > 0 && !slices.Contains(allowedSchemes, parsed.Scheme) { return nil, fmt.Errorf("scheme must be %s", strings.Join(allowedSchemes, " or ")) } - if net.ParseIP(parsed.Hostname()) != nil { + if net.ParseIP(parsed.Hostname()) != nil && !allowReserved { return nil, errors.New("hostname is IP") } if !allowReserved && isReserved(parsed) { diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml index ab3091aed0..59a5ba762f 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -123,6 +123,47 @@ paths: schema: type: string format: uri + "/iam/{did}/presentation_definition": + get: + summary: Used by relying parties to obtain a presentation definition for desired scopes as specified by Nuts RFC021. + description: | + The presentation definition (specified by https://identity.foundation/presentation-exchange/spec/v2.0.0/) 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 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: string + description: | + The scope for which a presentation definition is requested. Multiple scopes can be specified by separating them with a space. + example: usecase patient:x:read + responses: + "200": + description: PresentationDefinition that matches scope is found. + content: + application/json: + schema: + "$ref": "#/components/schemas/PresentationDefinition" + "400": + description: invalid scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "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 +373,11 @@ 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. + Specified at https://identity.foundation/presentation-exchange/spec/v2.0.0/ + type: object ErrorResponse: type: object required: