From d466a36347082ee2cd9e465228b1ed109936d9f3 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 23 May 2024 10:33:46 +0200 Subject: [PATCH 1/3] IAM: Refactor session management to middleware --- auth/api/iam/api.go | 35 ++- auth/api/iam/api_test.go | 18 +- auth/api/iam/openid4vp.go | 5 +- auth/api/iam/openid4vp_test.go | 25 +- auth/api/iam/session.go | 39 --- auth/api/iam/session_test.go | 19 -- auth/api/iam/types.go | 9 - auth/api/iam/user.go | 154 ++-------- auth/api/iam/user_test.go | 194 ++----------- auth/api/iam/usersession/data.go | 65 +++++ auth/api/iam/usersession/data_test.go | 45 +++ auth/api/iam/usersession/test.go | 33 +++ auth/api/iam/usersession/user_session.go | 204 +++++++++++++ auth/api/iam/usersession/user_session_test.go | 268 ++++++++++++++++++ auth/api/iam/usersession/util.go | 28 ++ docs/_static/auth/iam.partial.yaml | 4 +- 16 files changed, 721 insertions(+), 424 deletions(-) create mode 100644 auth/api/iam/usersession/data.go create mode 100644 auth/api/iam/usersession/data_test.go create mode 100644 auth/api/iam/usersession/test.go create mode 100644 auth/api/iam/usersession/user_session.go create mode 100644 auth/api/iam/usersession/user_session_test.go create mode 100644 auth/api/iam/usersession/util.go diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 6e4944d54d..29729d9182 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -27,6 +27,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" "html/template" "net/http" "net/url" @@ -73,17 +74,6 @@ const accessTokenValidity = 15 * time.Minute const oid4vciSessionValidity = 15 * time.Minute -// userSessionCookieName is the name of the cookie used to store the user session. -// It uses the __Host prefix, that instructs the user agent to treat it as a secure cookie: -// - Must be set with the Secure attribute -// - Must be set from an HTTPS uri -// - Must not contain a Domain attribute -// - Must contain a Path attribute -// Also see: -// - https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/02-Testing_for_Cookies_Attributes -// - https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies -const userSessionCookieName = "__Host-SID" - //go:embed assets var assetsFS embed.FS @@ -143,6 +133,29 @@ func (r Wrapper) Routes(router core.EchoRouter) { return next(c) } }, audit.Middleware(apiModuleName)) + router.Use(usersession.Middleware{ + Skipper: func(c echo.Context) bool { + // The following URLs require a user session: + paths := []string{ + "/oauth2/:did/user", + "/oauth2/:did/authorize", + "/oauth2/:did/callback", + } + for _, path := range paths { + if c.Path() == path { + return false + } + } + return true + }, + TimeOut: time.Hour, + Store: r.storageEngine.GetSessionDatabase().GetStore(time.Hour, "user", "session"), + CookiePath: func(tenantDID did.DID) string { + baseURL, _ := createOAuth2BaseURL(tenantDID) + // error only happens on invalid did:web DID, which can't happen here + return baseURL.Path + }, + }.Handle) } func (r Wrapper) strictMiddleware(ctx echo.Context, request interface{}, operationID string, f StrictHandlerFunc) (interface{}, error) { diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 005aee0c72..b1cf6dd4d4 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -24,6 +24,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" "net/http" "net/http/httptest" "net/url" @@ -396,12 +397,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { RedirectURI: "https://example.com/iam/holder/cb", ResponseType: "code", }) - _ = ctx.client.userSessionStore().Put("session-id", UserSession{ - TenantDID: holderDID, - Wallet: UserWallet{ - DID: holderDID, - }, - }) + callCtx, _ := usersession.CreateTestSession(requestContext(nil), holderDID) clientMetadata := oauth.OAuthClientMetadata{VPFormats: oauth.DefaultOpenIDSupportedFormats()} ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) pdEndpoint := "https://example.com/oauth2/did:web:example.com:iam:verifier/presentation_definition?scope=test" @@ -409,10 +405,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, gomock.Any()).Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil) ctx.iamClient.EXPECT().PostAuthorizationResponse(gomock.Any(), vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "https://example.com/oauth2/did:web:example.com:iam:verifier/response", "state").Return("https://example.com/iam/holder/redirect", nil) - res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]interface{}{}, func(request *http.Request) { - request.Header = make(http.Header) - request.AddCookie(createUserSessionCookie("session-id", "/")) - }), HandleAuthorizeRequestRequestObject{ + res, err := ctx.client.HandleAuthorizeRequest(callCtx, HandleAuthorizeRequestRequestObject{ Did: holderDID.String(), }) @@ -710,8 +703,11 @@ func TestWrapper_Routes(t *testing.T) { router.EXPECT().GET(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() router.EXPECT().POST(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + router.EXPECT().Use(gomock.AssignableToTypeOf(usersession.Middleware{}.Handle)) - (&Wrapper{}).Routes(router) + (&Wrapper{ + storageEngine: storage.NewTestStorageEngine(t), + }).Routes(router) } func TestWrapper_middleware(t *testing.T) { diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 5417be1e2f..8ddab96dd1 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" "net/http" "net/url" "slices" @@ -285,9 +286,7 @@ func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, tenantD return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing nonce parameter"}, responseURI, state) } - // TODO: Create session if it does not exist (use client state to get original Authorization Code request)? - // Although it would be quite weird (maybe it expired). - userSession, err := r.loadUserSession(ctx.Value(httpRequestContextKey{}).(*http.Request), tenantDID, nil) + userSession, err := usersession.Get(ctx) if userSession == nil { return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, InternalError: err, Description: "no user session found"} } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index b500dbd413..2053a9575b 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -21,6 +21,7 @@ package iam import ( "context" "encoding/json" + "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" "net/http" "net/url" "strings" @@ -190,25 +191,12 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { RedirectURI: "https://example.com/iam/holder/cb", VerifierDID: &verifierDID, } - userSession := UserSession{ - TenantDID: holderDID, - Wallet: UserWallet{ - DID: did.MustParseDID("did:jwk:123"), - }, - } - - httpRequest := &http.Request{ - Header: http.Header{}, - } - const userSessionID = "session_id" - httpRequest.AddCookie(createUserSessionCookie(userSessionID, "/")) - httpRequestCtx := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) + httpRequestCtx, _ := usersession.CreateTestSession(context.Background(), holderDID) t.Run("invalid client_id", func(t *testing.T) { ctx := newTestClient(t) params := defaultParams() params[oauth.ClientIDParam] = "did:nuts:1" expectPostError(t, ctx, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)", responseURI, "state") - _ = ctx.client.userSessionStore().Put(userSessionID, userSession) _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderDID, params, pe.WalletOwnerOrganization) @@ -229,7 +217,6 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { params := defaultParams() ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(nil, assert.AnError) expectPostError(t, ctx, oauth.ServerError, "failed to get client metadata (verifier)", responseURI, "state") - _ = ctx.client.userSessionStore().Put(userSessionID, userSession) _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderDID, params, pe.WalletOwnerOrganization) @@ -249,7 +236,6 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { ctx := newTestClient(t) params := defaultParams() params[oauth.ClientMetadataParam] = "not empty" - _ = ctx.client.userSessionStore().Put(userSessionID, userSession) expectPostError(t, ctx, oauth.InvalidRequest, "client_metadata and client_metadata_uri are mutually exclusive", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderDID, params, pe.WalletOwnerOrganization) @@ -261,7 +247,6 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { params := defaultParams() delete(params, oauth.ClientMetadataURIParam) params[oauth.ClientMetadataParam] = "{invalid" - _ = ctx.client.userSessionStore().Put(userSessionID, userSession) expectPostError(t, ctx, oauth.InvalidRequest, "invalid client_metadata", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderDID, params, pe.WalletOwnerOrganization) @@ -273,7 +258,6 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { params := defaultParams() ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(nil, assert.AnError) expectPostError(t, ctx, oauth.ServerError, "failed to get client metadata (verifier)", responseURI, "state") - _ = ctx.client.userSessionStore().Put(userSessionID, userSession) _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderDID, params, pe.WalletOwnerOrganization) @@ -311,7 +295,6 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) params := defaultParams() params[oauth.PresentationDefParam] = "not empty" - _ = ctx.client.userSessionStore().Put(userSessionID, userSession) expectPostError(t, ctx, oauth.InvalidRequest, "presentation_definition and presentation_definition_uri are mutually exclusive", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderDID, params, pe.WalletOwnerOrganization) @@ -324,7 +307,6 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { delete(params, oauth.PresentationDefUriParam) params[oauth.PresentationDefParam] = "{invalid" ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) - _ = ctx.client.userSessionStore().Put(userSessionID, userSession) expectPostError(t, ctx, oauth.InvalidRequest, "invalid presentation_definition", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderDID, params, pe.WalletOwnerOrganization) @@ -338,7 +320,6 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) ctx.iamClient.EXPECT().PresentationDefinition(gomock.Any(), pdEndpoint).Return(nil, assert.AnError) expectPostError(t, ctx, oauth.InvalidPresentationDefinitionURI, "failed to retrieve presentation definition on https://example.com/iam/verifier/presentation_definition?scope=test", responseURI, "state") - _ = ctx.client.userSessionStore().Put(userSessionID, userSession) _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderDID, params, pe.WalletOwnerOrganization) @@ -352,7 +333,6 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { ctx.iamClient.EXPECT().PresentationDefinition(gomock.Any(), pdEndpoint).Return(&pe.PresentationDefinition{}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, gomock.Any()).Return(nil, nil, assert.AnError) expectPostError(t, ctx, oauth.ServerError, assert.AnError.Error(), responseURI, "state") - _ = ctx.client.userSessionStore().Put(userSessionID, userSession) _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderDID, params, pe.WalletOwnerOrganization) @@ -366,7 +346,6 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { ctx.iamClient.EXPECT().PresentationDefinition(gomock.Any(), pdEndpoint).Return(&pe.PresentationDefinition{}, nil) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, gomock.Any()).Return(nil, nil, holder.ErrNoCredentials) expectPostError(t, ctx, oauth.InvalidRequest, "no credentials available (PD ID: , wallet: did:web:example.com:iam:holder)", responseURI, "state") - _ = ctx.client.userSessionStore().Put(userSessionID, userSession) _, err := ctx.client.handleAuthorizeRequestFromVerifier(httpRequestCtx, holderDID, params, pe.WalletOwnerOrganization) diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index f39ea5455d..25350b5bac 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -23,7 +23,6 @@ import ( "fmt" "net/url" - "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/http" @@ -138,44 +137,6 @@ func (v *PEXConsumer) credentialMap() (map[string]vc.VerifiableCredential, error return credentialMap, nil } -// UserSession is a session-bound Verifiable Credential wallet. -type UserSession struct { - // TenantDID is the requesting DID when the user session was created, typically the employer's (of the user) DID. - // A session needs to be scoped to the tenant DID, since the session gives access to the tenant's wallet, - // and the user session might contain session-bound credentials (e.g. EmployeeCredential) that were issued by the tenant. - TenantDID did.DID `json:"tenantDID"` - // PreAuthorizedUser is the user that is pre-authorized by the client application. - // It is stored to later assert that subsequent RequestUserAccessToken() calls that (accidentally or intentionally) - // re-use the browser session, are indeed for the same client application user. - PreAuthorizedUser *UserDetails `json:"preauthorized_user"` - Wallet UserWallet `json:"wallet"` -} - -// UserWallet is a session-bound Verifiable Credential wallet. -// It's an in-memory wallet which contains the user's private key in plain text. -// This is OK, since the associated credentials are intended for protocol compatibility (OpenID4VP with a low-assurance EmployeeCredential), -// when an actual user wallet is involved, this wallet isn't used. -type UserWallet struct { - Credentials []vc.VerifiableCredential - // JWK is an in-memory key pair associated with the user's wallet in JWK form. - JWK []byte - // DID is the did:jwk DID of the user's wallet. - DID did.DID -} - -// Key returns the JWK as jwk.Key -func (w UserWallet) Key() (jwk.Key, error) { - set, err := jwk.Parse(w.JWK) - if err != nil { - return nil, fmt.Errorf("failed to parse JWK: %w", err) - } - result, available := set.Key(0) - if !available { - return nil, errors.New("expected exactly 1 key in the JWK set") - } - return result, nil -} - // ServerState is a convenience type for extracting different types of data from the session. type ServerState struct { CredentialMap map[string]vc.VerifiableCredential diff --git a/auth/api/iam/session_test.go b/auth/api/iam/session_test.go index 6cdbea08c1..0490ccebf3 100644 --- a/auth/api/iam/session_test.go +++ b/auth/api/iam/session_test.go @@ -19,32 +19,13 @@ package iam import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" "encoding/json" - "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "testing" ) -func TestUserWallet_Key(t *testing.T) { - t.Run("ok", func(t *testing.T) { - pk, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - keyAsJWK, err := jwk.FromRaw(pk) - require.NoError(t, err) - jwkAsJSON, _ := json.Marshal(keyAsJWK) - wallet := UserWallet{ - JWK: jwkAsJSON, - } - key, err := wallet.Key() - require.NoError(t, err) - assert.Equal(t, keyAsJWK, key) - }) -} - func TestOpenID4VPVerifier_next(t *testing.T) { userPresentationDefinition := PresentationDefinition{ Id: "user", diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go index 37a6a9252e..9c7f043a3d 100644 --- a/auth/api/iam/types.go +++ b/auth/api/iam/types.go @@ -19,8 +19,6 @@ package iam import ( - "net/http" - "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -66,13 +64,6 @@ type WalletOwnerType = pe.WalletOwnerType // RequiredPresentationDefinitions is an alias type RequiredPresentationDefinitions = pe.WalletOwnerMapping -// CookieReader is an interface for reading cookies from an HTTP request. -// It is implemented by echo.Context and http.Request. -type CookieReader interface { - // Cookie returns the named cookie provided in the request. - Cookie(name string) (*http.Cookie, error) -} - const ( // responseModeQuery returns the answer to the authorization request append as query parameters to the provided redirect_uri responseModeQuery = "query" // default if no response_mode is specified diff --git a/auth/api/iam/user.go b/auth/api/iam/user.go index 01df669acc..650f3ab528 100644 --- a/auth/api/iam/user.go +++ b/auth/api/iam/user.go @@ -20,16 +20,13 @@ package iam import ( "context" - "encoding/base64" - "encoding/json" - "errors" "fmt" + "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" "net/http" "strings" "time" "github.com/labstack/echo/v4" - "github.com/lestrrat-go/jwx/v2/jwk" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -49,15 +46,11 @@ const ( // userRedirectTimeout is the timeout for the user redirect session. // This is the maximum time between the creation of the redirect for the user and the actual GET request to the user/wallet page. userRedirectTimeout = time.Second * 5 - // userSessionTimeout is the timeout for the user session. - // This is the TTL of the server side state and the cookie. - userSessionTimeout = time.Hour ) var oauthClientStateKey = []string{"oauth", "client_state"} var oauthCodeKey = []string{"oauth", "code"} var userRedirectSessionKey = []string{"user", "redirect"} -var userSessionKey = []string{"user", "session"} // handleUserLanding is the handler for the landing page of the user. // It renders the page with the correct context based on the token. @@ -89,24 +82,13 @@ func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { return err } - session, err := r.loadUserSession(echoCtx, redirectSession.OwnDID, accessTokenRequest.Body.PreauthorizedUser) + // Make sure there's a user session, loaded with EmployeeCredential + userSession, err := usersession.Get(echoCtx.Request().Context()) if err != nil { - // Should only really occur in exceptional circumstances (e.g. cookie survived after intended max age). - log.Logger().WithError(err).Info("Invalid user session, a new session will be created") + return err } - if session == nil { - wallet, err := r.createUserWallet(echoCtx.Request().Context(), redirectSession.OwnDID, *accessTokenRequest.Body.PreauthorizedUser) - if err != nil { - return fmt.Errorf("create user wallet: %w", err) - } - // this causes the session cookie to be set - if err = r.createUserSession(echoCtx, UserSession{ - TenantDID: redirectSession.OwnDID, - Wallet: *wallet, - PreAuthorizedUser: accessTokenRequest.Body.PreauthorizedUser, - }); err != nil { - return fmt.Errorf("create user session: %w", err) - } + if err := r.provisionUserSession(echoCtx.Request().Context(), userSession, *redirectSession.AccessTokenRequest.Body.PreauthorizedUser); err != nil { + return fmt.Errorf("couldn't provision user session: %w", err) } // use DPoP or not @@ -175,107 +157,36 @@ func (r Wrapper) userRedirectStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(userRedirectTimeout, userRedirectSessionKey...) } -// userSessionStore is used to keep track of active UserSession -func (r Wrapper) userSessionStore() storage.SessionStore { - return r.storageEngine.GetSessionDatabase().GetStore(userSessionTimeout, userSessionKey...) -} - // oauthClientStateStore is used tot store the client's OAuthSession func (r Wrapper) oauthClientStateStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...) } -// loadUserSession loads the user session given the session ID in the cookie. -// If there is no session cookie (not yet authenticated, or the session expired), nil is returned. -// If another, technical error occurs when retrieving the session. -func (r Wrapper) loadUserSession(cookies CookieReader, tenantDID did.DID, preAuthorizedUser *UserDetails) (*UserSession, error) { - cookie, err := cookies.Cookie(userSessionCookieName) - if err != nil { - // sadly, no cookie for you - // Cookie only returns http.ErrNoCookie - return nil, nil +func (r Wrapper) provisionUserSession(ctx context.Context, session *usersession.Data, preAuthorizedUser UserDetails) error { + if len(session.Wallet.Credentials) > 0 { + // already provisioned + return nil } - session := new(UserSession) - if err = r.userSessionStore().Get(cookie.Value, session); errors.Is(err, storage.ErrNotFound) { - return nil, errors.New("unknown or expired session") - } else if err != nil { - // other error occurred - return nil, fmt.Errorf("invalid user session: %w", err) - } - // Note that the session itself does not have an expiration field: - // it depends on the session store to clean up when it expires. - if !session.TenantDID.Equals(tenantDID) && !session.Wallet.DID.Equals(tenantDID) { - return nil, fmt.Errorf("session belongs to another tenant (%s)", session.TenantDID) - } - // If the existing session was created for a pre-authorized user, the call to RequestUserAccessToken() must be - // for the same user. - // TODO: When we support external Identity Providers, make sure the existing session was not for a preauthorized user. - if preAuthorizedUser != nil && *preAuthorizedUser != *session.PreAuthorizedUser { - return nil, errors.New("session belongs to another pre-authorized user") - } - return session, nil -} - -func (r Wrapper) createUserSession(ctx echo.Context, session UserSession) error { - sessionID := crypto.GenerateNonce() - if err := r.userSessionStore().Put(sessionID, session); err != nil { + employeeCredential, err := r.issueEmployeeCredential(ctx, *session, preAuthorizedUser) + if err != nil { return err } - // Do not set Expires: then it isn't a session cookie anymore. - // TODO: we could make this more secure by narrowing the Path, but we currently have the following user-facing paths: - // - /iam/:did/(openid4vp_authz_accept) - // - /oauth2/:did/user - // If we move these under a common base path (/oauth2 or /iam), we could use that as Path property - // The issue with the current approach is that we have a single cookie for the whole domain, - // thus a new user session for a different DID will overwrite the current one (since a new cookie is created). - // By scoping the cookies to a tenant (DID)-specific path, they can co-exist. - var path string - if r.auth.PublicURL().Path != "" { - path = r.auth.PublicURL().Path - } else { - path = "/" - } - ctx.SetCookie(createUserSessionCookie(sessionID, path)) - return nil -} - -func createUserSessionCookie(sessionID string, path string) *http.Cookie { - return &http.Cookie{ - Name: userSessionCookieName, - Value: sessionID, - Path: path, - MaxAge: int(userSessionTimeout.Seconds()), - Secure: true, - HttpOnly: true, // do not let JavaScript - SameSite: http.SameSiteStrictMode, // do not allow the cookie to be sent with cross-site requests - } + session.Wallet.Credentials = append(session.Wallet.Credentials, *employeeCredential) + return session.Save() } -func (r Wrapper) createUserWallet(ctx context.Context, issuerDID did.DID, userDetails UserDetails) (*UserWallet, error) { - userJWK, userDID, err := generateUserSessionJWK() - if err != nil { - return nil, err - } - userJWKBytes, err := json.Marshal(userJWK) - if err != nil { - return nil, err - } - // create user session wallet - wallet := UserWallet{ - JWK: userJWKBytes, - DID: *userDID, - } +func (r Wrapper) issueEmployeeCredential(ctx context.Context, session usersession.Data, userDetails UserDetails) (*vc.VerifiableCredential, error) { issuanceDate := time.Now() - expirationDate := issuanceDate.Add(userSessionTimeout) + expirationDate := session.ExpiresAt template := vc.VerifiableCredential{ Context: []ssi.URI{credential.NutsV1ContextURI}, Type: []ssi.URI{ssi.MustParseURI("EmployeeCredential")}, - Issuer: issuerDID.URI(), + Issuer: session.TenantDID.URI(), IssuanceDate: issuanceDate, ExpirationDate: &expirationDate, CredentialSubject: []interface{}{ map[string]string{ - "id": userDID.String(), + "id": session.Wallet.DID.String(), "identifier": userDetails.Id, "name": userDetails.Name, "roleName": userDetails.Role, @@ -291,32 +202,5 @@ func (r Wrapper) createUserWallet(ctx context.Context, issuerDID did.DID, userDe if err != nil { return nil, fmt.Errorf("issue EmployeeCredential: %w", err) } - wallet.Credentials = append(wallet.Credentials, *employeeCredential) - return &wallet, nil -} - -func generateUserSessionJWK() (jwk.Key, *did.DID, error) { - // Generate a key pair and JWK for storage - userJWK, err := crypto.GenerateJWK() - if err != nil { - return nil, nil, err - } - // Now derive the did:jwk DID - publicKey, err := userJWK.PublicKey() - if err != nil { - return nil, nil, err - } - publicUserJSON, err := json.Marshal(publicKey) - if err != nil { - return nil, nil, err - } - userDID, err := did.ParseDID("did:jwk:" + base64.RawStdEncoding.EncodeToString(publicUserJSON)) - if err != nil { - return nil, nil, err - } - if err := userJWK.Set(jwk.KeyIDKey, userDID.String()+"#0"); err != nil { - return nil, nil, err - } - - return userJWK, userDID, nil + return employeeCredential, nil } diff --git a/auth/api/iam/user_test.go b/auth/api/iam/user_test.go index 59797c4a6f..5ca50506e2 100644 --- a/auth/api/iam/user_test.go +++ b/auth/api/iam/user_test.go @@ -20,6 +20,8 @@ package iam import ( "context" + "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" "net/http" "strings" "testing" @@ -89,21 +91,22 @@ func TestWrapper_handleUserLanding(t *testing.T) { RequireSignedRequestObject: true, } - t.Run("new session", func(t *testing.T) { + t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) expectedURL := "https://example.com/authorize?client_id=did%3Aweb%3Aexample.com%3Aiam%3A123&request_uri=https://example.com/oauth2/" + webDID.String() + "/request.jwt/&request_uri_method=get" echoCtx := mock.NewMockContext(ctx.ctrl) echoCtx.EXPECT().QueryParam("token").Return("token") - echoCtx.EXPECT().Request().MinTimes(1).Return(&http.Request{Host: "example.com"}) + httpRequest := &http.Request{ + Host: "example.com", + } + requestCtx, userSession := usersession.CreateTestSession(context.Background(), walletDID) + httpRequest = httpRequest.WithContext(requestCtx) + echoCtx.EXPECT().Request().MinTimes(1).Return(httpRequest) echoCtx.EXPECT().Redirect(http.StatusFound, gomock.Any()).DoAndReturn(func(_ int, arg1 string) error { testAuthzReqRedirectURI(t, expectedURL, arg1) return nil }) - var capturedCookie *http.Cookie - echoCtx.EXPECT().Cookie(gomock.Any()).Return(nil, http.ErrNoCookie) - echoCtx.EXPECT().SetCookie(gomock.Any()).DoAndReturn(func(cookie *http.Cookie) { - capturedCookie = cookie - }) + var employeeCredentialTemplate vc.VerifiableCredential var employeeCredentialOptions issuer.CredentialOptions ctx.vcIssuer.EXPECT().Issue(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, t vc.VerifiableCredential, o issuer.CredentialOptions) (*vc.VerifiableCredential, error) { @@ -127,23 +130,10 @@ func TestWrapper_handleUserLanding(t *testing.T) { require.NoError(t, err) err = ctx.client.handleUserLanding(echoCtx) - require.NoError(t, err) - // check security settings of session cookie - assert.Equal(t, "/", capturedCookie.Path) - assert.Equal(t, "__Host-SID", capturedCookie.Name) - assert.Empty(t, capturedCookie.Domain) - assert.Empty(t, capturedCookie.Expires) - assert.NotEmpty(t, capturedCookie.MaxAge) - assert.Equal(t, http.SameSiteStrictMode, capturedCookie.SameSite) - assert.True(t, capturedCookie.Secure) - assert.True(t, capturedCookie.HttpOnly) // check for issued EmployeeCredential in session wallet - userSession := new(UserSession) - require.NoError(t, ctx.client.userSessionStore().Get(capturedCookie.Value, userSession)) + require.NoError(t, err) assert.Equal(t, walletDID, userSession.TenantDID) - require.NotNil(t, userSession.PreAuthorizedUser) - assert.Equal(t, userDetails.Id, userSession.PreAuthorizedUser.Id) require.Len(t, userSession.Wallet.Credentials, 1) // check the JWK can be parsed and contains a private key sessionKey, err := jwk.ParseKey(userSession.Wallet.JWK) @@ -167,33 +157,6 @@ func TestWrapper_handleUserLanding(t *testing.T) { err = store.Get("token", &RedirectSession{}) assert.Error(t, err) }) - t.Run("existing session", func(t *testing.T) { - ctx := newTestClient(t) - expectedURL := "https://example.com/authorize?client_id=did%3Aweb%3Aexample.com%3Aiam%3A123&request_uri=https://example.com/oauth2/" + webDID.String() + "/request.jwt/&request_uri_method=" - echoCtx := mock.NewMockContext(ctx.ctrl) - echoCtx.EXPECT().QueryParam("token").Return("token") - echoCtx.EXPECT().Request().MinTimes(1).Return(&http.Request{Host: "example.com"}) - echoCtx.EXPECT().Redirect(http.StatusFound, gomock.Any()).DoAndReturn(func(_ int, arg1 string) error { - testAuthzReqRedirectURI(t, expectedURL, arg1) - return nil - }) - echoCtx.EXPECT().Cookie(gomock.Any()).Return(&sessionCookie, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), verifierURL).Return(&serverMetadata, nil).Times(2) - ctx.jar.EXPECT().Create(webDID, &verifierDID, gomock.Any()) - require.NoError(t, ctx.client.userRedirectStore().Put("token", redirectSession)) - session := UserSession{ - TenantDID: walletDID, - PreAuthorizedUser: &userDetails, - Wallet: UserWallet{ - DID: userDID, - }, - } - require.NoError(t, ctx.client.userSessionStore().Put(sessionCookie.Value, session)) - - err := ctx.client.handleUserLanding(echoCtx) - - assert.NoError(t, err) - }) t.Run("error - no token", func(t *testing.T) { ctx := newTestClient(t) echoCtx := mock.NewMockContext(ctx.ctrl) @@ -246,9 +209,12 @@ func TestWrapper_handleUserLanding(t *testing.T) { }) echoCtx := mock.NewMockContext(ctx.ctrl) echoCtx.EXPECT().QueryParam("token").Return("token") - echoCtx.EXPECT().Request().MinTimes(1).Return(&http.Request{Host: "example.com"}) - echoCtx.EXPECT().Cookie(gomock.Any()).Return(nil, http.ErrNoCookie) - echoCtx.EXPECT().SetCookie(gomock.Any()) + httpRequest := &http.Request{ + Host: "example.com", + } + requestCtx, _ := usersession.CreateTestSession(context.Background(), walletDID) + httpRequest = httpRequest.WithContext(requestCtx) + echoCtx.EXPECT().Request().MinTimes(1).Return(httpRequest) store := ctx.client.storageEngine.GetSessionDatabase().GetStore(time.Second*5, "user", "redirect") err := store.Put("token", redirectSession) require.NoError(t, err) @@ -260,6 +226,9 @@ func TestWrapper_handleUserLanding(t *testing.T) { // token has been burned assert.ErrorIs(t, store.Get("token", new(RedirectSession)), storage.ErrNotFound) }) + httpRequest := &http.Request{Host: "example.com"} + session, _ := usersession.CreateTestSession(audit.TestContext(), walletDID) + httpRequest = httpRequest.WithContext(session) t.Run("error - missing authorization_endpoint", func(t *testing.T) { ctx := newTestClient(t) ctx.vcIssuer.EXPECT().Issue(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, t vc.VerifiableCredential, _ issuer.CredentialOptions) (*vc.VerifiableCredential, error) { @@ -268,7 +237,7 @@ func TestWrapper_handleUserLanding(t *testing.T) { }) echoCtx := mock.NewMockContext(ctx.ctrl) echoCtx.EXPECT().QueryParam("token").Return("token") - echoCtx.EXPECT().Request().MinTimes(1).Return(&http.Request{Host: "example.com"}) + echoCtx.EXPECT().Request().MinTimes(1).Return(httpRequest) echoCtx.EXPECT().Cookie(gomock.Any()).Return(nil, http.ErrNoCookie) echoCtx.EXPECT().SetCookie(gomock.Any()) require.NoError(t, ctx.client.userRedirectStore().Put("token", redirectSession)) @@ -291,7 +260,7 @@ func TestWrapper_handleUserLanding(t *testing.T) { }) echoCtx := mock.NewMockContext(ctx.ctrl) echoCtx.EXPECT().QueryParam("token").Return("token") - echoCtx.EXPECT().Request().MinTimes(1).Return(&http.Request{Host: "example.com"}) + echoCtx.EXPECT().Request().MinTimes(1).Return(httpRequest) echoCtx.EXPECT().Cookie(gomock.Any()).Return(nil, http.ErrNoCookie) echoCtx.EXPECT().SetCookie(gomock.Any()) require.NoError(t, ctx.client.userRedirectStore().Put("token", redirectSession)) @@ -307,122 +276,3 @@ func TestWrapper_handleUserLanding(t *testing.T) { assert.ErrorIs(t, ctx.client.userRedirectStore().Get("token", new(RedirectSession)), storage.ErrNotFound) }) } - -func TestWrapper_loadUserSession(t *testing.T) { - user := &UserDetails{ - Id: "test", - Name: "John Doe", - Role: "Caregiver", - } - t.Run("ok", func(t *testing.T) { - ctx := newTestClient(t) - expected := UserSession{ - TenantDID: walletDID, - PreAuthorizedUser: user, - Wallet: UserWallet{ - DID: userDID, - }, - } - _ = ctx.client.userSessionStore().Put(sessionCookie.Value, expected) - ctrl := gomock.NewController(t) - echoCtx := mock.NewMockContext(ctrl) - echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil).Times(2) - - // organisation wallet - actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) - assert.NoError(t, err) - assert.Equal(t, expected, *actual) - - // user wallet - actual, err = ctx.client.loadUserSession(echoCtx, userDID, user) - assert.NoError(t, err) - assert.Equal(t, expected, *actual) - }) - t.Run("ok - no pre-authorized user", func(t *testing.T) { - ctx := newTestClient(t) - expected := UserSession{ - TenantDID: walletDID, - PreAuthorizedUser: user, - Wallet: UserWallet{ - DID: userDID, - }, - } - _ = ctx.client.userSessionStore().Put(sessionCookie.Value, expected) - ctrl := gomock.NewController(t) - echoCtx := mock.NewMockContext(ctrl) - echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil) - - actual, err := ctx.client.loadUserSession(echoCtx, walletDID, nil) - - assert.NoError(t, err) - assert.Equal(t, expected, *actual) - }) - t.Run("error - no session cookie", func(t *testing.T) { - ctx := newTestClient(t) - ctrl := gomock.NewController(t) - echoCtx := mock.NewMockContext(ctrl) - echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(nil, http.ErrNoCookie) - - actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) - - assert.NoError(t, err) - assert.Nil(t, actual) - }) - t.Run("error - session not found", func(t *testing.T) { - ctx := newTestClient(t) - ctrl := gomock.NewController(t) - echoCtx := mock.NewMockContext(ctrl) - echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil) - - actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) - - assert.EqualError(t, err, "unknown or expired session") - assert.Nil(t, actual) - }) - t.Run("error - session belongs to a different tenant", func(t *testing.T) { - ctx := newTestClient(t) - expected := UserSession{ - TenantDID: did.MustParseDID("did:web:someone-else"), - Wallet: UserWallet{ - DID: userDID, - }, - } - _ = ctx.client.userSessionStore().Put(sessionCookie.Value, expected) - ctrl := gomock.NewController(t) - echoCtx := mock.NewMockContext(ctrl) - echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil) - - actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) - - assert.EqualError(t, err, "session belongs to another tenant (did:web:someone-else)") - assert.Nil(t, actual) - }) - t.Run("error - session belongs to a different pre-authorized user", func(t *testing.T) { - ctx := newTestClient(t) - expected := UserSession{ - TenantDID: walletDID, - PreAuthorizedUser: &UserDetails{Id: "someone-else"}, - Wallet: UserWallet{ - DID: userDID, - }, - } - - _ = ctx.client.userSessionStore().Put(sessionCookie.Value, expected) - ctrl := gomock.NewController(t) - echoCtx := mock.NewMockContext(ctrl) - echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil) - - actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) - - assert.EqualError(t, err, "session belongs to another pre-authorized user") - assert.Nil(t, actual) - }) -} - -func Test_generateUserSessionJWK(t *testing.T) { - key, userDID, err := generateUserSessionJWK() - require.NoError(t, err) - require.NotNil(t, key) - require.NotNil(t, userDID) - assert.True(t, strings.HasPrefix(userDID.String(), "did:jwk:")) -} diff --git a/auth/api/iam/usersession/data.go b/auth/api/iam/usersession/data.go new file mode 100644 index 0000000000..aebbfb0276 --- /dev/null +++ b/auth/api/iam/usersession/data.go @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package usersession + +import ( + "errors" + "fmt" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "time" +) + +// Data is a session-bound Verifiable Credential wallet. +type Data struct { + // Save is a function that persists the session. + Save func() error `json:"-"` + // TenantDID is the requesting DID when the user session was created, typically the employer's (of the user) DID. + // A session needs to be scoped to the tenant DID, since the session gives access to the tenant's wallet, + // and the user session might contain session-bound credentials (e.g. EmployeeCredential) that were issued by the tenant. + TenantDID did.DID `json:"tenantDID"` + Wallet UserWallet `json:"wallet"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// UserWallet is a session-bound Verifiable Credential wallet. +// It's an in-memory wallet which contains the user's private key in plain text. +// This is OK, since the associated credentials are intended for protocol compatibility (OpenID4VP with a low-assurance EmployeeCredential), +// when an actual user wallet is involved, this wallet isn't used. +type UserWallet struct { + Credentials []vc.VerifiableCredential + // JWK is an in-memory key pair associated with the user's wallet in JWK form. + JWK []byte + // DID is the did:jwk DID of the user's wallet. + DID did.DID +} + +// Key returns the JWK as jwk.Key +func (w UserWallet) Key() (jwk.Key, error) { + set, err := jwk.Parse(w.JWK) + if err != nil { + return nil, fmt.Errorf("failed to parse JWK: %w", err) + } + result, available := set.Key(0) + if !available { + return nil, errors.New("expected exactly 1 key in the JWK set") + } + return result, nil +} diff --git a/auth/api/iam/usersession/data_test.go b/auth/api/iam/usersession/data_test.go new file mode 100644 index 0000000000..975e45dd4b --- /dev/null +++ b/auth/api/iam/usersession/data_test.go @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package usersession + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestUserWallet_Key(t *testing.T) { + t.Run("ok", func(t *testing.T) { + pk, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + keyAsJWK, err := jwk.FromRaw(pk) + require.NoError(t, err) + jwkAsJSON, _ := json.Marshal(keyAsJWK) + wallet := UserWallet{ + JWK: jwkAsJSON, + } + key, err := wallet.Key() + require.NoError(t, err) + assert.Equal(t, keyAsJWK, key) + }) +} diff --git a/auth/api/iam/usersession/test.go b/auth/api/iam/usersession/test.go new file mode 100644 index 0000000000..27f78a424b --- /dev/null +++ b/auth/api/iam/usersession/test.go @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package usersession + +import ( + "context" + "github.com/nuts-foundation/go-did/did" + "time" +) + +func CreateTestSession(ctx context.Context, tenantDID did.DID) (context.Context, *Data) { + session, _ := createUserSession(tenantDID, time.Hour) + session.Save = func() error { + return nil + } + return context.WithValue(ctx, userSessionContextKey, session), session +} diff --git a/auth/api/iam/usersession/user_session.go b/auth/api/iam/usersession/user_session.go new file mode 100644 index 0000000000..a79d1f9c94 --- /dev/null +++ b/auth/api/iam/usersession/user_session.go @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package usersession + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/log" + "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/storage" + "net/http" + "time" +) + +var userSessionContextKey = struct{}{} + +// userSessionCookieName is the name of the cookie used to store the user session. +// It uses the __Secure prefix, that instructs the user agent to treat it as a secure cookie: +// - Must be set with the Secure attribute +// - Must be set from an HTTPS uri +// Note that earlier, we used the Host cookie prefix, but that doesn't work in a multi-tenant environment, +// since then the Path attribute (used for multi-tenancy) can't be used. +// Also see: +// - https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/02-Testing_for_Cookies_Attributes +// - https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies +const userSessionCookieName = "__Secure-SID" + +// Middleware is Echo middleware that ensures a user session is available in the request context (unless skipped). +// If no session is available, a new session is created. +// All HTTP requests to which the middleware is applied must contain a tenant parameter in the HTTP request path, specified as ':did' +type Middleware struct { + // Skipper defines a function to skip middleware. + Skipper middleware.Skipper + // TimeOut is the maximum lifetime of a user session. + TimeOut time.Duration + // Store is the session store to use for storing user sessions. + Store storage.SessionStore + // CookiePath is a function that returns the path for the user session cookie. + CookiePath func(tenantDID did.DID) string +} + +func (u Middleware) Handle(next echo.HandlerFunc) echo.HandlerFunc { + return func(echoCtx echo.Context) error { + if u.Skipper(echoCtx) { + return next(echoCtx) + } + tenantDIDRaw := echoCtx.Param("did") + if tenantDIDRaw == "" { + // Indicates misconfiguration + return errors.New("missing tenant DID") + } + tenantDID, err := did.ParseDID(tenantDIDRaw) + if err != nil { + return fmt.Errorf("invalid tenant DID: %w", err) + } + + sessionID, sessionData, err := u.loadUserSession(echoCtx, *tenantDID) + if err != nil { + // Should only really occur in exceptional circumstances (e.g. cookie survived after intended max age). + log.Logger().WithError(err).Info("Invalid user session, a new session will be created") + } + if sessionData == nil { + sessionData, err = createUserSession(*tenantDID, u.TimeOut) + sessionID = crypto.GenerateNonce() + if err := u.Store.Put(sessionID, sessionData); err != nil { + return err + } + if err != nil { + return fmt.Errorf("create user session: %w", err) + } + // By scoping the cookie to a tenant (DID)-specific path, the user can have a session per tenant DID on the same domain. + echoCtx.SetCookie(u.createUserSessionCookie(sessionID, u.CookiePath(*tenantDID))) + } + sessionData.Save = func() error { + return u.Store.Put(sessionID, sessionData) + } + // Session data is put in request context for access by API handlers + echoCtx.SetRequest(echoCtx.Request().WithContext(context.WithValue(echoCtx.Request().Context(), userSessionContextKey, sessionData))) + + return next(echoCtx) + } +} + +// loadUserSession loads the user session given the session ID in the cookie. +// If there is no session cookie (not yet authenticated, or the session expired), nil is returned. +// If another, technical error occurs when retrieving the session. +func (u Middleware) loadUserSession(cookies CookieReader, tenantDID did.DID) (string, *Data, error) { + cookie, err := cookies.Cookie(userSessionCookieName) + if err != nil { + // sadly, no cookie for you + // Cookie only returns http.ErrNoCookie + return "", nil, nil + } + session := new(Data) + sessionID := cookie.Value + if err = u.Store.Get(sessionID, session); errors.Is(err, storage.ErrNotFound) { + return "", nil, errors.New("unknown or expired session") + } else if err != nil { + // other error occurred + return "", nil, fmt.Errorf("invalid user session: %w", err) + } + if session.ExpiresAt.Before(time.Now()) { + // session has expired: possible if session was updated, which causes the TTL to be updated. + // Could also be implemented by separating "create" and "update" in the session store, + // but this adds less complexity. + return "", nil, errors.New("expired session") + } + if !session.TenantDID.Equals(tenantDID) { + return "", nil, fmt.Errorf("session belongs to another tenant (%s)", session.TenantDID) + } + return sessionID, session, nil +} + +func createUserSession(tenantDID did.DID, timeOut time.Duration) (*Data, error) { + userJWK, userDID, err := generateUserSessionJWK() + if err != nil { + return nil, err + } + userJWKBytes, err := json.Marshal(userJWK) + if err != nil { + return nil, err + } + // create user session wallet + return &Data{ + TenantDID: tenantDID, + Wallet: UserWallet{ + JWK: userJWKBytes, + DID: *userDID, + }, + ExpiresAt: time.Now().Add(timeOut), + }, nil +} + +func (u Middleware) createUserSessionCookie(sessionID string, path string) *http.Cookie { + // Do not set Expires: then it isn't a session cookie anymore. + return &http.Cookie{ + Name: userSessionCookieName, + Value: sessionID, + Path: path, + MaxAge: int(u.TimeOut.Seconds()), + Secure: true, // only transfer over HTTPS + HttpOnly: true, // do not let JavaScript interact with the cookie + SameSite: http.SameSiteStrictMode, // do not allow the cookie to be sent with cross-site requests + } +} + +// Get retrieves the user session from the request context. +// If the user session is not found, an error is returned. +func Get(ctx context.Context) (*Data, error) { + result, ok := ctx.Value(userSessionContextKey).(*Data) + if !ok { + return nil, errors.New("no user session found") + } + return result, nil +} + +func generateUserSessionJWK() (jwk.Key, *did.DID, error) { + // Generate a key pair and JWK for storage + userJWK, err := crypto.GenerateJWK() + if err != nil { + return nil, nil, err + } + // Now derive the did:jwk DID + publicKey, err := userJWK.PublicKey() + if err != nil { + return nil, nil, err + } + publicUserJSON, err := json.Marshal(publicKey) + if err != nil { + return nil, nil, err + } + userDID, err := did.ParseDID("did:jwk:" + base64.RawStdEncoding.EncodeToString(publicUserJSON)) + if err != nil { + return nil, nil, err + } + if err := userJWK.Set(jwk.KeyIDKey, userDID.String()+"#0"); err != nil { + return nil, nil, err + } + + return userJWK, userDID, nil +} diff --git a/auth/api/iam/usersession/user_session_test.go b/auth/api/iam/usersession/user_session_test.go new file mode 100644 index 0000000000..12c4945893 --- /dev/null +++ b/auth/api/iam/usersession/user_session_test.go @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package usersession + +import ( + "github.com/labstack/echo/v4" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +var tenantDID = did.MustParseDID("did:web:example.com:iam:123") +var userDID = did.MustParseDID("did:jwk:really-a-jwk") + +var sessionCookie = http.Cookie{ + Name: "__Secure-SID", + Value: "sessionID", + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, +} + +type testCookieReader http.Cookie + +func (t *testCookieReader) Cookie(name string) (*http.Cookie, error) { + if t != nil && name == t.Name { + return (*http.Cookie)(t), nil + } + return nil, http.ErrNoCookie +} + +func TestMiddleware_Handle(t *testing.T) { + t.Run("ok - session is created", func(t *testing.T) { + instance, sessionStore := createInstance(t) + httpResponse := httptest.NewRecorder() + echoServer := echo.New() + echoContext := echoServer.NewContext(httptest.NewRequest(http.MethodGet, "/iam/"+tenantDID.String(), nil), httpResponse) + echoContext.SetParamNames("did") + echoContext.SetParamValues(tenantDID.String()) + + var capturedSession *Data + err := instance.Handle(func(c echo.Context) error { + var err error + capturedSession, err = Get(c.Request().Context()) + return err + })(echoContext) + + assert.NoError(t, err) + assert.NotNil(t, capturedSession) + assert.Equal(t, tenantDID, capturedSession.TenantDID) + // Assert stored session + var storedSession = new(Data) + cookie := httpResponse.Result().Cookies()[0] + require.NoError(t, sessionStore.Get(cookie.Value, storedSession)) + assert.Equal(t, tenantDID, storedSession.TenantDID) + assert.NotNil(t, capturedSession.Save) + }) + t.Run("ok - existing session", func(t *testing.T) { + instance, sessionStore := createInstance(t) + expected, _ := createUserSession(tenantDID, time.Hour) + _ = sessionStore.Put(sessionCookie.Value, expected) + httpResponse := httptest.NewRecorder() + echoServer := echo.New() + echoContext := echoServer.NewContext(httptest.NewRequest(http.MethodGet, "/iam/"+tenantDID.String(), nil), httpResponse) + echoContext.SetParamNames("did") + echoContext.SetParamValues(tenantDID.String()) + echoContext.Request().AddCookie(&sessionCookie) + + var capturedSession *Data + err := instance.Handle(func(c echo.Context) error { + capturedSession, _ = Get(c.Request().Context()) + capturedSession.Wallet.Credentials = append(capturedSession.Wallet.Credentials, vc.VerifiableCredential{}) + return capturedSession.Save() + })(echoContext) + + assert.NoError(t, err) + assert.NotNil(t, capturedSession) + assert.Equal(t, expected.TenantDID, capturedSession.TenantDID) + assert.NotNil(t, capturedSession.Save) + // Make sure no new cookie is set, which indicates session creation + assert.Empty(t, httpResponse.Result().Cookies()) + }) + t.Run("skip", func(t *testing.T) { + instance, _ := createInstance(t) + instance.Skipper = func(_ echo.Context) bool { + return true + } + httpResponse := httptest.NewRecorder() + echoServer := echo.New() + echoContext := echoServer.NewContext(httptest.NewRequest(http.MethodGet, "/iam/"+tenantDID.String(), nil), httpResponse) + echoContext.SetParamNames("did") + echoContext.SetParamValues(tenantDID.String()) + + err := instance.Handle(func(c echo.Context) error { + return nil + })(echoContext) + + assert.NoError(t, err) + assert.Empty(t, httpResponse.Result().Cookies()) + }) + t.Run("error - missing tenant DID", func(t *testing.T) { + instance, _ := createInstance(t) + httpResponse := httptest.NewRecorder() + echoServer := echo.New() + echoContext := echoServer.NewContext(httptest.NewRequest(http.MethodGet, "/iam/", nil), httpResponse) + + err := instance.Handle(func(c echo.Context) error { + return nil + })(echoContext) + + assert.Error(t, err) + assert.Empty(t, httpResponse.Result().Cookies()) + }) + t.Run("error - invalid tenant DID", func(t *testing.T) { + instance, _ := createInstance(t) + httpResponse := httptest.NewRecorder() + echoServer := echo.New() + echoContext := echoServer.NewContext(httptest.NewRequest(http.MethodGet, "/iam/invalid", nil), httpResponse) + echoContext.SetParamNames("did") + echoContext.SetParamValues("invalid") + + err := instance.Handle(func(c echo.Context) error { + return nil + })(echoContext) + + assert.Error(t, err) + assert.Empty(t, httpResponse.Result().Cookies()) + }) + t.Run("error - unknown session ID causes new session", func(t *testing.T) { + instance, _ := createInstance(t) + httpResponse := httptest.NewRecorder() + echoServer := echo.New() + echoContext := echoServer.NewContext(httptest.NewRequest(http.MethodGet, "/iam/"+tenantDID.String(), nil), httpResponse) + echoContext.SetParamNames("did") + echoContext.SetParamValues(tenantDID.String()) + // Session is not in storage, so error will be triggered and new session be created + echoContext.Request().AddCookie(&sessionCookie) + + var capturedSession *Data + err := instance.Handle(func(c echo.Context) error { + var err error + capturedSession, err = Get(c.Request().Context()) + return err + })(echoContext) + + assert.NoError(t, err) + assert.NotNil(t, capturedSession) + assert.Equal(t, tenantDID, capturedSession.TenantDID) + // Assert stored session + assert.Len(t, httpResponse.Result().Cookies(), 1) + }) +} + +func TestMiddleware_loadUserSession(t *testing.T) { + t.Run("ok", func(t *testing.T) { + instance, sessionStore := createInstance(t) + expected, _ := createUserSession(tenantDID, time.Hour) + _ = sessionStore.Put(sessionCookie.Value, expected) + + actualID, actualData, err := instance.loadUserSession((*testCookieReader)(&sessionCookie), tenantDID) + require.NoError(t, err) + assert.Equal(t, expected.TenantDID, actualData.TenantDID) + assert.Equal(t, sessionCookie.Value, actualID) + }) + t.Run("error - no session cookie", func(t *testing.T) { + instance, _ := createInstance(t) + + _, actual, err := instance.loadUserSession((*testCookieReader)(nil), tenantDID) + + assert.NoError(t, err) + assert.Nil(t, actual) + }) + t.Run("error - session not found", func(t *testing.T) { + instance, _ := createInstance(t) + + _, actual, err := instance.loadUserSession((*testCookieReader)(&sessionCookie), tenantDID) + + assert.EqualError(t, err, "unknown or expired session") + assert.Nil(t, actual) + }) + t.Run("error - session belongs to a different tenant", func(t *testing.T) { + instance, sessionStore := createInstance(t) + expected, _ := createUserSession(tenantDID, time.Hour) + expected.TenantDID = did.MustParseDID("did:web:someone-else") + _ = sessionStore.Put(sessionCookie.Value, expected) + + _, actual, err := instance.loadUserSession((*testCookieReader)(&sessionCookie), tenantDID) + + assert.EqualError(t, err, "session belongs to another tenant (did:web:someone-else)") + assert.Nil(t, actual) + }) + t.Run("error - expired", func(t *testing.T) { + instance, sessionStore := createInstance(t) + expected := Data{ + TenantDID: tenantDID, + Wallet: UserWallet{ + DID: userDID, + }, + ExpiresAt: time.Now().Add(-time.Hour), + } + _ = sessionStore.Put(sessionCookie.Value, expected) + + _, actual, err := instance.loadUserSession((*testCookieReader)(&sessionCookie), tenantDID) + + assert.EqualError(t, err, "expired session") + assert.Nil(t, actual) + }) +} + +func Test_generateUserSessionJWK(t *testing.T) { + key, userDID, err := generateUserSessionJWK() + require.NoError(t, err) + require.NotNil(t, key) + require.NotNil(t, userDID) + assert.True(t, strings.HasPrefix(userDID.String(), "did:jwk:")) +} + +func createInstance(t *testing.T) (Middleware, storage.SessionStore) { + store := storage.NewTestInMemorySessionDatabase(t).GetStore(time.Hour, "sessions") + return Middleware{ + Skipper: func(c echo.Context) bool { + return false + }, + TimeOut: time.Hour, + Store: store, + CookiePath: func(tenantDID did.DID) string { + return "/oauth2/" + tenantDID.String() + }, + }, store +} + +func TestMiddleware_createUserSessionCookie(t *testing.T) { + cookie := Middleware{ + TimeOut: 30 * time.Minute, + }.createUserSessionCookie("sessionID", "/iam/did:web:example.com:iam:123") + assert.Equal(t, "/iam/did:web:example.com:iam:123", cookie.Path) + assert.Equal(t, "__Secure-SID", cookie.Name) + assert.Empty(t, cookie.Domain) + assert.Empty(t, cookie.Expires) + assert.Equal(t, 30*time.Minute, time.Duration(cookie.MaxAge)*time.Second) + assert.Equal(t, http.SameSiteStrictMode, cookie.SameSite) + assert.True(t, cookie.Secure) + assert.True(t, cookie.HttpOnly) +} diff --git a/auth/api/iam/usersession/util.go b/auth/api/iam/usersession/util.go new file mode 100644 index 0000000000..5a970b8669 --- /dev/null +++ b/auth/api/iam/usersession/util.go @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package usersession + +import "net/http" + +// CookieReader is an interface for reading cookies from an HTTP request. +// It is implemented by echo.Context and http.Request. +type CookieReader interface { + // Cookie returns the named cookie provided in the request. + Cookie(name string) (*http.Cookie, error) +} diff --git a/docs/_static/auth/iam.partial.yaml b/docs/_static/auth/iam.partial.yaml index 92c6b36646..12a4a14b12 100644 --- a/docs/_static/auth/iam.partial.yaml +++ b/docs/_static/auth/iam.partial.yaml @@ -44,7 +44,7 @@ paths: description: DID does not exist. /oauth2/{did}/token: post: - summary: Used by to request access- or refresh tokens. + summary: Used by the OAuth2 client (backend, not the browser) to request access- or refresh tokens. description: | Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-endpoint. Requires the use of PKCE as specified by https://datatracker.ietf.org/doc/html/rfc7636 and optionally DPoP as specified by https://datatracker.ietf.org/doc/html/rfc9449. @@ -99,7 +99,7 @@ paths: $ref: "#/components/schemas/ErrorResponse" /oauth2/{did}/authorize: get: - summary: Used by resource owners to initiate the authorization code flow. + summary: Used by resource owners (the browser) to initiate the authorization code flow. description: Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.1 operationId: handleAuthorizeRequest tags: From 3c7e720365570e229af3b6cd79891f8f5e0fdaa0 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Sat, 25 May 2024 09:27:06 +0200 Subject: [PATCH 2/3] fix test --- auth/api/iam/user_test.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/auth/api/iam/user_test.go b/auth/api/iam/user_test.go index 5ca50506e2..37340612bd 100644 --- a/auth/api/iam/user_test.go +++ b/auth/api/iam/user_test.go @@ -238,8 +238,6 @@ func TestWrapper_handleUserLanding(t *testing.T) { echoCtx := mock.NewMockContext(ctx.ctrl) echoCtx.EXPECT().QueryParam("token").Return("token") echoCtx.EXPECT().Request().MinTimes(1).Return(httpRequest) - echoCtx.EXPECT().Cookie(gomock.Any()).Return(nil, http.ErrNoCookie) - echoCtx.EXPECT().SetCookie(gomock.Any()) require.NoError(t, ctx.client.userRedirectStore().Put("token", redirectSession)) ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), verifierURL).Return(&oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: "", @@ -254,15 +252,9 @@ func TestWrapper_handleUserLanding(t *testing.T) { }) t.Run("error - missing authorization_endpoint", func(t *testing.T) { ctx := newTestClient(t) - ctx.vcIssuer.EXPECT().Issue(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, t vc.VerifiableCredential, _ issuer.CredentialOptions) (*vc.VerifiableCredential, error) { - // just return whatever template was given to avoid nil deref - return &t, nil - }) echoCtx := mock.NewMockContext(ctx.ctrl) echoCtx.EXPECT().QueryParam("token").Return("token") echoCtx.EXPECT().Request().MinTimes(1).Return(httpRequest) - echoCtx.EXPECT().Cookie(gomock.Any()).Return(nil, http.ErrNoCookie) - echoCtx.EXPECT().SetCookie(gomock.Any()) require.NoError(t, ctx.client.userRedirectStore().Put("token", redirectSession)) ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), verifierURL).Return(&oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: "https://example.com/authorize", From cc44b3084c19a05f3f8f164f9926c8a6d89672dd Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 30 May 2024 13:12:47 +0200 Subject: [PATCH 3/3] PR feedback --- auth/api/iam/api.go | 4 +- auth/api/iam/api_test.go | 6 +- auth/api/iam/openid4vp.go | 4 +- auth/api/iam/openid4vp_test.go | 4 +- auth/api/iam/user.go | 8 +-- auth/api/iam/user_test.go | 8 +-- auth/api/iam/usersession/data.go | 65 ----------------- auth/api/iam/usersession/data_test.go | 45 ------------ auth/api/iam/usersession/util.go | 28 -------- .../user_session.go => http/user/session.go | 71 +++++++++++++++---- .../user/session_test.go | 46 ++++++++---- .../api/iam/usersession => http/user}/test.go | 4 +- 12 files changed, 110 insertions(+), 183 deletions(-) delete mode 100644 auth/api/iam/usersession/data.go delete mode 100644 auth/api/iam/usersession/data_test.go delete mode 100644 auth/api/iam/usersession/util.go rename auth/api/iam/usersession/user_session.go => http/user/session.go (72%) rename auth/api/iam/usersession/user_session_test.go => http/user/session_test.go (90%) rename {auth/api/iam/usersession => http/user}/test.go (95%) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 29729d9182..b29c996398 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -27,7 +27,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" + "github.com/nuts-foundation/nuts-node/http/user" "html/template" "net/http" "net/url" @@ -133,7 +133,7 @@ func (r Wrapper) Routes(router core.EchoRouter) { return next(c) } }, audit.Middleware(apiModuleName)) - router.Use(usersession.Middleware{ + router.Use(user.SessionMiddleware{ Skipper: func(c echo.Context) bool { // The following URLs require a user session: paths := []string{ diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index b1cf6dd4d4..abba5cf55d 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -24,7 +24,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" + "github.com/nuts-foundation/nuts-node/http/user" "net/http" "net/http/httptest" "net/url" @@ -397,7 +397,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { RedirectURI: "https://example.com/iam/holder/cb", ResponseType: "code", }) - callCtx, _ := usersession.CreateTestSession(requestContext(nil), holderDID) + callCtx, _ := user.CreateTestSession(requestContext(nil), holderDID) clientMetadata := oauth.OAuthClientMetadata{VPFormats: oauth.DefaultOpenIDSupportedFormats()} ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) pdEndpoint := "https://example.com/oauth2/did:web:example.com:iam:verifier/presentation_definition?scope=test" @@ -703,7 +703,7 @@ func TestWrapper_Routes(t *testing.T) { router.EXPECT().GET(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() router.EXPECT().POST(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - router.EXPECT().Use(gomock.AssignableToTypeOf(usersession.Middleware{}.Handle)) + router.EXPECT().Use(gomock.AssignableToTypeOf(user.SessionMiddleware{}.Handle)) (&Wrapper{ storageEngine: storage.NewTestStorageEngine(t), diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 8ddab96dd1..eac30b1203 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -23,7 +23,7 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" + "github.com/nuts-foundation/nuts-node/http/user" "net/http" "net/url" "slices" @@ -286,7 +286,7 @@ func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, tenantD return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing nonce parameter"}, responseURI, state) } - userSession, err := usersession.Get(ctx) + userSession, err := user.GetSession(ctx) if userSession == nil { return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, InternalError: err, Description: "no user session found"} } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 2053a9575b..0cfd0d0a11 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -21,7 +21,7 @@ package iam import ( "context" "encoding/json" - "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" + "github.com/nuts-foundation/nuts-node/http/user" "net/http" "net/url" "strings" @@ -191,7 +191,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { RedirectURI: "https://example.com/iam/holder/cb", VerifierDID: &verifierDID, } - httpRequestCtx, _ := usersession.CreateTestSession(context.Background(), holderDID) + httpRequestCtx, _ := user.CreateTestSession(context.Background(), holderDID) t.Run("invalid client_id", func(t *testing.T) { ctx := newTestClient(t) params := defaultParams() diff --git a/auth/api/iam/user.go b/auth/api/iam/user.go index 650f3ab528..c771b23541 100644 --- a/auth/api/iam/user.go +++ b/auth/api/iam/user.go @@ -21,7 +21,7 @@ package iam import ( "context" "fmt" - "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" + "github.com/nuts-foundation/nuts-node/http/user" "net/http" "strings" "time" @@ -83,7 +83,7 @@ func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { } // Make sure there's a user session, loaded with EmployeeCredential - userSession, err := usersession.Get(echoCtx.Request().Context()) + userSession, err := user.GetSession(echoCtx.Request().Context()) if err != nil { return err } @@ -162,7 +162,7 @@ func (r Wrapper) oauthClientStateStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...) } -func (r Wrapper) provisionUserSession(ctx context.Context, session *usersession.Data, preAuthorizedUser UserDetails) error { +func (r Wrapper) provisionUserSession(ctx context.Context, session *user.Session, preAuthorizedUser UserDetails) error { if len(session.Wallet.Credentials) > 0 { // already provisioned return nil @@ -175,7 +175,7 @@ func (r Wrapper) provisionUserSession(ctx context.Context, session *usersession. return session.Save() } -func (r Wrapper) issueEmployeeCredential(ctx context.Context, session usersession.Data, userDetails UserDetails) (*vc.VerifiableCredential, error) { +func (r Wrapper) issueEmployeeCredential(ctx context.Context, session user.Session, userDetails UserDetails) (*vc.VerifiableCredential, error) { issuanceDate := time.Now() expirationDate := session.ExpiresAt template := vc.VerifiableCredential{ diff --git a/auth/api/iam/user_test.go b/auth/api/iam/user_test.go index 37340612bd..302911f556 100644 --- a/auth/api/iam/user_test.go +++ b/auth/api/iam/user_test.go @@ -21,7 +21,7 @@ package iam import ( "context" "github.com/nuts-foundation/nuts-node/audit" - "github.com/nuts-foundation/nuts-node/auth/api/iam/usersession" + "github.com/nuts-foundation/nuts-node/http/user" "net/http" "strings" "testing" @@ -99,7 +99,7 @@ func TestWrapper_handleUserLanding(t *testing.T) { httpRequest := &http.Request{ Host: "example.com", } - requestCtx, userSession := usersession.CreateTestSession(context.Background(), walletDID) + requestCtx, userSession := user.CreateTestSession(context.Background(), walletDID) httpRequest = httpRequest.WithContext(requestCtx) echoCtx.EXPECT().Request().MinTimes(1).Return(httpRequest) echoCtx.EXPECT().Redirect(http.StatusFound, gomock.Any()).DoAndReturn(func(_ int, arg1 string) error { @@ -212,7 +212,7 @@ func TestWrapper_handleUserLanding(t *testing.T) { httpRequest := &http.Request{ Host: "example.com", } - requestCtx, _ := usersession.CreateTestSession(context.Background(), walletDID) + requestCtx, _ := user.CreateTestSession(context.Background(), walletDID) httpRequest = httpRequest.WithContext(requestCtx) echoCtx.EXPECT().Request().MinTimes(1).Return(httpRequest) store := ctx.client.storageEngine.GetSessionDatabase().GetStore(time.Second*5, "user", "redirect") @@ -227,7 +227,7 @@ func TestWrapper_handleUserLanding(t *testing.T) { assert.ErrorIs(t, store.Get("token", new(RedirectSession)), storage.ErrNotFound) }) httpRequest := &http.Request{Host: "example.com"} - session, _ := usersession.CreateTestSession(audit.TestContext(), walletDID) + session, _ := user.CreateTestSession(audit.TestContext(), walletDID) httpRequest = httpRequest.WithContext(session) t.Run("error - missing authorization_endpoint", func(t *testing.T) { ctx := newTestClient(t) diff --git a/auth/api/iam/usersession/data.go b/auth/api/iam/usersession/data.go deleted file mode 100644 index aebbfb0276..0000000000 --- a/auth/api/iam/usersession/data.go +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (C) 2024 Nuts community - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package usersession - -import ( - "errors" - "fmt" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/nuts-foundation/go-did/did" - "github.com/nuts-foundation/go-did/vc" - "time" -) - -// Data is a session-bound Verifiable Credential wallet. -type Data struct { - // Save is a function that persists the session. - Save func() error `json:"-"` - // TenantDID is the requesting DID when the user session was created, typically the employer's (of the user) DID. - // A session needs to be scoped to the tenant DID, since the session gives access to the tenant's wallet, - // and the user session might contain session-bound credentials (e.g. EmployeeCredential) that were issued by the tenant. - TenantDID did.DID `json:"tenantDID"` - Wallet UserWallet `json:"wallet"` - ExpiresAt time.Time `json:"expiresAt"` -} - -// UserWallet is a session-bound Verifiable Credential wallet. -// It's an in-memory wallet which contains the user's private key in plain text. -// This is OK, since the associated credentials are intended for protocol compatibility (OpenID4VP with a low-assurance EmployeeCredential), -// when an actual user wallet is involved, this wallet isn't used. -type UserWallet struct { - Credentials []vc.VerifiableCredential - // JWK is an in-memory key pair associated with the user's wallet in JWK form. - JWK []byte - // DID is the did:jwk DID of the user's wallet. - DID did.DID -} - -// Key returns the JWK as jwk.Key -func (w UserWallet) Key() (jwk.Key, error) { - set, err := jwk.Parse(w.JWK) - if err != nil { - return nil, fmt.Errorf("failed to parse JWK: %w", err) - } - result, available := set.Key(0) - if !available { - return nil, errors.New("expected exactly 1 key in the JWK set") - } - return result, nil -} diff --git a/auth/api/iam/usersession/data_test.go b/auth/api/iam/usersession/data_test.go deleted file mode 100644 index 975e45dd4b..0000000000 --- a/auth/api/iam/usersession/data_test.go +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2024 Nuts community - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package usersession - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "encoding/json" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "testing" -) - -func TestUserWallet_Key(t *testing.T) { - t.Run("ok", func(t *testing.T) { - pk, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - keyAsJWK, err := jwk.FromRaw(pk) - require.NoError(t, err) - jwkAsJSON, _ := json.Marshal(keyAsJWK) - wallet := UserWallet{ - JWK: jwkAsJSON, - } - key, err := wallet.Key() - require.NoError(t, err) - assert.Equal(t, keyAsJWK, key) - }) -} diff --git a/auth/api/iam/usersession/util.go b/auth/api/iam/usersession/util.go deleted file mode 100644 index 5a970b8669..0000000000 --- a/auth/api/iam/usersession/util.go +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (C) 2024 Nuts community - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package usersession - -import "net/http" - -// CookieReader is an interface for reading cookies from an HTTP request. -// It is implemented by echo.Context and http.Request. -type CookieReader interface { - // Cookie returns the named cookie provided in the request. - Cookie(name string) (*http.Cookie, error) -} diff --git a/auth/api/iam/usersession/user_session.go b/http/user/session.go similarity index 72% rename from auth/api/iam/usersession/user_session.go rename to http/user/session.go index a79d1f9c94..90b3faba9b 100644 --- a/auth/api/iam/usersession/user_session.go +++ b/http/user/session.go @@ -16,7 +16,7 @@ * */ -package usersession +package user import ( "context" @@ -28,6 +28,7 @@ import ( "github.com/labstack/echo/v4/middleware" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/storage" @@ -48,10 +49,10 @@ var userSessionContextKey = struct{}{} // - https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies const userSessionCookieName = "__Secure-SID" -// Middleware is Echo middleware that ensures a user session is available in the request context (unless skipped). +// SessionMiddleware is Echo middleware that ensures a user session is available in the request context (unless skipped). // If no session is available, a new session is created. // All HTTP requests to which the middleware is applied must contain a tenant parameter in the HTTP request path, specified as ':did' -type Middleware struct { +type SessionMiddleware struct { // Skipper defines a function to skip middleware. Skipper middleware.Skipper // TimeOut is the maximum lifetime of a user session. @@ -62,7 +63,7 @@ type Middleware struct { CookiePath func(tenantDID did.DID) string } -func (u Middleware) Handle(next echo.HandlerFunc) echo.HandlerFunc { +func (u SessionMiddleware) Handle(next echo.HandlerFunc) echo.HandlerFunc { return func(echoCtx echo.Context) error { if u.Skipper(echoCtx) { return next(echoCtx) @@ -107,14 +108,14 @@ func (u Middleware) Handle(next echo.HandlerFunc) echo.HandlerFunc { // loadUserSession loads the user session given the session ID in the cookie. // If there is no session cookie (not yet authenticated, or the session expired), nil is returned. // If another, technical error occurs when retrieving the session. -func (u Middleware) loadUserSession(cookies CookieReader, tenantDID did.DID) (string, *Data, error) { +func (u SessionMiddleware) loadUserSession(cookies CookieReader, tenantDID did.DID) (string, *Session, error) { cookie, err := cookies.Cookie(userSessionCookieName) if err != nil { // sadly, no cookie for you // Cookie only returns http.ErrNoCookie return "", nil, nil } - session := new(Data) + session := new(Session) sessionID := cookie.Value if err = u.Store.Get(sessionID, session); errors.Is(err, storage.ErrNotFound) { return "", nil, errors.New("unknown or expired session") @@ -134,7 +135,7 @@ func (u Middleware) loadUserSession(cookies CookieReader, tenantDID did.DID) (st return sessionID, session, nil } -func createUserSession(tenantDID did.DID, timeOut time.Duration) (*Data, error) { +func createUserSession(tenantDID did.DID, timeOut time.Duration) (*Session, error) { userJWK, userDID, err := generateUserSessionJWK() if err != nil { return nil, err @@ -144,9 +145,9 @@ func createUserSession(tenantDID did.DID, timeOut time.Duration) (*Data, error) return nil, err } // create user session wallet - return &Data{ + return &Session{ TenantDID: tenantDID, - Wallet: UserWallet{ + Wallet: Wallet{ JWK: userJWKBytes, DID: *userDID, }, @@ -154,7 +155,7 @@ func createUserSession(tenantDID did.DID, timeOut time.Duration) (*Data, error) }, nil } -func (u Middleware) createUserSessionCookie(sessionID string, path string) *http.Cookie { +func (u SessionMiddleware) createUserSessionCookie(sessionID string, path string) *http.Cookie { // Do not set Expires: then it isn't a session cookie anymore. return &http.Cookie{ Name: userSessionCookieName, @@ -167,10 +168,10 @@ func (u Middleware) createUserSessionCookie(sessionID string, path string) *http } } -// Get retrieves the user session from the request context. +// GetSession retrieves the user session from the request context. // If the user session is not found, an error is returned. -func Get(ctx context.Context) (*Data, error) { - result, ok := ctx.Value(userSessionContextKey).(*Data) +func GetSession(ctx context.Context) (*Session, error) { + result, ok := ctx.Value(userSessionContextKey).(*Session) if !ok { return nil, errors.New("no user session found") } @@ -202,3 +203,47 @@ func generateUserSessionJWK() (jwk.Key, *did.DID, error) { return userJWK, userDID, nil } + +// Session is a session-bound Verifiable Credential wallet. +type Session struct { + // Save is a function that persists the session. + Save func() error `json:"-"` + // TenantDID is the requesting DID when the user session was created, typically the employer's (of the user) DID. + // A session needs to be scoped to the tenant DID, since the session gives access to the tenant's wallet, + // and the user session might contain session-bound credentials (e.g. EmployeeCredential) that were issued by the tenant. + TenantDID did.DID `json:"tenantDID"` + Wallet Wallet `json:"wallet"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// Wallet is a session-bound Verifiable Credential wallet. +// It's an in-memory wallet which contains the user's private key in plain text. +// This is OK, since the associated credentials are intended for protocol compatibility (OpenID4VP with a low-assurance EmployeeCredential), +// when an actual user wallet is involved, this wallet isn't used. +type Wallet struct { + Credentials []vc.VerifiableCredential + // JWK is an in-memory key pair associated with the user's wallet in JWK form. + JWK []byte + // DID is the did:jwk DID of the user's wallet. + DID did.DID +} + +// Key returns the JWK as jwk.Key +func (w Wallet) Key() (jwk.Key, error) { + set, err := jwk.Parse(w.JWK) + if err != nil { + return nil, fmt.Errorf("failed to parse JWK: %w", err) + } + result, available := set.Key(0) + if !available { + return nil, errors.New("expected exactly 1 key in the JWK set") + } + return result, nil +} + +// CookieReader is an interface for reading cookies from an HTTP request. +// It is implemented by echo.Context and http.Request. +type CookieReader interface { + // Cookie returns the named cookie provided in the request. + Cookie(name string) (*http.Cookie, error) +} diff --git a/auth/api/iam/usersession/user_session_test.go b/http/user/session_test.go similarity index 90% rename from auth/api/iam/usersession/user_session_test.go rename to http/user/session_test.go index 12c4945893..9320d69fb4 100644 --- a/auth/api/iam/usersession/user_session_test.go +++ b/http/user/session_test.go @@ -16,10 +16,15 @@ * */ -package usersession +package user import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" "github.com/labstack/echo/v4" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/storage" @@ -62,10 +67,10 @@ func TestMiddleware_Handle(t *testing.T) { echoContext.SetParamNames("did") echoContext.SetParamValues(tenantDID.String()) - var capturedSession *Data + var capturedSession *Session err := instance.Handle(func(c echo.Context) error { var err error - capturedSession, err = Get(c.Request().Context()) + capturedSession, err = GetSession(c.Request().Context()) return err })(echoContext) @@ -73,7 +78,7 @@ func TestMiddleware_Handle(t *testing.T) { assert.NotNil(t, capturedSession) assert.Equal(t, tenantDID, capturedSession.TenantDID) // Assert stored session - var storedSession = new(Data) + var storedSession = new(Session) cookie := httpResponse.Result().Cookies()[0] require.NoError(t, sessionStore.Get(cookie.Value, storedSession)) assert.Equal(t, tenantDID, storedSession.TenantDID) @@ -90,9 +95,9 @@ func TestMiddleware_Handle(t *testing.T) { echoContext.SetParamValues(tenantDID.String()) echoContext.Request().AddCookie(&sessionCookie) - var capturedSession *Data + var capturedSession *Session err := instance.Handle(func(c echo.Context) error { - capturedSession, _ = Get(c.Request().Context()) + capturedSession, _ = GetSession(c.Request().Context()) capturedSession.Wallet.Credentials = append(capturedSession.Wallet.Credentials, vc.VerifiableCredential{}) return capturedSession.Save() })(echoContext) @@ -160,10 +165,10 @@ func TestMiddleware_Handle(t *testing.T) { // Session is not in storage, so error will be triggered and new session be created echoContext.Request().AddCookie(&sessionCookie) - var capturedSession *Data + var capturedSession *Session err := instance.Handle(func(c echo.Context) error { var err error - capturedSession, err = Get(c.Request().Context()) + capturedSession, err = GetSession(c.Request().Context()) return err })(echoContext) @@ -215,9 +220,9 @@ func TestMiddleware_loadUserSession(t *testing.T) { }) t.Run("error - expired", func(t *testing.T) { instance, sessionStore := createInstance(t) - expected := Data{ + expected := Session{ TenantDID: tenantDID, - Wallet: UserWallet{ + Wallet: Wallet{ DID: userDID, }, ExpiresAt: time.Now().Add(-time.Hour), @@ -239,9 +244,9 @@ func Test_generateUserSessionJWK(t *testing.T) { assert.True(t, strings.HasPrefix(userDID.String(), "did:jwk:")) } -func createInstance(t *testing.T) (Middleware, storage.SessionStore) { +func createInstance(t *testing.T) (SessionMiddleware, storage.SessionStore) { store := storage.NewTestInMemorySessionDatabase(t).GetStore(time.Hour, "sessions") - return Middleware{ + return SessionMiddleware{ Skipper: func(c echo.Context) bool { return false }, @@ -254,7 +259,7 @@ func createInstance(t *testing.T) (Middleware, storage.SessionStore) { } func TestMiddleware_createUserSessionCookie(t *testing.T) { - cookie := Middleware{ + cookie := SessionMiddleware{ TimeOut: 30 * time.Minute, }.createUserSessionCookie("sessionID", "/iam/did:web:example.com:iam:123") assert.Equal(t, "/iam/did:web:example.com:iam:123", cookie.Path) @@ -266,3 +271,18 @@ func TestMiddleware_createUserSessionCookie(t *testing.T) { assert.True(t, cookie.Secure) assert.True(t, cookie.HttpOnly) } + +func TestUserWallet_Key(t *testing.T) { + t.Run("ok", func(t *testing.T) { + pk, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + keyAsJWK, err := jwk.FromRaw(pk) + require.NoError(t, err) + jwkAsJSON, _ := json.Marshal(keyAsJWK) + wallet := Wallet{ + JWK: jwkAsJSON, + } + key, err := wallet.Key() + require.NoError(t, err) + assert.Equal(t, keyAsJWK, key) + }) +} diff --git a/auth/api/iam/usersession/test.go b/http/user/test.go similarity index 95% rename from auth/api/iam/usersession/test.go rename to http/user/test.go index 27f78a424b..9960e923c4 100644 --- a/auth/api/iam/usersession/test.go +++ b/http/user/test.go @@ -16,7 +16,7 @@ * */ -package usersession +package user import ( "context" @@ -24,7 +24,7 @@ import ( "time" ) -func CreateTestSession(ctx context.Context, tenantDID did.DID) (context.Context, *Data) { +func CreateTestSession(ctx context.Context, tenantDID did.DID) (context.Context, *Session) { session, _ := createUserSession(tenantDID, time.Hour) session.Save = func() error { return nil