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: