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

Add access token request call for the OpenID4VP user flow #2619

Merged
merged 4 commits into from
Dec 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ func (r *Wrapper) Routes(router core.EchoRouter) {
// - POST handles the form submission, initiating the flow.
router.GET("/iam/:did/openid4vp_demo", r.handleOpenID4VPDemoLanding, auditMiddleware)
router.POST("/iam/:did/openid4vp_demo", r.handleOpenID4VPDemoSendRequest, auditMiddleware)
// The following handlers are used for the user facing OAuth2 flows.
router.GET("/iam/:did/user", r.handleUserLanding, auditMiddleware)
}

func (r Wrapper) middleware(ctx echo.Context, request interface{}, operationID string, f StrictHandlerFunc) (interface{}, error) {
Expand Down Expand Up @@ -375,8 +377,32 @@ func (r Wrapper) idToOwnedDID(ctx context.Context, id string) (*did.DID, error)
return &ownDID, nil
}

func createSession(params map[string]string, ownDID did.DID) *Session {
session := &Session{
func (r Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) {
if request.Body == nil {
// why did oapi-codegen generate a pointer for the body??
return nil, core.InvalidInputError("missing request body")
}
// resolve wallet
requestHolder, err := did.ParseDID(request.Did)
if err != nil {
return nil, core.NotFoundError("did not found: %w", err)
}
isWallet, err := r.vdr.IsOwner(ctx, *requestHolder)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use idToOwnedDID (or something)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API does not use the id from the path but a DID from the request body.

if err != nil {
return nil, err
}
if !isWallet {
return nil, core.InvalidInputError("did not owned by this node: %w", err)
}
if request.Body.UserID != nil && len(*request.Body.UserID) > 0 {
// forward to user flow
return r.requestUserAccessToken(ctx, *requestHolder, request)
}
return r.requestServiceAccessToken(ctx, *requestHolder, request)
}

func createSession(params map[string]string, ownDID did.DID) *OAuthSession {
session := &OAuthSession{
// TODO: Validate client ID
ClientID: params[clientIDParam],
// TODO: Validate scope
Expand Down
55 changes: 54 additions & 1 deletion auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"net/http/httptest"
"net/url"
"testing"
"time"

"github.com/labstack/echo/v4"
"github.com/nuts-foundation/go-did/did"
Expand All @@ -45,7 +46,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"time"
)

var webDID = did.MustParseDID("did:web:example.com:iam:123")
Expand Down Expand Up @@ -350,6 +350,7 @@ func statusCodeFrom(err error) int {
}

type testCtx struct {
ctrl *gomock.Controller
client *Wrapper
authnServices *auth.MockAuthenticationServices
vdr *vdr.MockVDR
Expand Down Expand Up @@ -382,6 +383,7 @@ func newTestClient(t testing.TB) *testCtx {
vdr.EXPECT().Resolver().Return(resolver).AnyTimes()

return &testCtx{
ctrl: ctrl,
authnServices: authnServices,
relyingParty: relyingPary,
resolver: resolver,
Expand Down Expand Up @@ -488,6 +490,57 @@ func TestWrapper_idToOwnedDID(t *testing.T) {
})
}

func TestWrapper_RequestAccessToken(t *testing.T) {
walletDID := did.MustParseDID("did:web:test.test:iam:123")
verifierDID := did.MustParseDID("did:web:test.test:iam:456")
body := &RequestAccessTokenJSONRequestBody{Verifier: verifierDID.String(), Scope: "first second"}

t.Run("ok - service flow", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil)
ctx.resolver.EXPECT().Resolve(verifierDID, nil).Return(&did.Document{}, &resolver.DocumentMetadata{}, nil)
ctx.relyingParty.EXPECT().RequestRFC021AccessToken(nil, walletDID, verifierDID, "first second").Return(&oauth.TokenResponse{}, nil)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})

require.NoError(t, err)
})
t.Run("ok - user flow", func(t *testing.T) {
userID := "test"
body := &RequestAccessTokenJSONRequestBody{Verifier: verifierDID.String(), Scope: "first second", UserID: &userID}
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})

require.NoError(t, err)
})
t.Run("error - DID not owned", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(false, nil)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})

require.Error(t, err)
assert.ErrorContains(t, err, "not owned by this node")
})
t.Run("error - invalid DID", func(t *testing.T) {
ctx := newTestClient(t)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: "invalid", Body: body})

require.EqualError(t, err, "did not found: invalid DID")
})
t.Run("error - missing request body", func(t *testing.T) {
ctx := newTestClient(t)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String()})

require.Error(t, err)
assert.EqualError(t, err, "missing request body")
})
}

type strictServerCallCapturer bool

func (s *strictServerCallCapturer) handle(ctx echo.Context, request interface{}) (response interface{}, err error) {
Expand Down
25 changes: 23 additions & 2 deletions auth/api/iam/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 9 additions & 7 deletions auth/api/iam/openid4vp.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,20 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"

"github.com/google/uuid"
"github.com/labstack/echo/v4"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
httpNuts "github.com/nuts-foundation/nuts-node/http"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vcr/holder"
"net/http"
"net/url"
"strings"
"time"
)

const sessionExpiry = 5 * time.Minute
Expand All @@ -55,15 +57,15 @@ func (r *Wrapper) sendPresentationRequest(ctx context.Context, response http.Res
params[responseTypeParam] = responseTypeVPIDToken
// TODO: Depending on parameter size, we either use redirect with query parameters or a form post.
// For simplicity, we now just query parameters.
result := AddQueryParams(*authzEndpoint, params)
result := httpNuts.AddQueryParams(*authzEndpoint, params)
response.Header().Add("Location", result.String())
response.WriteHeader(http.StatusFound)
return nil
}

// handlePresentationRequest handles an Authorization Request as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html.
// It is handled by a wallet, called by a verifier who wants the wallet to present one or more verifiable credentials.
func (r *Wrapper) handlePresentationRequest(params map[string]string, session *Session) (HandleAuthorizeRequestResponseObject, error) {
func (r *Wrapper) handlePresentationRequest(params map[string]string, session *OAuthSession) (HandleAuthorizeRequestResponseObject, error) {
ctx := context.TODO()
// Presentation definition is always derived from the scope.
// Later on, we might support presentation_definition and/or presentation_definition_uri parameters instead of scope as well.
Expand Down Expand Up @@ -179,7 +181,7 @@ func (r *Wrapper) handlePresentationRequestAccept(c echo.Context) error {
return errors.New("missing sessionID parameter")
}

var session Session
var session OAuthSession
sessionStore := r.storageEngine.GetSessionDatabase().GetStore(sessionExpiry, "openid", session.OwnDID.String(), "session")
err := sessionStore.Get(sessionID, &session)
if err != nil {
Expand Down
21 changes: 2 additions & 19 deletions auth/api/iam/s2s_vptoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,24 +106,7 @@ func (r *Wrapper) handleS2SAccessTokenRequest(issuer did.DID, scope string, subm
return HandleTokenRequest200JSONResponse(*response), nil
}

func (r *Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) {
if request.Body == nil {
// why did oapi-codegen generate a pointer for the body??
return nil, core.InvalidInputError("missing request body")
}
// resolve wallet
requestHolder, err := did.ParseDID(request.Did)
if err != nil {
return nil, core.NotFoundError("did not found: %w", err)
}
isWallet, err := r.vdr.IsOwner(ctx, *requestHolder)
if err != nil {
return nil, err
}
if !isWallet {
return nil, core.InvalidInputError("did not owned by this node: %w", err)
}

func (r Wrapper) requestServiceAccessToken(ctx context.Context, requestHolder did.DID, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) {
// resolve verifier metadata
requestVerifier, err := did.ParseDID(request.Body.Verifier)
if err != nil {
Expand All @@ -137,7 +120,7 @@ func (r *Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessT
return nil, err
}

tokenResult, err := r.auth.RelyingParty().RequestRFC021AccessToken(ctx, *requestHolder, *requestVerifier, request.Body.Scope)
tokenResult, err := r.auth.RelyingParty().RequestRFC021AccessToken(ctx, requestHolder, *requestVerifier, request.Body.Scope)
if err != nil {
// this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials
return nil, err
Expand Down
58 changes: 10 additions & 48 deletions auth/api/iam/s2s_vptoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,98 +19,60 @@
package iam

import (
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/nuts-foundation/nuts-node/vcr/signature/proof"
"net/http"
"testing"
"time"

"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"errors"
"net/http"
"testing"
"time"

"github.com/lestrrat-go/jwx/v2/jwt"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/jsonld"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vcr/signature/proof"
"github.com/nuts-foundation/nuts-node/vcr/test"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)

func TestWrapper_RequestAccessToken(t *testing.T) {
func TestWrapper_requestServiceAccessToken(t *testing.T) {
walletDID := did.MustParseDID("did:test:123")
verifierDID := did.MustParseDID("did:test:456")
body := &RequestAccessTokenJSONRequestBody{Verifier: verifierDID.String(), Scope: "first second"}

t.Run("ok", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil)
ctx.resolver.EXPECT().Resolve(verifierDID, nil).Return(&did.Document{}, &resolver.DocumentMetadata{}, nil)
ctx.relyingParty.EXPECT().RequestRFC021AccessToken(nil, walletDID, verifierDID, "first second").Return(&oauth.TokenResponse{}, nil)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})

require.NoError(t, err)
})
t.Run("error - DID not owned", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(false, nil)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})

require.Error(t, err)
assert.ErrorContains(t, err, "not owned by this node")
})
t.Run("error - invalid DID", func(t *testing.T) {
ctx := newTestClient(t)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: "invalid", Body: body})

require.EqualError(t, err, "did not found: invalid DID")
})
t.Run("error - missing request body", func(t *testing.T) {
ctx := newTestClient(t)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String()})

require.Error(t, err)
assert.EqualError(t, err, "missing request body")
})
t.Run("error - invalid verifier did", func(t *testing.T) {
ctx := newTestClient(t)
body := &RequestAccessTokenJSONRequestBody{Verifier: "invalid"}
ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})
_, err := ctx.client.requestServiceAccessToken(nil, walletDID, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})

require.Error(t, err)
assert.EqualError(t, err, "invalid verifier: invalid DID")
})
t.Run("error - verifier not found", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil)
ctx.resolver.EXPECT().Resolve(verifierDID, nil).Return(nil, nil, resolver.ErrNotFound)

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})
_, err := ctx.client.requestServiceAccessToken(nil, walletDID, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})

require.Error(t, err)
assert.EqualError(t, err, "verifier not found: unable to find the DID document")
})
t.Run("error - verifier error", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil)
ctx.resolver.EXPECT().Resolve(verifierDID, nil).Return(&did.Document{}, &resolver.DocumentMetadata{}, nil)
ctx.relyingParty.EXPECT().RequestRFC021AccessToken(nil, walletDID, verifierDID, "first second").Return(nil, core.Error(http.StatusPreconditionFailed, "no matching credentials"))

_, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})
_, err := ctx.client.requestServiceAccessToken(nil, walletDID, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body})

require.Error(t, err)
assert.EqualError(t, err, "no matching credentials")
Expand Down
Loading
Loading