Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added presentation_definition endpoint #2492

Merged
merged 13 commits into from
Oct 26, 2023
2 changes: 1 addition & 1 deletion auth/api/auth/v1/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 17 additions & 1 deletion auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
gerardsn marked this conversation as resolved.
Show resolved Hide resolved
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{
Expand Down
50 changes: 46 additions & 4 deletions auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,24 @@ 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"
"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/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")
Expand Down Expand Up @@ -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) {
Expand Down
46 changes: 43 additions & 3 deletions auth/api/iam/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 TestOAuthErrorCode(rse.ResponseBody, InvalidScope) {
return nil, ErrInvalidScope
}
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
}
81 changes: 81 additions & 0 deletions auth/api/iam/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
14 changes: 14 additions & 0 deletions auth/api/iam/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
package iam

import (
"encoding/json"
"errors"
"github.com/labstack/echo/v4"
"github.com/nuts-foundation/nuts-node/core"
Expand Down Expand Up @@ -125,3 +126,16 @@ func (p oauth2ErrorWriter) Write(echoContext echo.Context, _ int, _ string, err
redirectURI.RawQuery = query.Encode()
return echoContext.Redirect(http.StatusFound, redirectURI.String())
}

const InvalidScope = ErrorCode("invalid_scope")
woutslakhorst marked this conversation as resolved.
Show resolved Hide resolved

var ErrInvalidScope = errors.New("invalid scope")
woutslakhorst marked this conversation as resolved.
Show resolved Hide resolved

// TestOAuthErrorCode tests if the response is an OAuth2 error with the given code.
func TestOAuthErrorCode(responseBody []byte, code ErrorCode) bool {
var oauthErr OAuth2Error
if err := json.Unmarshal(responseBody, &oauthErr); err != nil {
return false
}
return oauthErr.Code == code
}
Loading
Loading