Skip to content

Commit

Permalink
replace JWT with secure random code
Browse files Browse the repository at this point in the history
  • Loading branch information
reinkrul committed Oct 3, 2023
1 parent a3d6af4 commit 6c6f0a3
Show file tree
Hide file tree
Showing 8 changed files with 57 additions and 81 deletions.
10 changes: 5 additions & 5 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import (
"github.com/nuts-foundation/nuts-node/auth"
"github.com/nuts-foundation/nuts-node/auth/log"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/storage"
"github.com/nuts-foundation/nuts-node/vcr"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
Expand All @@ -53,23 +53,23 @@ type Wrapper struct {
vcr vcr.VCR
vdr vdr.VDR
auth auth.AuthenticationServices
privateKeyStore crypto.KeyStore
sessions *SessionManager
storageInstance storage.Engine
templates *template.Template
}

func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, privateKeyStore crypto.KeyStore) *Wrapper {
func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, storageInstance storage.Engine) *Wrapper {
templates := template.New("oauth2 templates")
_, err := templates.ParseFS(assets, "assets/*.html")
if err != nil {
panic(err)
}
return &Wrapper{
sessions: &SessionManager{sessions: new(sync.Map)},
storageInstance: storageInstance,
auth: authInstance,
vcr: vcrInstance,
vdr: vdrInstance,
privateKeyStore: privateKeyStore,
templates: templates,
}
}
Expand Down Expand Up @@ -146,7 +146,7 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
}
case "vp_token-bearer":
// Nuts RFC021 vp_token bearer flow
return r.handleS2SAccessTokenRequest(ctx, *ownDID, request.Body.AdditionalProperties)
return r.handleS2SAccessTokenRequest(*ownDID, request.Body.AdditionalProperties)
default:
return nil, OAuth2Error{
Code: UnsupportedGrantType,
Expand Down
17 changes: 9 additions & 8 deletions auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import (
"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/crypto"
"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"
Expand Down Expand Up @@ -166,22 +166,24 @@ func TestWrapper_GetOAuthClientMetadata(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, 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, UnsupportedResponseType, "")
Expand All @@ -192,9 +194,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 @@ -238,7 +241,6 @@ type testCtx struct {
client *Wrapper
authnServices *auth.MockAuthenticationServices
vdr *vdr.MockVDR
keyStore *crypto.MockKeyStore
resolver *resolver.MockDIDResolver
verifier *verifier.MockVerifier
vcr *vcr.MockVCR
Expand All @@ -248,7 +250,7 @@ func newTestClient(t testing.TB) *testCtx {
publicURL, err := url.Parse("https://example.com")
require.NoError(t, err)
ctrl := gomock.NewController(t)
keyStore := crypto.NewMockKeyStore(ctrl)
storageEngine := storage.NewTestStorageEngine(t)
mockVerifier := verifier.NewMockVerifier(ctrl)
mockVCR := vcr.NewMockVCR(ctrl)
mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes()
Expand All @@ -267,14 +269,13 @@ func newTestClient(t testing.TB) *testCtx {
authnServices: authnServices,
resolver: resolver,
vdr: vdr,
keyStore: keyStore,
verifier: mockVerifier,
vcr: mockVCR,
client: &Wrapper{
auth: authnServices,
vdr: vdr,
vcr: mockVCR,
privateKeyStore: keyStore,
storageInstance: storageEngine,
},
}
}
Expand Down
4 changes: 2 additions & 2 deletions auth/api/iam/openid4vp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) {
_ = peStore.LoadFromFile("test/presentation_definition_mapping.json")
mockAuth := auth.NewMockAuthenticationServices(ctrl)
mockAuth.EXPECT().PresentationDefinitions().Return(peStore)
instance := New(mockAuth, nil, nil)
instance := New(mockAuth, nil, nil, nil)

params := map[string]string{
"scope": "unsupported",
Expand All @@ -139,7 +139,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) {
assert.Nil(t, response)
})
t.Run("invalid response_mode", func(t *testing.T) {
instance := New(nil, nil, nil)
instance := New(nil, nil, nil, nil)
params := map[string]string{
"scope": "eOverdracht-overdrachtsbericht",
"response_type": "code",
Expand Down
49 changes: 16 additions & 33 deletions auth/api/iam/s2s_vptoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/lestrrat-go/jwx/jws"
"github.com/lestrrat-go/jwx/jwt"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/core"
Expand All @@ -49,7 +47,7 @@ const scopeCredentialsClaimKey = "vcs"

// handleS2SAccessTokenRequest handles the /token request with vp_token bearer grant type, intended for service-to-service exchanges.
// It performs cheap checks first (parameter presence and validity), then the more expensive ones (checking signatures and matching VCs to the presentation definition).
func (s Wrapper) handleS2SAccessTokenRequest(ctx context.Context, issuer did.DID, params map[string]string) (HandleTokenRequestResponseObject, error) {
func (s Wrapper) handleS2SAccessTokenRequest(issuer did.DID, params map[string]string) (HandleTokenRequestResponseObject, error) {
submissionEncoded := params["presentation_submission"]
scope := params[scopeParam]
assertionEncoded := params["assertion"]
Expand Down Expand Up @@ -146,44 +144,29 @@ func (s Wrapper) handleS2SAccessTokenRequest(ctx context.Context, issuer did.DID
Description: "presentation_submission parameter is invalid: re-evaluation of the credentials against the presentation definition yields different credentials (bug or attempted hack)",
}
}

// TODO: Check that the returned credentials fulfill the presentation definition of the scope.
// TODO: Only include credentials from the presentation submission
response, err := s.createAccessToken(ctx, issuer, issueTime, matchedCredentials, scope)
// All OK, allow access
response, err := s.createAccessToken(issuer, issueTime, vp, scope)
if err != nil {
return nil, fmt.Errorf("creating access token: %w", err)
return nil, err
}
return HandleTokenRequest200JSONResponse(*response), nil
}

func (s Wrapper) createAccessToken(ctx context.Context, issuer did.DID, issueTime time.Time, credentials []vc.VerifiableCredential, scope string) (*TokenResponse, error) {
// Sign with the private key of the issuer
keyResolver := resolver.DIDKeyResolver{Resolver: s.vdr.Resolver()}
signingKeyID, _, err := keyResolver.ResolveKey(issuer, &issueTime, resolver.NutsSigningKeyType)
if err != nil {
return nil, fmt.Errorf("resolve issuer signing key: %w", err)
}
// TODO: The JWT becomes quite large due to inclusion of the complete VCs;
// do we need to have an opaque token with backend storage of tokens?
claims := map[string]interface{}{
jwt.IssuedAtKey: issueTime.Unix(),
jwt.IssuerKey: issuer.String(),
jwt.ExpirationKey: issueTime.Add(accessTokenValidity).Unix(),
jwt.NotBeforeKey: issueTime.Unix(),
scopeJWTClaimKey: scope,
scopeCredentialsClaimKey: credentials,
}
headers := map[string]interface{}{
jws.KeyIDKey: signingKeyID.String(),
}
token, err := s.privateKeyStore.SignJWT(ctx, claims, headers, signingKeyID.String())
func (s Wrapper) createAccessToken(issuer did.DID, issueTime time.Time, presentation vc.VerifiablePresentation, scope string) (*TokenResponse, error) {
accessToken := AccessToken{
Token: generateCode(),
Issuer: issuer.String(),
Expiration: issueTime.Add(accessTokenValidity),
Presentation: presentation,
}
err := s.storageInstance.GetSessionDatabase().GetStore(accessTokenValidity, issuer.String(), "accesstoken").Put(accessToken.Token, accessToken)
if err != nil {
return nil, fmt.Errorf("sign token: %w", err)
return nil, fmt.Errorf("unable to store access token: %w", err)
}
expiredIn := int(accessTokenValidity.Seconds())
expiresIn := int(accessTokenValidity.Seconds())
return &TokenResponse{
AccessToken: token,
ExpiresIn: &expiredIn,
AccessToken: accessToken.Token,
ExpiresIn: &expiresIn,
Scope: &scope,
TokenType: "bearer",
}, nil
Expand Down
45 changes: 14 additions & 31 deletions auth/api/iam/s2s_vptoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,21 @@
package iam

import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
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/audit"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"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"
"net/url"
"testing"
"time"

"github.com/nuts-foundation/go-did/did"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestWrapper_RequestAccessToken(t *testing.T) {
Expand Down Expand Up @@ -103,7 +99,6 @@ func TestWrapper_RequestAccessToken(t *testing.T) {
func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
issuerDID := did.MustParseDID("did:test:123")
const requestedScope = "eOverdracht-overdrachtsbericht"
const expectedAccessToken = "access-token"
// Create issuer DID document and keys
keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
issuerDIDDocument := did.Document{
Expand Down Expand Up @@ -139,34 +134,22 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
t.Run("ok", func(t *testing.T) {
ctx := newTestClient(t)
ctx.verifier.EXPECT().VerifyVP(verifiablePresentation, true, false, gomock.Any()).Return(verifiablePresentation.VerifiableCredential, nil)
ctx.resolver.EXPECT().Resolve(issuerDID, gomock.Any()).Return(&issuerDIDDocument, &resolver.DocumentMetadata{}, nil)
var actualClaims map[string]interface{}
var actualHeaders map[string]interface{}
ctx.keyStore.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), keyID.String()).
DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, _ interface{}) (string, error) {
actualClaims = claims
actualHeaders = headers
return expectedAccessToken, nil
})

params := map[string]string{
"assertion": url.QueryEscape(string(verifiablePresentationJSON)),
"presentation_submission": url.QueryEscape(string(presentationSubmissionJSON)),
"scope": requestedScope,
}

resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params)
resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

require.NoError(t, err)
require.IsType(t, HandleTokenRequest200JSONResponse{}, resp)
tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse))
assert.Equal(t, "bearer", tokenResponse.TokenType)
assert.Equal(t, requestedScope, *tokenResponse.Scope)
assert.Equal(t, int(accessTokenValidity.Seconds()), *tokenResponse.ExpiresIn)
assert.Equal(t, expectedAccessToken, tokenResponse.AccessToken)
assert.Equal(t, keyID.String(), actualHeaders["kid"])
// Make sure token is not valid for too long
assert.GreaterOrEqual(t, time.Now().Add(accessTokenValidity).Add(time.Minute).Unix(), actualClaims["exp"])
assert.NotEmpty(t, tokenResponse.AccessToken)
})
t.Run("VP is in JWT format (not supported)", func(t *testing.T) {
ctx := newTestClient(t)
Expand All @@ -176,7 +159,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
"scope": requestedScope,
}

resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params)
resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

assert.EqualError(t, err, "TODO: VPs in JWT format not yet supported")
assert.Nil(t, resp)
Expand All @@ -189,9 +172,9 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
"scope": requestedScope,
}

resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params)
resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

assert.EqualError(t, err, "assertion parameter is invalid: json: cannot unmarshal array into Go value of type map[string]interface {}")
assert.EqualError(t, err, "invalid_request - json: cannot unmarshal array into Go value of type map[string]interface {} - assertion parameter is invalid: invalid JSON")
assert.Nil(t, resp)
})
t.Run("submission is not valid JSON", func(t *testing.T) {
Expand All @@ -202,9 +185,9 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
"scope": requestedScope,
}

resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params)
resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

assert.EqualError(t, err, `presentation_submission parameter is invalid: invalid character 'o' in literal null (expecting 'u')`)
assert.EqualError(t, err, `invalid_request - invalid character 'o' in literal null (expecting 'u') - presentation_submission parameter is invalid: invalid JSON`)
assert.Nil(t, resp)
})
t.Run("unsupported scope", func(t *testing.T) {
Expand All @@ -216,9 +199,9 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
"scope": "everything",
}

resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params)
resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

assert.EqualError(t, err, `unsupported scope for presentation exchange: everything`)
assert.EqualError(t, err, `invalid_scope - unsupported scope for presentation exchange: everything`)
assert.Nil(t, resp)
})
t.Run("re-evaluation of presentation definition yields different credentials", func(t *testing.T) {
Expand Down Expand Up @@ -246,9 +229,9 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
"scope": requestedScope,
}

resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params)
resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, params)

assert.EqualError(t, err, "presentation_submission parameter is invalid: re-evaluation of the credentials against the presentation definition yields different credentials (bug or attempted hack)")
assert.EqualError(t, err, "invalid_request - presentation_submission parameter is invalid: re-evaluation of the credentials against the presentation definition yields different credentials (bug or attempted hack)")
assert.Nil(t, resp)
})
}
9 changes: 9 additions & 0 deletions auth/api/iam/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ package iam

import (
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"time"
)

// DIDDocument is an alias
Expand All @@ -32,6 +34,13 @@ type DIDDocumentMetadata = resolver.DocumentMetadata
// ErrorResponse is an alias
type ErrorResponse = OAuth2Error

type AccessToken struct {
Token string
Issuer string
Expiration time.Time
Presentation vc.VerifiablePresentation
}

const (
// responseTypeParam is the name of the response_type parameter.
// Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.1
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System {
system.RegisterRoutes(statusEngine.(core.Routable))
system.RegisterRoutes(metricsEngine.(core.Routable))
system.RegisterRoutes(&authAPIv1.Wrapper{Auth: authInstance, CredentialResolver: credentialInstance})
system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance, cryptoInstance))
system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance, storageInstance))
system.RegisterRoutes(&authMeansAPI.Wrapper{Auth: authInstance})
system.RegisterRoutes(&didmanAPI.Wrapper{Didman: didmanInstance})

Expand Down
2 changes: 1 addition & 1 deletion storage/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func NewTestStorageEngineInDir(dir string) Engine {
return result
}

func NewTestStorageEngine(t *testing.T) Engine {
func NewTestStorageEngine(t testing.TB) Engine {
oldOpts := append(DefaultBBoltOptions[:])
t.Cleanup(func() {
DefaultBBoltOptions = oldOpts
Expand Down

0 comments on commit 6c6f0a3

Please sign in to comment.