Skip to content

Commit

Permalink
IAM: Handle /token requests with vp_token bearer grant type
Browse files Browse the repository at this point in the history
  • Loading branch information
reinkrul committed Nov 24, 2023
1 parent b7bf822 commit 1981064
Show file tree
Hide file tree
Showing 20 changed files with 1,046 additions and 142 deletions.
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ The following options can be configured on the server:
http.default.auth.type Whether to enable authentication for the default interface, specify 'token_v2' for bearer token mode or 'token' for legacy bearer token mode.
http.default.cors.origin [] When set, enables CORS from the specified origins on the default HTTP interface.
**JSONLD**
jsonld.contexts.localmapping [https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist.
jsonld.contexts.localmapping [https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson] This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist.
jsonld.contexts.remoteallowlist [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json] In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here.
**Network**
network.bootstrapnodes [] List of bootstrap nodes ('<host>:<port>') which the node initially connect to.
Expand Down
37 changes: 35 additions & 2 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"embed"
"encoding/json"
"errors"
"fmt"
"github.com/labstack/echo/v4"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/nuts-node/audit"
Expand Down Expand Up @@ -116,6 +117,11 @@ func (r Wrapper) middleware(ctx echo.Context, request interface{}, operationID s

// HandleTokenRequest handles calls to the token endpoint for exchanging a grant (e.g authorization code or pre-authorized code) for an access token.
func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) {
ownDID, err := r.idToOwnedDID(ctx, request.Id)
if err != nil {
return nil, err
}

switch request.Body.GrantType {
case "authorization_code":
// Options:
Expand All @@ -139,6 +145,9 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
Code: oauth.UnsupportedGrantType,
Description: "not implemented yet",
}
case "vp_token-bearer":
// Nuts RFC021 vp_token bearer flow
return r.handleS2SAccessTokenRequest(*ownDID, request.Body.AdditionalProperties)
default:
return nil, oauth.OAuth2Error{
Code: oauth.UnsupportedGrantType,
Expand Down Expand Up @@ -226,7 +235,10 @@ func toAnyMap(input any) (*map[string]any, error) {

// HandleAuthorizeRequest handles calls to the authorization endpoint for starting an authorization code flow.
func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAuthorizeRequestRequestObject) (HandleAuthorizeRequestResponseObject, error) {
ownDID := idToDID(request.Id)
ownDID, err := r.idToOwnedDID(ctx, request.Id)
if err != nil {
return nil, err
}
// Create session object to be passed to handler

// Workaround: deepmap codegen doesn't support dynamic query parameters.
Expand All @@ -236,7 +248,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
for key, value := range httpRequest.URL.Query() {
params[key] = value[0]
}
session := createSession(params, ownDID)
session := createSession(params, *ownDID)
if session.RedirectURI == "" {
// TODO: Spec says that the redirect URI is optional, but it's not clear what to do if it's not provided.
// Threat models say it's unsafe to omit redirect_uri.
Expand Down Expand Up @@ -343,6 +355,27 @@ func (r Wrapper) PresentationDefinition(_ context.Context, request PresentationD
return PresentationDefinition200JSONResponse(*presentationDefinition), nil
}

func (r Wrapper) idToOwnedDID(ctx context.Context, id string) (*did.DID, error) {
ownDID := idToDID(id)
owned, err := r.vdr.IsOwner(ctx, ownDID)
if err != nil {
if resolver.IsFunctionalResolveError(err) {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "invalid issuer DID: " + err.Error(),
}
}
return nil, fmt.Errorf("DID resolution failed: %w", err)
}
if !owned {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "issuer DID not owned by the server",
}
}
return &ownDID, nil
}

func createSession(params map[string]string, ownDID did.DID) *Session {
session := &Session{
// TODO: Validate client ID
Expand Down
60 changes: 55 additions & 5 deletions auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ import (
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/jsonld"
"github.com/nuts-foundation/nuts-node/storage"
"github.com/nuts-foundation/nuts-node/vcr"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vcr/verifier"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -175,7 +177,6 @@ func TestWrapper_PresentationDefinition(t *testing.T) {

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"}})

Expand All @@ -198,7 +199,6 @@ func TestWrapper_PresentationDefinition(t *testing.T) {

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"}})

Expand All @@ -211,22 +211,24 @@ func TestWrapper_PresentationDefinition(t *testing.T) {
func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
t.Run("missing redirect_uri", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(gomock.Any(), nutsDID).Return(true, nil)

res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{}), HandleAuthorizeRequestRequestObject{
Id: nutsDID.String(),
Id: nutsDID.ID,
})

requireOAuthError(t, err, oauth.InvalidRequest, "redirect_uri is required")
assert.Nil(t, res)
})
t.Run("unsupported response type", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(gomock.Any(), nutsDID).Return(true, nil)

res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{
"redirect_uri": "https://example.com",
"response_type": "unsupported",
}), HandleAuthorizeRequestRequestObject{
Id: nutsDID.String(),
Id: nutsDID.ID,
})

requireOAuthError(t, err, oauth.UnsupportedResponseType, "")
Expand All @@ -237,9 +239,10 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
func TestWrapper_HandleTokenRequest(t *testing.T) {
t.Run("unsupported grant type", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(gomock.Any(), nutsDID).Return(true, nil)

res, err := ctx.client.HandleTokenRequest(nil, HandleTokenRequestRequestObject{
Id: nutsDID.String(),
Id: nutsDID.ID,
Body: &HandleTokenRequestFormdataRequestBody{
GrantType: "unsupported",
},
Expand Down Expand Up @@ -372,31 +375,43 @@ type testCtx struct {
vdr *vdr.MockVDR
resolver *resolver.MockDIDResolver
relyingParty *oauthServices.MockRelyingParty
verifier *verifier.MockVerifier
vcr *vcr.MockVCR
}

func newTestClient(t testing.TB) *testCtx {
publicURL, err := url.Parse("https://example.com")
require.NoError(t, err)
ctrl := gomock.NewController(t)
storageEngine := storage.NewTestStorageEngine(t)
mockVerifier := verifier.NewMockVerifier(ctrl)
mockVCR := vcr.NewMockVCR(ctrl)
mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes()
authnServices := auth.NewMockAuthenticationServices(ctrl)
authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes()
authnServices.EXPECT().PresentationDefinitions().Return(pe.TestDefinitionResolver(t)).AnyTimes()
resolver := resolver.NewMockDIDResolver(ctrl)
relyingPary := oauthServices.NewMockRelyingParty(ctrl)
verifier := verifier.NewMockVerifier(ctrl)
vdr := vdr.NewMockVDR(ctrl)
vcr := vcr.NewMockVCR(ctrl)

authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes()
authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes()
vcr.EXPECT().Verifier().Return(verifier).AnyTimes()
vdr.EXPECT().Resolver().Return(resolver).AnyTimes()

return &testCtx{
authnServices: authnServices,
relyingParty: relyingPary,
resolver: resolver,
vdr: vdr,
verifier: mockVerifier,
vcr: mockVCR,
client: &Wrapper{
auth: authnServices,
vdr: vdr,
vcr: mockVCR,
storageEngine: storageEngine,
},
}
Expand Down Expand Up @@ -458,6 +473,41 @@ func TestWrapper_middleware(t *testing.T) {

}

func TestWrapper_idToOwnedDID(t *testing.T) {
t.Run("ok", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(true, nil)

_, err := ctx.client.idToOwnedDID(nil, nutsDID.ID)

assert.NoError(t, err)
})
t.Run("error - did not managed by this node", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, nutsDID)

_, err := ctx.client.idToOwnedDID(nil, nutsDID.ID)

assert.EqualError(t, err, "invalid_request - issuer DID not owned by the server")
})
t.Run("DID does not exist (functional resolver error)", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(false, resolver.ErrNotFound)

_, err := ctx.client.idToOwnedDID(nil, nutsDID.ID)

assert.EqualError(t, err, "invalid_request - invalid issuer DID: unable to find the DID document")
})
t.Run("other resolver error", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(false, errors.New("unknown error"))

_, err := ctx.client.idToOwnedDID(nil, nutsDID.ID)

assert.EqualError(t, err, "DID resolution failed: unknown error")
})
}

type strictServerCallCapturer bool

func (s *strictServerCallCapturer) handle(ctx echo.Context, request interface{}) (response interface{}, err error) {
Expand Down
4 changes: 1 addition & 3 deletions auth/api/iam/openid4vp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,12 @@ func TestWrapper_handlePresentationRequest(t *testing.T) {
}
t.Run("with scope", func(t *testing.T) {
ctrl := gomock.NewController(t)
peStore := &pe.DefinitionResolver{}
require.NoError(t, peStore.LoadFromFile("test/presentation_definition_mapping.json"))
mockVDR := vdr.NewMockVDR(ctrl)
mockVCR := vcr.NewMockVCR(ctrl)
mockWallet := holder.NewMockWallet(ctrl)
mockVCR.EXPECT().Wallet().Return(mockWallet)
mockAuth := auth.NewMockAuthenticationServices(ctrl)
mockAuth.EXPECT().PresentationDefinitions().Return(peStore)
mockAuth.EXPECT().PresentationDefinitions().Return(pe.TestDefinitionResolver(t))
mockWallet.EXPECT().List(gomock.Any(), holderDID).Return(walletCredentials, nil)
mockVDR.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil)
instance := New(mockAuth, mockVCR, mockVDR, storage.NewTestStorageEngine(t))
Expand Down
Loading

0 comments on commit 1981064

Please sign in to comment.