Skip to content

Commit

Permalink
added presentation_definition endpoint (#2492)
Browse files Browse the repository at this point in the history
  • Loading branch information
woutslakhorst authored Oct 26, 2023
1 parent db42d97 commit 6d4bef3
Show file tree
Hide file tree
Showing 15 changed files with 366 additions and 20 deletions.
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) {
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 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
}
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
13 changes: 13 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 All @@ -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.
Expand Down Expand Up @@ -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
}
Loading

0 comments on commit 6d4bef3

Please sign in to comment.