From cbd7dd7f5a12c21017f74538cb4780370048beb4 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Wed, 18 Mar 2026 11:58:18 +0100 Subject: [PATCH] feat: Create AuthSessions and set cookies (#4650) Signed-off-by: maksim.nabokikh --- cmd/dex/config.go | 21 + cmd/dex/serve.go | 54 ++ examples/config-dev.yaml | 8 + server/handlers.go | 59 ++- server/server.go | 14 + server/session.go | 339 +++++++++++++ server/session_test.go | 586 ++++++++++++++++++++++ server/templates.go | 29 +- storage/conformance/conformance.go | 21 +- storage/ent/client/authsession.go | 27 +- storage/ent/client/types.go | 4 +- storage/ent/db/authsession.go | 35 +- storage/ent/db/authsession/authsession.go | 30 ++ storage/ent/db/authsession/where.go | 210 ++++++++ storage/ent/db/authsession_create.go | 54 ++ storage/ent/db/authsession_query.go | 8 +- storage/ent/db/authsession_update.go | 148 ++++++ storage/ent/db/migrate/schema.go | 3 + storage/ent/db/mutation.go | 164 +++++- storage/ent/db/runtime.go | 16 +- storage/ent/schema/authsession.go | 9 + storage/etcd/etcd.go | 18 +- storage/etcd/types.go | 12 +- storage/kubernetes/storage.go | 29 +- storage/kubernetes/types.go | 12 +- storage/memory/memory.go | 28 +- storage/sql/crud.go | 41 +- storage/sql/migrate.go | 7 +- storage/storage.go | 22 +- web/templates/password.html | 11 +- web/themes/dark/styles.css | 10 + web/themes/light/styles.css | 10 + 32 files changed, 1922 insertions(+), 117 deletions(-) create mode 100644 server/session.go create mode 100644 server/session_test.go diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 8efc3c38..32f8e832 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -65,6 +65,10 @@ type Config struct { // querying the storage. Cannot be specified without enabling a passwords // database. StaticPasswords []password `json:"staticPasswords"` + + // Sessions holds authentication session configuration. + // Requires DEX_SESSIONS_ENABLED=true feature flag. + Sessions *Sessions `json:"sessions"` } // Validate the configuration @@ -103,6 +107,11 @@ func (c Config) Validate() error { if len(checkErrors) != 0 { return fmt.Errorf("invalid Config:\n\t-\t%s", strings.Join(checkErrors, "\n\t-\t")) } + + if c.Sessions != nil && !featureflags.SessionsEnabled.Enabled() { + return fmt.Errorf("sessions config requires sessions to be enabled (DEX_SESSIONS_ENABLED=true)") + } + return nil } @@ -585,3 +594,15 @@ type RefreshToken struct { AbsoluteLifetime string `json:"absoluteLifetime"` ValidIfNotUsedFor string `json:"validIfNotUsedFor"` } + +// Sessions holds authentication session configuration. +type Sessions struct { + // CookieName is the name of the session cookie. Defaults to "dex_session". + CookieName string `json:"cookieName"` + // AbsoluteLifetime is the maximum session lifetime from creation. Defaults to "24h". + AbsoluteLifetime string `json:"absoluteLifetime"` + // ValidIfNotUsedFor is the idle timeout. Defaults to "1h". + ValidIfNotUsedFor string `json:"validIfNotUsedFor"` + // RememberMeCheckedByDefault controls the default state of the "remember me" checkbox. + RememberMeCheckedByDefault *bool `json:"rememberMeCheckedByDefault"` +} diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 5cc0877a..27d2502c 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -415,6 +415,19 @@ func runServe(options serveOptions) error { serverConfig.RefreshTokenPolicy = refreshTokenPolicy + if featureflags.SessionsEnabled.Enabled() { + sessionConfig, err := parseSessionConfig(c.Sessions) + if err != nil { + return fmt.Errorf("invalid session config: %v", err) + } + serverConfig.SessionConfig = sessionConfig + logger.Info("config sessions", + "cookie_name", sessionConfig.CookieName, + "absolute_lifetime", sessionConfig.AbsoluteLifetime, + "valid_if_not_used_for", sessionConfig.ValidIfNotUsedFor, + ) + } + serverConfig.RealIPHeader = c.Web.ClientRemoteIP.Header serverConfig.TrustedRealIPCIDRs, err = c.Web.ClientRemoteIP.ParseTrustedProxies() if err != nil { @@ -759,3 +772,44 @@ func loadTLSConfig(certFile, keyFile, caFile string, baseConfig *tls.Config) (*t func recordBuildInfo() { buildInfo.WithLabelValues(version, runtime.Version(), fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)).Set(1) } + +func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) { + sc := &server.SessionConfig{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + ValidIfNotUsedFor: 1 * time.Hour, + RememberMeCheckedByDefault: true, + } + if s != nil { + if s.CookieName != "" { + sc.CookieName = s.CookieName + } + if s.AbsoluteLifetime != "" { + d, err := time.ParseDuration(s.AbsoluteLifetime) + if err != nil { + return nil, fmt.Errorf("invalid absoluteLifetime %q: %v", s.AbsoluteLifetime, err) + } + sc.AbsoluteLifetime = d + } + if s.ValidIfNotUsedFor != "" { + d, err := time.ParseDuration(s.ValidIfNotUsedFor) + if err != nil { + return nil, fmt.Errorf("invalid validIfNotUsedFor %q: %v", s.ValidIfNotUsedFor, err) + } + sc.ValidIfNotUsedFor = d + } + if s.RememberMeCheckedByDefault != nil { + sc.RememberMeCheckedByDefault = *s.RememberMeCheckedByDefault + } + } + if sc.AbsoluteLifetime <= 0 { + return nil, fmt.Errorf("absoluteLifetime must be positive, got %v", sc.AbsoluteLifetime) + } + if sc.ValidIfNotUsedFor <= 0 { + return nil, fmt.Errorf("validIfNotUsedFor must be positive, got %v", sc.ValidIfNotUsedFor) + } + if sc.ValidIfNotUsedFor > sc.AbsoluteLifetime { + return nil, fmt.Errorf("validIfNotUsedFor (%v) must not exceed absoluteLifetime (%v)", sc.ValidIfNotUsedFor, sc.AbsoluteLifetime) + } + return sc, nil +} diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 0e8bb575..fe66df8e 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -95,6 +95,14 @@ telemetry: # validIfNotUsedFor: "2160h" # 90 days # absoluteLifetime: "3960h" # 165 days +# Authentication sessions configuration. +# Requires DEX_SESSIONS_ENABLED=true feature flag. +# sessions: +# cookieName: "dex_session" +# absoluteLifetime: "24h" +# validIfNotUsedFor: "1h" +# rememberMeCheckedByDefault: false + # Options for controlling the logger. # logger: # level: "debug" diff --git a/server/handlers.go b/server/handlers.go index 20fd85bf..360c46ea 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -365,6 +365,16 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { return } + // Check if there's a valid session that can skip login for this client. + if s.sessionConfig != nil { + if redirectURL, ok := s.trySessionLogin(ctx, r, w, authReq); ok { + if redirectURL != "" { + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + } + return + } + } + scopes := parseScopes(authReq.Scopes) // Work out where the "Select another login method" link should go. @@ -493,9 +503,11 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { return } + rememberMe := s.rememberMeDefault() + switch r.Method { case http.MethodGet: - if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink); err != nil { + if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink, rememberMe); err != nil { s.logger.ErrorContext(r.Context(), "server template error", "err", err) } case http.MethodPost: @@ -510,7 +522,7 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { return } if !ok { - if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink); err != nil { + if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink, rememberMe); err != nil { s.logger.ErrorContext(r.Context(), "server template error", "err", err) } s.logger.ErrorContext(r.Context(), "failed login attempt: Invalid credentials.", "user", username) @@ -523,13 +535,21 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { return } + // Re-read auth request after finalizeLogin populated Claims. + authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + + rememberMe := r.FormValue("remember_me") == "on" + if err := s.createOrUpdateAuthSession(ctx, r, w, authReq, rememberMe); err != nil { + s.logger.ErrorContext(ctx, "failed to create/update auth session", "err", err) + } + if canSkipApproval { - authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) - if err != nil { - s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } + // authReq was already re-read after finalizeLogin above. s.sendCodeResponse(w, r, authReq) return } @@ -628,13 +648,22 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) return } + // Re-read auth request after finalizeLogin populated Claims. + authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + + // Connector callbacks don't render the remember_me checkbox, so we use the server default. + // The password login handler reads r.FormValue("remember_me") from the submitted form instead. + if err := s.createOrUpdateAuthSession(ctx, r, w, authReq, s.sessionConfig != nil && s.sessionConfig.RememberMeCheckedByDefault); err != nil { + s.logger.ErrorContext(ctx, "failed to create/update auth session", "err", err) + } + if canSkipApproval { - authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) - if err != nil { - s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) - s.renderError(r, w, http.StatusInternalServerError, "Login error.") - return - } + // authReq was already re-read after finalizeLogin above. s.sendCodeResponse(w, r, authReq) return } @@ -854,6 +883,8 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { } func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) { + s.updateSessionTokenIssuedAt(r, authReq.ClientID) + ctx := r.Context() if s.now().After(authReq.Expiry) { s.renderError(r, w, http.StatusBadRequest, "User session has expired.") diff --git a/server/server.go b/server/server.go index e63cb278..4acf6eb8 100644 --- a/server/server.go +++ b/server/server.go @@ -137,6 +137,17 @@ type Config struct { // If enabled, the server will continue starting even if some connectors fail to initialize. // This allows the server to operate with a subset of connectors if some are misconfigured. ContinueOnConnectorFailure bool + + // SessionConfig holds session settings. Nil when sessions are disabled. + SessionConfig *SessionConfig +} + +// SessionConfig holds resolved session configuration. +type SessionConfig struct { + CookieName string + AbsoluteLifetime time.Duration + ValidIfNotUsedFor time.Duration + RememberMeCheckedByDefault bool } // WebConfig holds the server's frontend templates and asset configuration. @@ -226,6 +237,8 @@ type Server struct { logger *slog.Logger signer signer.Signer + + sessionConfig *SessionConfig } // NewServer constructs a server from the provided config. @@ -349,6 +362,7 @@ func newServer(ctx context.Context, c Config) (*Server, error) { passwordConnector: c.PasswordConnector, logger: c.Logger, signer: c.Signer, + sessionConfig: c.SessionConfig, } // Retrieves connector objects in backend storage. This list includes the static connectors diff --git a/server/session.go b/server/session.go new file mode 100644 index 00000000..f6355abf --- /dev/null +++ b/server/session.go @@ -0,0 +1,339 @@ +package server + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/http" + "path" + "strings" + + "github.com/dexidp/dex/storage" +) + +// rememberMeDefault returns a pointer to the default remember-me value if sessions are enabled, nil otherwise. +func (s *Server) rememberMeDefault() *bool { + if s.sessionConfig == nil { + return nil + } + v := s.sessionConfig.RememberMeCheckedByDefault + return &v +} + +// remoteIP returns the real IP from context (set by parseRealIP middleware) or falls back to r.RemoteAddr. +func remoteIP(r *http.Request) string { + if ip, ok := r.Context().Value(RequestKeyRemoteIP).(string); ok && ip != "" { + return ip + } + return r.RemoteAddr +} + +// sessionCookieValue encodes session identity into a cookie value. +// Format: base64url(userID) + "." + base64url(connectorID) + "." + nonce +// TODO(nabokihms): consider cookie encoding +func sessionCookieValue(userID, connectorID, nonce string) string { + return base64.RawURLEncoding.EncodeToString([]byte(userID)) + + "." + base64.RawURLEncoding.EncodeToString([]byte(connectorID)) + + "." + nonce +} + +// parseSessionCookie decodes a session cookie value into its components. +func parseSessionCookie(value string) (userID, connectorID, nonce string, err error) { + parts := strings.SplitN(value, ".", 3) + if len(parts) != 3 { + return "", "", "", fmt.Errorf("invalid session cookie format") + } + + userIDBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", "", "", fmt.Errorf("decode userID: %w", err) + } + + connectorIDBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", "", "", fmt.Errorf("decode connectorID: %w", err) + } + + return string(userIDBytes), string(connectorIDBytes), parts[2], nil +} + +func (s *Server) sessionCookiePath() string { + if s.issuerURL.Path == "" { + return "/" + } + return s.issuerURL.Path +} + +func (s *Server) setSessionCookie(w http.ResponseWriter, userID, connectorID, nonce string, rememberMe bool) { + cookie := &http.Cookie{ + Name: s.sessionConfig.CookieName, + Value: sessionCookieValue(userID, connectorID, nonce), + Path: s.sessionCookiePath(), + HttpOnly: true, + Secure: s.issuerURL.Scheme == "https", + SameSite: http.SameSiteLaxMode, + } + if rememberMe { + cookie.MaxAge = int(s.sessionConfig.AbsoluteLifetime.Seconds()) + } + http.SetCookie(w, cookie) +} + +func (s *Server) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: s.sessionConfig.CookieName, + Value: "", + Path: s.sessionCookiePath(), + HttpOnly: true, + Secure: s.issuerURL.Scheme == "https", + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// getValidAuthSession returns a valid, non-expired session or nil. +// It parses the session cookie to extract (userID, connectorID, nonce), +// looks up the session by composite key, and verifies the nonce. +// Invalid or expired session cookies are cleared automatically. +func (s *Server) getValidAuthSession(ctx context.Context, w http.ResponseWriter, r *http.Request, authReq *storage.AuthRequest) *storage.AuthSession { + if s.sessionConfig == nil { + return nil + } + + cookie, err := r.Cookie(s.sessionConfig.CookieName) + if err != nil || cookie.Value == "" { + return nil + } + + userID, connectorID, nonce, err := parseSessionCookie(cookie.Value) + if err != nil { + s.logger.DebugContext(ctx, "invalid session cookie format", "err", err) + s.clearSessionCookie(w) + return nil + } + + session, err := s.storage.GetAuthSession(ctx, userID, connectorID) + if err != nil { + if !errors.Is(err, storage.ErrNotFound) { + s.logger.ErrorContext(ctx, "failed to get auth session", "err", err) + } + s.clearSessionCookie(w) + return nil + } + + // Verify nonce to prevent cookie forgery. + if session.Nonce != nonce { + s.logger.DebugContext(ctx, "auth session nonce mismatch") + s.clearSessionCookie(w) + return nil + } + + now := s.now() + + // Check absolute lifetime. + if now.After(session.CreatedAt.Add(s.sessionConfig.AbsoluteLifetime)) { + s.logger.InfoContext(ctx, "auth session expired (absolute lifetime)", + "user_id", session.UserID, "connector_id", session.ConnectorID) + if err := s.storage.DeleteAuthSession(ctx, session.UserID, session.ConnectorID); err != nil { + s.logger.DebugContext(ctx, "failed to delete expired auth session", "err", err) + } + s.clearSessionCookie(w) + return nil + } + + // Check idle timeout. + if now.After(session.LastActivity.Add(s.sessionConfig.ValidIfNotUsedFor)) { + s.logger.InfoContext(ctx, "auth session expired (idle timeout)", + "user_id", session.UserID, "connector_id", session.ConnectorID) + if err := s.storage.DeleteAuthSession(ctx, session.UserID, session.ConnectorID); err != nil { + s.logger.DebugContext(ctx, "failed to delete expired auth session", "err", err) + } + s.clearSessionCookie(w) + return nil + } + + // Only reuse sessions from the same connector. + if session.ConnectorID != authReq.ConnectorID { + return nil + } + + return &session +} + +// createOrUpdateAuthSession creates a new session or updates an existing one +// after a successful login, and sets the session cookie. +// rememberMe controls whether the cookie is persistent (survives browser close). +func (s *Server) createOrUpdateAuthSession(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq storage.AuthRequest, rememberMe bool) error { + if s.sessionConfig == nil { + return nil + } + + now := s.now() + userID := authReq.Claims.UserID + connectorID := authReq.ConnectorID + + clientState := &storage.ClientAuthState{ + Active: true, + ExpiresAt: now.Add(s.sessionConfig.AbsoluteLifetime), + LastActivity: now, + } + + // Try to reuse existing session for this (userID, connectorID). + session, err := s.storage.GetAuthSession(ctx, userID, connectorID) + if err == nil { + // Session exists, update it. + s.logger.DebugContext(ctx, "updating existing auth session", + "user_id", userID, "connector_id", connectorID, "client_id", authReq.ClientID) + + if err := s.storage.UpdateAuthSession(ctx, userID, connectorID, func(old storage.AuthSession) (storage.AuthSession, error) { + old.LastActivity = now + if old.ClientStates == nil { + old.ClientStates = make(map[string]*storage.ClientAuthState) + } + old.ClientStates[authReq.ClientID] = clientState + return old, nil + }); err != nil { + return fmt.Errorf("update auth session: %w", err) + } + + s.setSessionCookie(w, userID, connectorID, session.Nonce, rememberMe) + return nil + } + + // Unexpected error, exit the method. + if !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("get auth session: %w", err) + } + + nonce := storage.NewID() + newSession := storage.AuthSession{ + UserID: userID, + ConnectorID: connectorID, + Nonce: nonce, + ClientStates: map[string]*storage.ClientAuthState{ + authReq.ClientID: clientState, + }, + CreatedAt: now, + LastActivity: now, + IPAddress: remoteIP(r), + UserAgent: r.UserAgent(), + } + + if err := s.storage.CreateAuthSession(ctx, newSession); err != nil { + return fmt.Errorf("create auth session: %w", err) + } + + s.logger.DebugContext(ctx, "created new auth session", + "user_id", userID, "connector_id", connectorID, "client_id", authReq.ClientID) + s.setSessionCookie(w, userID, connectorID, nonce, rememberMe) + return nil +} + +// trySessionLogin checks if the user has a valid session for the same connector. +// If so, it finalizes login from the stored identity and returns a redirect URL. +// Returns ("", false) if session-based login is not possible. +func (s *Server) trySessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest) (string, bool) { + session := s.getValidAuthSession(ctx, w, r, authReq) + if session == nil { + return "", false + } + + clientState, ok := session.ClientStates[authReq.ClientID] + if !ok || !clientState.Active { + return "", false + } + + now := s.now() + if now.After(clientState.ExpiresAt) { + return "", false + } + + // Load identity from storage. + ui, err := s.storage.GetUserIdentity(ctx, session.UserID, session.ConnectorID) + if err != nil { + s.logger.ErrorContext(ctx, "session: failed to get user identity", "err", err) + return "", false + } + + claims := storage.Claims{ + UserID: ui.Claims.UserID, + Username: ui.Claims.Username, + PreferredUsername: ui.Claims.PreferredUsername, + Email: ui.Claims.Email, + EmailVerified: ui.Claims.EmailVerified, + Groups: ui.Claims.Groups, + } + + // Update AuthRequest with stored identity (without logging "login successful"). + if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.LoggedIn = true + a.Claims = claims + a.ConnectorID = session.ConnectorID + return a, nil + }); err != nil { + s.logger.ErrorContext(ctx, "session: failed to update auth request", "err", err) + return "", false + } + + s.logger.DebugContext(ctx, "session: re-authenticated from session", + "user_id", session.UserID, "connector_id", session.ConnectorID) + + // Update session activity. + _ = s.storage.UpdateAuthSession(ctx, session.UserID, session.ConnectorID, func(old storage.AuthSession) (storage.AuthSession, error) { + old.LastActivity = now + if cs, ok := old.ClientStates[authReq.ClientID]; ok { + cs.LastActivity = now + } + return old, nil + }) + + // Build HMAC for approval URL. + h := hmac.New(sha256.New, authReq.HMACKey) + h.Write([]byte(authReq.ID)) + mac := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + // Skip approval if globally configured or user already consented to the requested scopes. + if !authReq.ForceApprovalPrompt && (s.skipApproval || scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes)) { + // Re-read to get the updated AuthRequest (LoggedIn, Claims, ConnectorID set above). + updated, err := s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(ctx, "session: failed to get auth request", "err", err) + return "", false + } + s.sendCodeResponse(w, r, updated) + return "", true + } + + returnURL := path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID + "&hmac=" + mac + return returnURL, true +} + +// updateSessionTokenIssuedAt updates the session's LastTokenIssuedAt for the given client. +func (s *Server) updateSessionTokenIssuedAt(r *http.Request, clientID string) { + if s.sessionConfig == nil { + return + } + + cookie, err := r.Cookie(s.sessionConfig.CookieName) + if err != nil || cookie.Value == "" { + return + } + + userID, connectorID, _, err := parseSessionCookie(cookie.Value) + if err != nil { + return + } + + now := s.now() + _ = s.storage.UpdateAuthSession(r.Context(), userID, connectorID, func(old storage.AuthSession) (storage.AuthSession, error) { + old.LastActivity = now + if cs, ok := old.ClientStates[clientID]; ok { + cs.LastTokenIssuedAt = now + cs.LastActivity = now + } + return old, nil + }) +} diff --git a/server/session_test.go b/server/session_test.go new file mode 100644 index 00000000..757c11b3 --- /dev/null +++ b/server/session_test.go @@ -0,0 +1,586 @@ +package server + +import ( + "crypto" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/memory" +) + +func newTestSessionServer(t *testing.T) *Server { + t.Helper() + + now := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC) + issuerURL, err := url.Parse("https://example.com/dex") + require.NoError(t, err) + + return &Server{ + storage: memory.New(nil), + logger: slog.Default(), + now: func() time.Time { return now }, + sessionConfig: &SessionConfig{ + CookieName: "dex_session", + AbsoluteLifetime: 24 * time.Hour, + ValidIfNotUsedFor: 1 * time.Hour, + }, + issuerURL: *issuerURL, + } +} + +func TestSetSessionCookie(t *testing.T) { + s := newTestSessionServer(t) + w := httptest.NewRecorder() + + s.setSessionCookie(w, "user1", "conn1", "nonce123", false) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + + c := cookies[0] + assert.Equal(t, "dex_session", c.Name) + assert.Equal(t, sessionCookieValue("user1", "conn1", "nonce123"), c.Value) + assert.Equal(t, "/dex", c.Path) + assert.True(t, c.HttpOnly) + assert.True(t, c.Secure) + assert.Equal(t, http.SameSiteLaxMode, c.SameSite) +} + +func TestSetSessionCookie_HTTP(t *testing.T) { + s := newTestSessionServer(t) + u, _ := url.Parse("http://localhost:5556/dex") + s.issuerURL = *u + w := httptest.NewRecorder() + + s.setSessionCookie(w, "user1", "conn1", "nonce123", false) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + assert.False(t, cookies[0].Secure) +} + +func TestClearSessionCookie(t *testing.T) { + s := newTestSessionServer(t) + w := httptest.NewRecorder() + + s.clearSessionCookie(w) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, -1, cookies[0].MaxAge) + assert.Equal(t, "", cookies[0].Value) +} + +func TestSessionCookieValueRoundtrip(t *testing.T) { + tests := []struct { + name string + userID string + connectorID string + nonce string + }{ + {"simple", "user1", "ldap", "abc123"}, + {"with special chars", "user@example.com", "oidc-provider", "xyz789"}, + {"unicode", "юзер", "коннектор", "nonce"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value := sessionCookieValue(tt.userID, tt.connectorID, tt.nonce) + gotUser, gotConn, gotNonce, err := parseSessionCookie(value) + require.NoError(t, err) + assert.Equal(t, tt.userID, gotUser) + assert.Equal(t, tt.connectorID, gotConn) + assert.Equal(t, tt.nonce, gotNonce) + }) + } +} + +func TestParseSessionCookie_Invalid(t *testing.T) { + //nolint:dogsled // only for tests + _, _, _, err := parseSessionCookie("invalid") + assert.Error(t, err) + //nolint:dogsled // only for tests + _, _, _, err = parseSessionCookie("a.b") + assert.Error(t, err) +} + +func TestGetValidAuthSession(t *testing.T) { + ctx := t.Context() + authReq := &storage.AuthRequest{ConnectorID: "conn1"} + + t.Run("no session config", func(t *testing.T) { + s := newTestSessionServer(t) + s.sessionConfig = nil + r := httptest.NewRequest(http.MethodGet, "/", nil) + assert.Nil(t, s.getValidAuthSession(ctx, httptest.NewRecorder(), r, authReq)) + }) + + t.Run("no cookie", func(t *testing.T) { + s := newTestSessionServer(t) + r := httptest.NewRequest(http.MethodGet, "/", nil) + assert.Nil(t, s.getValidAuthSession(ctx, httptest.NewRecorder(), r, authReq)) + }) + + t.Run("invalid cookie format", func(t *testing.T) { + s := newTestSessionServer(t) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: "invalid-format"}) + w := httptest.NewRecorder() + assert.Nil(t, s.getValidAuthSession(ctx, w, r, authReq)) + // Cookie should be cleared. + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + }) + + t.Run("session not found", func(t *testing.T) { + s := newTestSessionServer(t) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("nouser", "noconn", "nonce")}) + w := httptest.NewRecorder() + assert.Nil(t, s.getValidAuthSession(ctx, w, r, authReq)) + // Cookie should be cleared. + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + }) + + t.Run("valid session", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.now() + nonce := "test-nonce" + + session := storage.AuthSession{ + UserID: "user1", + ConnectorID: "conn1", + Nonce: nonce, + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-5 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "conn1", nonce)}) + + result := s.getValidAuthSession(ctx, httptest.NewRecorder(), r, authReq) + require.NotNil(t, result) + assert.Equal(t, "user1", result.UserID) + assert.Equal(t, "conn1", result.ConnectorID) + }) + + t.Run("connector mismatch", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.now() + nonce := "test-nonce-conn" + + session := storage.AuthSession{ + UserID: "user1", + ConnectorID: "ldap", + Nonce: nonce, + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-5 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "ldap", nonce)}) + + githubReq := &storage.AuthRequest{ConnectorID: "github"} + assert.Nil(t, s.getValidAuthSession(ctx, httptest.NewRecorder(), r, githubReq)) + }) + + t.Run("nonce mismatch", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.now() + + session := storage.AuthSession{ + UserID: "user2", + ConnectorID: "conn2", + Nonce: "correct-nonce", + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-5 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user2", "conn2", "wrong-nonce")}) + + conn2Req := &storage.AuthRequest{ConnectorID: "conn2"} + w := httptest.NewRecorder() + assert.Nil(t, s.getValidAuthSession(ctx, w, r, conn2Req)) + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + }) + + t.Run("expired absolute lifetime", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.now() + nonce := "expired-nonce" + + session := storage.AuthSession{ + UserID: "user3", + ConnectorID: "conn3", + Nonce: nonce, + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-25 * time.Hour), + LastActivity: now.Add(-1 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user3", "conn3", nonce)}) + + conn3Req := &storage.AuthRequest{ConnectorID: "conn3"} + w := httptest.NewRecorder() + assert.Nil(t, s.getValidAuthSession(ctx, w, r, conn3Req)) + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + + // Session should be deleted. + _, err := s.storage.GetAuthSession(ctx, "user3", "conn3") + assert.ErrorIs(t, err, storage.ErrNotFound) + }) + + t.Run("expired idle timeout", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.now() + nonce := "idle-nonce" + + session := storage.AuthSession{ + UserID: "user4", + ConnectorID: "conn4", + Nonce: nonce, + ClientStates: map[string]*storage.ClientAuthState{}, + CreatedAt: now.Add(-2 * time.Hour), + LastActivity: now.Add(-2 * time.Hour), + IPAddress: "127.0.0.1", + UserAgent: "test", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, session)) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user4", "conn4", nonce)}) + + conn4Req := &storage.AuthRequest{ConnectorID: "conn4"} + w := httptest.NewRecorder() + assert.Nil(t, s.getValidAuthSession(ctx, w, r, conn4Req)) + assert.Equal(t, -1, w.Result().Cookies()[0].MaxAge) + + // Session should be deleted. + _, err := s.storage.GetAuthSession(ctx, "user4", "conn4") + assert.ErrorIs(t, err, storage.ErrNotFound) + }) +} + +func TestCreateOrUpdateAuthSession(t *testing.T) { + ctx := t.Context() + + t.Run("create new session", func(t *testing.T) { + s := newTestSessionServer(t) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + authReq := storage.AuthRequest{ + ID: "auth-1", + ClientID: "client-1", + Claims: storage.Claims{UserID: "user-1"}, + ConnectorID: "mock", + } + + err := s.createOrUpdateAuthSession(ctx, r, w, authReq, false) + require.NoError(t, err) + + // Cookie should be set. + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + + userID, connectorID, nonce, err := parseSessionCookie(cookies[0].Value) + require.NoError(t, err) + assert.Equal(t, "user-1", userID) + assert.Equal(t, "mock", connectorID) + assert.NotEmpty(t, nonce) + + // Session should exist in storage. + session, err := s.storage.GetAuthSession(ctx, "user-1", "mock") + require.NoError(t, err) + assert.Equal(t, "user-1", session.UserID) + assert.Equal(t, "mock", session.ConnectorID) + require.Contains(t, session.ClientStates, "client-1") + assert.True(t, session.ClientStates["client-1"].Active) + }) + + t.Run("update existing session", func(t *testing.T) { + s := newTestSessionServer(t) + now := s.now() + nonce := "existing-nonce" + + existingSession := storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + Nonce: nonce, + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": { + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + LastActivity: now.Add(-10 * time.Minute), + }, + }, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-10 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + } + require.NoError(t, s.storage.CreateAuthSession(ctx, existingSession)) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + authReq := storage.AuthRequest{ + ID: "auth-2", + ClientID: "client-2", + Claims: storage.Claims{UserID: "user-1"}, + ConnectorID: "mock", + } + + err := s.createOrUpdateAuthSession(ctx, r, w, authReq, false) + require.NoError(t, err) + + // Cookie should be set with existing nonce. + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + _, _, gotNonce, err := parseSessionCookie(cookies[0].Value) + require.NoError(t, err) + assert.Equal(t, nonce, gotNonce) + + // Session should have both clients. + session, err := s.storage.GetAuthSession(ctx, "user-1", "mock") + require.NoError(t, err) + assert.Len(t, session.ClientStates, 2) + assert.Contains(t, session.ClientStates, "client-1") + assert.Contains(t, session.ClientStates, "client-2") + }) + + t.Run("nil session config", func(t *testing.T) { + s := newTestSessionServer(t) + s.sessionConfig = nil + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + err := s.createOrUpdateAuthSession(ctx, r, w, storage.AuthRequest{}, false) + assert.NoError(t, err) + assert.Empty(t, w.Result().Cookies()) + }) +} + +// setupSessionLoginFixture creates the necessary storage objects for trySessionLogin tests. +func setupSessionLoginFixture(t *testing.T, s *Server) storage.AuthRequest { + t.Helper() + ctx := t.Context() + now := s.now() + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-1", + ConnectorID: "mock", + Nonce: "test-nonce", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": { + Active: true, + ExpiresAt: now.Add(24 * time.Hour), + LastActivity: now.Add(-1 * time.Minute), + }, + }, + CreatedAt: now.Add(-30 * time.Minute), + LastActivity: now.Add(-1 * time.Minute), + IPAddress: "127.0.0.1", + UserAgent: "test", + })) + + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-1", + ConnectorID: "mock", + Claims: storage.Claims{ + UserID: "user-1", + Username: "testuser", + Email: "test@example.com", + }, + Consents: map[string][]string{"client-1": {"openid", "email"}}, + CreatedAt: now.Add(-1 * time.Hour), + LastLogin: now.Add(-30 * time.Minute), + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: "client-1", + ConnectorID: "mock", + Scopes: []string{"openid", "email"}, + RedirectURI: "http://localhost/callback", + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + return authReq +} + +func sessionCookieRequest(userID, connectorID, nonce string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue(userID, connectorID, nonce)}) + return r +} + +func TestTrySessionLogin(t *testing.T) { + ctx := t.Context() + + t.Run("no session", func(t *testing.T) { + s := newTestSessionServer(t) + authReq := storage.AuthRequest{ConnectorID: "mock"} + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok) + }) + + t.Run("successful login with skipApproval", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = true + authReq := setupSessionLoginFixture(t, s) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.True(t, ok) + // sendCodeResponse deletes the AuthRequest after processing, + // so we can't verify it here. The fact that ok=true is sufficient. + }) + + t.Run("successful login redirects to approval", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = false + authReq := setupSessionLoginFixture(t, s) + authReq.ForceApprovalPrompt = true + + require.NoError(t, s.storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.ForceApprovalPrompt = true + return a, nil + })) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.True(t, ok) + assert.Contains(t, redirectURL, "/approval") + assert.Contains(t, redirectURL, "req="+authReq.ID) + }) + + t.Run("skips approval when consent already given", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = false + authReq := setupSessionLoginFixture(t, s) + // Scopes match stored consent: {"client-1": {"openid", "email"}} + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.True(t, ok) + }) + + t.Run("connector mismatch returns false", func(t *testing.T) { + s := newTestSessionServer(t) + authReq := setupSessionLoginFixture(t, s) + authReq.ConnectorID = "github" + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok) + }) + + t.Run("no client state for requested client", func(t *testing.T) { + s := newTestSessionServer(t) + authReq := setupSessionLoginFixture(t, s) + authReq.ClientID = "unknown-client" + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok) + }) + + t.Run("expired client state returns false", func(t *testing.T) { + s := newTestSessionServer(t) + ctx := t.Context() + now := s.now() + + require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{ + UserID: "user-exp", + ConnectorID: "mock", + Nonce: "nonce-exp", + ClientStates: map[string]*storage.ClientAuthState{ + "client-1": { + Active: true, + ExpiresAt: now.Add(-1 * time.Hour), // expired + }, + }, + CreatedAt: now.Add(-2 * time.Hour), + LastActivity: now.Add(-1 * time.Minute), + })) + + require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{ + UserID: "user-exp", + ConnectorID: "mock", + Claims: storage.Claims{UserID: "user-exp"}, + Consents: make(map[string][]string), + CreatedAt: now, + LastLogin: now, + })) + + authReq := storage.AuthRequest{ + ID: storage.NewID(), + ClientID: "client-1", + ConnectorID: "mock", + HMACKey: storage.NewHMACKey(crypto.SHA256), + Expiry: now.Add(10 * time.Minute), + } + require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq)) + + r := sessionCookieRequest("user-exp", "mock", "nonce-exp") + w := httptest.NewRecorder() + + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + assert.False(t, ok) + }) + + t.Run("updates session activity", func(t *testing.T) { + s := newTestSessionServer(t) + s.skipApproval = true + authReq := setupSessionLoginFixture(t, s) + + r := sessionCookieRequest("user-1", "mock", "test-nonce") + w := httptest.NewRecorder() + + _, ok := s.trySessionLogin(ctx, r, w, &authReq) + require.True(t, ok) + + session, err := s.storage.GetAuthSession(ctx, "user-1", "mock") + require.NoError(t, err) + assert.Equal(t, s.now(), session.LastActivity) + }) +} diff --git a/server/templates.go b/server/templates.go index b77663e1..b377d056 100644 --- a/server/templates.go +++ b/server/templates.go @@ -291,18 +291,31 @@ func (t *templates) login(r *http.Request, w http.ResponseWriter, connectors []c return renderTemplate(w, t.loginTmpl, data) } -func (t *templates) password(r *http.Request, w http.ResponseWriter, postURL, lastUsername, usernamePrompt string, lastWasInvalid bool, backLink string) error { +func (t *templates) password(r *http.Request, w http.ResponseWriter, postURL, lastUsername, usernamePrompt string, lastWasInvalid bool, backLink string, rememberMe *bool) error { if lastWasInvalid { w.WriteHeader(http.StatusUnauthorized) } data := struct { - PostURL string - BackLink string - Username string - UsernamePrompt string - Invalid bool - ReqPath string - }{postURL, backLink, lastUsername, usernamePrompt, lastWasInvalid, r.URL.Path} + PostURL string + BackLink string + Username string + UsernamePrompt string + Invalid bool + ReqPath string + ShowRememberMe bool + RememberMeChecked bool + }{ + PostURL: postURL, + BackLink: backLink, + Username: lastUsername, + UsernamePrompt: usernamePrompt, + Invalid: lastWasInvalid, + ReqPath: r.URL.Path, + ShowRememberMe: rememberMe != nil, + } + if rememberMe != nil { + data.RememberMeChecked = *rememberMe + } return renderTemplate(w, t.passwordTmpl, data) } diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go index 94b23745..e4e30307 100644 --- a/storage/conformance/conformance.go +++ b/storage/conformance/conformance.go @@ -1174,11 +1174,11 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { now := time.Now().UTC().Round(time.Millisecond) session := storage.AuthSession{ - ID: storage.NewID(), + UserID: "user1", + ConnectorID: "conn1", + Nonce: storage.NewID(), ClientStates: map[string]*storage.ClientAuthState{ "client1": { - UserID: "user1", - ConnectorID: "conn1", Active: true, ExpiresAt: now.Add(24 * time.Hour), LastActivity: now, @@ -1201,7 +1201,7 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { mustBeErrAlreadyExists(t, "auth session", err) // Get and compare. - got, err := s.GetAuthSession(ctx, session.ID) + got, err := s.GetAuthSession(ctx, session.UserID, session.ConnectorID) if err != nil { t.Fatalf("get auth session: %v", err) } @@ -1219,10 +1219,8 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { // Update: add a new client state. newNow := now.Add(time.Minute) - if err := s.UpdateAuthSession(ctx, session.ID, func(old storage.AuthSession) (storage.AuthSession, error) { + if err := s.UpdateAuthSession(ctx, session.UserID, session.ConnectorID, func(old storage.AuthSession) (storage.AuthSession, error) { old.ClientStates["client2"] = &storage.ClientAuthState{ - UserID: "user2", - ConnectorID: "conn2", Active: true, ExpiresAt: newNow.Add(24 * time.Hour), LastActivity: newNow, @@ -1234,7 +1232,7 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { } // Get and verify update. - got, err = s.GetAuthSession(ctx, session.ID) + got, err = s.GetAuthSession(ctx, session.UserID, session.ConnectorID) if err != nil { t.Fatalf("get auth session after update: %v", err) } @@ -1244,9 +1242,6 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { if got.ClientStates["client2"] == nil { t.Fatal("expected client2 state to exist") } - if got.ClientStates["client2"].UserID != "user2" { - t.Errorf("expected client2 user_id to be user2, got %s", got.ClientStates["client2"].UserID) - } // List and verify. sessions, err := s.ListAuthSessions(ctx) @@ -1258,11 +1253,11 @@ func testAuthSessionCRUD(t *testing.T, s storage.Storage) { } // Delete. - if err := s.DeleteAuthSession(ctx, session.ID); err != nil { + if err := s.DeleteAuthSession(ctx, session.UserID, session.ConnectorID); err != nil { t.Fatalf("delete auth session: %v", err) } // Get deleted should return ErrNotFound. - _, err = s.GetAuthSession(ctx, session.ID) + _, err = s.GetAuthSession(ctx, session.UserID, session.ConnectorID) mustBeErrNotFound(t, "auth session", err) } diff --git a/storage/ent/client/authsession.go b/storage/ent/client/authsession.go index 439bdbe3..14120f1b 100644 --- a/storage/ent/client/authsession.go +++ b/storage/ent/client/authsession.go @@ -18,8 +18,12 @@ func (d *Database) CreateAuthSession(ctx context.Context, session storage.AuthSe return fmt.Errorf("encode client states auth session: %w", err) } + id := compositeKeyID(session.UserID, session.ConnectorID, d.hasher) _, err = d.client.AuthSession.Create(). - SetID(session.ID). + SetID(id). + SetUserID(session.UserID). + SetConnectorID(session.ConnectorID). + SetNonce(session.Nonce). SetClientStates(encodedStates). SetCreatedAt(session.CreatedAt). SetLastActivity(session.LastActivity). @@ -32,9 +36,10 @@ func (d *Database) CreateAuthSession(ctx context.Context, session storage.AuthSe return nil } -// GetAuthSession extracts an auth session from the database by session ID. -func (d *Database) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { - authSession, err := d.client.AuthSession.Get(ctx, sessionID) +// GetAuthSession extracts an auth session from the database by user ID and connector ID. +func (d *Database) GetAuthSession(ctx context.Context, userID, connectorID string) (storage.AuthSession, error) { + id := compositeKeyID(userID, connectorID, d.hasher) + authSession, err := d.client.AuthSession.Get(ctx, id) if err != nil { return storage.AuthSession{}, convertDBError("get auth session: %w", err) } @@ -55,9 +60,10 @@ func (d *Database) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, return storageAuthSessions, nil } -// DeleteAuthSession deletes an auth session from the database by session ID. -func (d *Database) DeleteAuthSession(ctx context.Context, sessionID string) error { - err := d.client.AuthSession.DeleteOneID(sessionID).Exec(ctx) +// DeleteAuthSession deletes an auth session from the database by user ID and connector ID. +func (d *Database) DeleteAuthSession(ctx context.Context, userID, connectorID string) error { + id := compositeKeyID(userID, connectorID, d.hasher) + err := d.client.AuthSession.DeleteOneID(id).Exec(ctx) if err != nil { return convertDBError("delete auth session: %w", err) } @@ -65,13 +71,14 @@ func (d *Database) DeleteAuthSession(ctx context.Context, sessionID string) erro } // UpdateAuthSession changes an auth session using an updater function. -func (d *Database) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { +func (d *Database) UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { + id := compositeKeyID(userID, connectorID, d.hasher) tx, err := d.BeginTx(ctx) if err != nil { return convertDBError("update auth session tx: %w", err) } - authSession, err := tx.AuthSession.Get(ctx, sessionID) + authSession, err := tx.AuthSession.Get(ctx, id) if err != nil { return rollback(tx, "update auth session database: %w", err) } @@ -90,7 +97,7 @@ func (d *Database) UpdateAuthSession(ctx context.Context, sessionID string, upda return rollback(tx, "encode client states auth session: %w", err) } - _, err = tx.AuthSession.UpdateOneID(sessionID). + _, err = tx.AuthSession.UpdateOneID(id). SetClientStates(encodedStates). SetLastActivity(newSession.LastActivity). SetIPAddress(newSession.IPAddress). diff --git a/storage/ent/client/types.go b/storage/ent/client/types.go index f8e99c4a..14bcdfc3 100644 --- a/storage/ent/client/types.go +++ b/storage/ent/client/types.go @@ -198,7 +198,9 @@ func toStorageUserIdentity(u *db.UserIdentity) storage.UserIdentity { func toStorageAuthSession(s *db.AuthSession) storage.AuthSession { result := storage.AuthSession{ - ID: s.ID, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, CreatedAt: s.CreatedAt, LastActivity: s.LastActivity, IPAddress: s.IPAddress, diff --git a/storage/ent/db/authsession.go b/storage/ent/db/authsession.go index b81479c7..26882da8 100644 --- a/storage/ent/db/authsession.go +++ b/storage/ent/db/authsession.go @@ -17,6 +17,12 @@ type AuthSession struct { config `json:"-"` // ID of the ent. ID string `json:"id,omitempty"` + // UserID holds the value of the "user_id" field. + UserID string `json:"user_id,omitempty"` + // ConnectorID holds the value of the "connector_id" field. + ConnectorID string `json:"connector_id,omitempty"` + // Nonce holds the value of the "nonce" field. + Nonce string `json:"nonce,omitempty"` // ClientStates holds the value of the "client_states" field. ClientStates []byte `json:"client_states,omitempty"` // CreatedAt holds the value of the "created_at" field. @@ -37,7 +43,7 @@ func (*AuthSession) scanValues(columns []string) ([]any, error) { switch columns[i] { case authsession.FieldClientStates: values[i] = new([]byte) - case authsession.FieldID, authsession.FieldIPAddress, authsession.FieldUserAgent: + case authsession.FieldID, authsession.FieldUserID, authsession.FieldConnectorID, authsession.FieldNonce, authsession.FieldIPAddress, authsession.FieldUserAgent: values[i] = new(sql.NullString) case authsession.FieldCreatedAt, authsession.FieldLastActivity: values[i] = new(sql.NullTime) @@ -62,6 +68,24 @@ func (_m *AuthSession) assignValues(columns []string, values []any) error { } else if value.Valid { _m.ID = value.String } + case authsession.FieldUserID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field user_id", values[i]) + } else if value.Valid { + _m.UserID = value.String + } + case authsession.FieldConnectorID: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field connector_id", values[i]) + } else if value.Valid { + _m.ConnectorID = value.String + } + case authsession.FieldNonce: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field nonce", values[i]) + } else if value.Valid { + _m.Nonce = value.String + } case authsession.FieldClientStates: if value, ok := values[i].(*[]byte); !ok { return fmt.Errorf("unexpected type %T for field client_states", values[i]) @@ -128,6 +152,15 @@ func (_m *AuthSession) String() string { var builder strings.Builder builder.WriteString("AuthSession(") builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("user_id=") + builder.WriteString(_m.UserID) + builder.WriteString(", ") + builder.WriteString("connector_id=") + builder.WriteString(_m.ConnectorID) + builder.WriteString(", ") + builder.WriteString("nonce=") + builder.WriteString(_m.Nonce) + builder.WriteString(", ") builder.WriteString("client_states=") builder.WriteString(fmt.Sprintf("%v", _m.ClientStates)) builder.WriteString(", ") diff --git a/storage/ent/db/authsession/authsession.go b/storage/ent/db/authsession/authsession.go index e2548f90..8e5bdfc2 100644 --- a/storage/ent/db/authsession/authsession.go +++ b/storage/ent/db/authsession/authsession.go @@ -11,6 +11,12 @@ const ( Label = "auth_session" // FieldID holds the string denoting the id field in the database. FieldID = "id" + // FieldUserID holds the string denoting the user_id field in the database. + FieldUserID = "user_id" + // FieldConnectorID holds the string denoting the connector_id field in the database. + FieldConnectorID = "connector_id" + // FieldNonce holds the string denoting the nonce field in the database. + FieldNonce = "nonce" // FieldClientStates holds the string denoting the client_states field in the database. FieldClientStates = "client_states" // FieldCreatedAt holds the string denoting the created_at field in the database. @@ -28,6 +34,9 @@ const ( // Columns holds all SQL columns for authsession fields. var Columns = []string{ FieldID, + FieldUserID, + FieldConnectorID, + FieldNonce, FieldClientStates, FieldCreatedAt, FieldLastActivity, @@ -46,6 +55,12 @@ func ValidColumn(column string) bool { } var ( + // UserIDValidator is a validator for the "user_id" field. It is called by the builders before save. + UserIDValidator func(string) error + // ConnectorIDValidator is a validator for the "connector_id" field. It is called by the builders before save. + ConnectorIDValidator func(string) error + // NonceValidator is a validator for the "nonce" field. It is called by the builders before save. + NonceValidator func(string) error // DefaultIPAddress holds the default value on creation for the "ip_address" field. DefaultIPAddress string // DefaultUserAgent holds the default value on creation for the "user_agent" field. @@ -62,6 +77,21 @@ func ByID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldID, opts...).ToFunc() } +// ByUserID orders the results by the user_id field. +func ByUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserID, opts...).ToFunc() +} + +// ByConnectorID orders the results by the connector_id field. +func ByConnectorID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldConnectorID, opts...).ToFunc() +} + +// ByNonce orders the results by the nonce field. +func ByNonce(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldNonce, opts...).ToFunc() +} + // ByCreatedAt orders the results by the created_at field. func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() diff --git a/storage/ent/db/authsession/where.go b/storage/ent/db/authsession/where.go index a4f52894..cdda7fb8 100644 --- a/storage/ent/db/authsession/where.go +++ b/storage/ent/db/authsession/where.go @@ -64,6 +64,21 @@ func IDContainsFold(id string) predicate.AuthSession { return predicate.AuthSession(sql.FieldContainsFold(FieldID, id)) } +// UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. +func UserID(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldUserID, v)) +} + +// ConnectorID applies equality check predicate on the "connector_id" field. It's identical to ConnectorIDEQ. +func ConnectorID(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldConnectorID, v)) +} + +// Nonce applies equality check predicate on the "nonce" field. It's identical to NonceEQ. +func Nonce(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldNonce, v)) +} + // ClientStates applies equality check predicate on the "client_states" field. It's identical to ClientStatesEQ. func ClientStates(v []byte) predicate.AuthSession { return predicate.AuthSession(sql.FieldEQ(FieldClientStates, v)) @@ -89,6 +104,201 @@ func UserAgent(v string) predicate.AuthSession { return predicate.AuthSession(sql.FieldEQ(FieldUserAgent, v)) } +// UserIDEQ applies the EQ predicate on the "user_id" field. +func UserIDEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldUserID, v)) +} + +// UserIDNEQ applies the NEQ predicate on the "user_id" field. +func UserIDNEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldUserID, v)) +} + +// UserIDIn applies the In predicate on the "user_id" field. +func UserIDIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldUserID, vs...)) +} + +// UserIDNotIn applies the NotIn predicate on the "user_id" field. +func UserIDNotIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldUserID, vs...)) +} + +// UserIDGT applies the GT predicate on the "user_id" field. +func UserIDGT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldUserID, v)) +} + +// UserIDGTE applies the GTE predicate on the "user_id" field. +func UserIDGTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldUserID, v)) +} + +// UserIDLT applies the LT predicate on the "user_id" field. +func UserIDLT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldUserID, v)) +} + +// UserIDLTE applies the LTE predicate on the "user_id" field. +func UserIDLTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldUserID, v)) +} + +// UserIDContains applies the Contains predicate on the "user_id" field. +func UserIDContains(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContains(FieldUserID, v)) +} + +// UserIDHasPrefix applies the HasPrefix predicate on the "user_id" field. +func UserIDHasPrefix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasPrefix(FieldUserID, v)) +} + +// UserIDHasSuffix applies the HasSuffix predicate on the "user_id" field. +func UserIDHasSuffix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasSuffix(FieldUserID, v)) +} + +// UserIDEqualFold applies the EqualFold predicate on the "user_id" field. +func UserIDEqualFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEqualFold(FieldUserID, v)) +} + +// UserIDContainsFold applies the ContainsFold predicate on the "user_id" field. +func UserIDContainsFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContainsFold(FieldUserID, v)) +} + +// ConnectorIDEQ applies the EQ predicate on the "connector_id" field. +func ConnectorIDEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldConnectorID, v)) +} + +// ConnectorIDNEQ applies the NEQ predicate on the "connector_id" field. +func ConnectorIDNEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldConnectorID, v)) +} + +// ConnectorIDIn applies the In predicate on the "connector_id" field. +func ConnectorIDIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldConnectorID, vs...)) +} + +// ConnectorIDNotIn applies the NotIn predicate on the "connector_id" field. +func ConnectorIDNotIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldConnectorID, vs...)) +} + +// ConnectorIDGT applies the GT predicate on the "connector_id" field. +func ConnectorIDGT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldConnectorID, v)) +} + +// ConnectorIDGTE applies the GTE predicate on the "connector_id" field. +func ConnectorIDGTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldConnectorID, v)) +} + +// ConnectorIDLT applies the LT predicate on the "connector_id" field. +func ConnectorIDLT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldConnectorID, v)) +} + +// ConnectorIDLTE applies the LTE predicate on the "connector_id" field. +func ConnectorIDLTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldConnectorID, v)) +} + +// ConnectorIDContains applies the Contains predicate on the "connector_id" field. +func ConnectorIDContains(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContains(FieldConnectorID, v)) +} + +// ConnectorIDHasPrefix applies the HasPrefix predicate on the "connector_id" field. +func ConnectorIDHasPrefix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasPrefix(FieldConnectorID, v)) +} + +// ConnectorIDHasSuffix applies the HasSuffix predicate on the "connector_id" field. +func ConnectorIDHasSuffix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasSuffix(FieldConnectorID, v)) +} + +// ConnectorIDEqualFold applies the EqualFold predicate on the "connector_id" field. +func ConnectorIDEqualFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEqualFold(FieldConnectorID, v)) +} + +// ConnectorIDContainsFold applies the ContainsFold predicate on the "connector_id" field. +func ConnectorIDContainsFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContainsFold(FieldConnectorID, v)) +} + +// NonceEQ applies the EQ predicate on the "nonce" field. +func NonceEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEQ(FieldNonce, v)) +} + +// NonceNEQ applies the NEQ predicate on the "nonce" field. +func NonceNEQ(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNEQ(FieldNonce, v)) +} + +// NonceIn applies the In predicate on the "nonce" field. +func NonceIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldIn(FieldNonce, vs...)) +} + +// NonceNotIn applies the NotIn predicate on the "nonce" field. +func NonceNotIn(vs ...string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldNotIn(FieldNonce, vs...)) +} + +// NonceGT applies the GT predicate on the "nonce" field. +func NonceGT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGT(FieldNonce, v)) +} + +// NonceGTE applies the GTE predicate on the "nonce" field. +func NonceGTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldGTE(FieldNonce, v)) +} + +// NonceLT applies the LT predicate on the "nonce" field. +func NonceLT(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLT(FieldNonce, v)) +} + +// NonceLTE applies the LTE predicate on the "nonce" field. +func NonceLTE(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldLTE(FieldNonce, v)) +} + +// NonceContains applies the Contains predicate on the "nonce" field. +func NonceContains(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContains(FieldNonce, v)) +} + +// NonceHasPrefix applies the HasPrefix predicate on the "nonce" field. +func NonceHasPrefix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasPrefix(FieldNonce, v)) +} + +// NonceHasSuffix applies the HasSuffix predicate on the "nonce" field. +func NonceHasSuffix(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldHasSuffix(FieldNonce, v)) +} + +// NonceEqualFold applies the EqualFold predicate on the "nonce" field. +func NonceEqualFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldEqualFold(FieldNonce, v)) +} + +// NonceContainsFold applies the ContainsFold predicate on the "nonce" field. +func NonceContainsFold(v string) predicate.AuthSession { + return predicate.AuthSession(sql.FieldContainsFold(FieldNonce, v)) +} + // ClientStatesEQ applies the EQ predicate on the "client_states" field. func ClientStatesEQ(v []byte) predicate.AuthSession { return predicate.AuthSession(sql.FieldEQ(FieldClientStates, v)) diff --git a/storage/ent/db/authsession_create.go b/storage/ent/db/authsession_create.go index a680d675..080b094c 100644 --- a/storage/ent/db/authsession_create.go +++ b/storage/ent/db/authsession_create.go @@ -20,6 +20,24 @@ type AuthSessionCreate struct { hooks []Hook } +// SetUserID sets the "user_id" field. +func (_c *AuthSessionCreate) SetUserID(v string) *AuthSessionCreate { + _c.mutation.SetUserID(v) + return _c +} + +// SetConnectorID sets the "connector_id" field. +func (_c *AuthSessionCreate) SetConnectorID(v string) *AuthSessionCreate { + _c.mutation.SetConnectorID(v) + return _c +} + +// SetNonce sets the "nonce" field. +func (_c *AuthSessionCreate) SetNonce(v string) *AuthSessionCreate { + _c.mutation.SetNonce(v) + return _c +} + // SetClientStates sets the "client_states" field. func (_c *AuthSessionCreate) SetClientStates(v []byte) *AuthSessionCreate { _c.mutation.SetClientStates(v) @@ -119,6 +137,30 @@ func (_c *AuthSessionCreate) defaults() { // check runs all checks and user-defined validators on the builder. func (_c *AuthSessionCreate) check() error { + if _, ok := _c.mutation.UserID(); !ok { + return &ValidationError{Name: "user_id", err: errors.New(`db: missing required field "AuthSession.user_id"`)} + } + if v, ok := _c.mutation.UserID(); ok { + if err := authsession.UserIDValidator(v); err != nil { + return &ValidationError{Name: "user_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.user_id": %w`, err)} + } + } + if _, ok := _c.mutation.ConnectorID(); !ok { + return &ValidationError{Name: "connector_id", err: errors.New(`db: missing required field "AuthSession.connector_id"`)} + } + if v, ok := _c.mutation.ConnectorID(); ok { + if err := authsession.ConnectorIDValidator(v); err != nil { + return &ValidationError{Name: "connector_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.connector_id": %w`, err)} + } + } + if _, ok := _c.mutation.Nonce(); !ok { + return &ValidationError{Name: "nonce", err: errors.New(`db: missing required field "AuthSession.nonce"`)} + } + if v, ok := _c.mutation.Nonce(); ok { + if err := authsession.NonceValidator(v); err != nil { + return &ValidationError{Name: "nonce", err: fmt.Errorf(`db: validator failed for field "AuthSession.nonce": %w`, err)} + } + } if _, ok := _c.mutation.ClientStates(); !ok { return &ValidationError{Name: "client_states", err: errors.New(`db: missing required field "AuthSession.client_states"`)} } @@ -174,6 +216,18 @@ func (_c *AuthSessionCreate) createSpec() (*AuthSession, *sqlgraph.CreateSpec) { _node.ID = id _spec.ID.Value = id } + if value, ok := _c.mutation.UserID(); ok { + _spec.SetField(authsession.FieldUserID, field.TypeString, value) + _node.UserID = value + } + if value, ok := _c.mutation.ConnectorID(); ok { + _spec.SetField(authsession.FieldConnectorID, field.TypeString, value) + _node.ConnectorID = value + } + if value, ok := _c.mutation.Nonce(); ok { + _spec.SetField(authsession.FieldNonce, field.TypeString, value) + _node.Nonce = value + } if value, ok := _c.mutation.ClientStates(); ok { _spec.SetField(authsession.FieldClientStates, field.TypeBytes, value) _node.ClientStates = value diff --git a/storage/ent/db/authsession_query.go b/storage/ent/db/authsession_query.go index dc3528f9..6550b55d 100644 --- a/storage/ent/db/authsession_query.go +++ b/storage/ent/db/authsession_query.go @@ -262,12 +262,12 @@ func (_q *AuthSessionQuery) Clone() *AuthSessionQuery { // Example: // // var v []struct { -// ClientStates []byte `json:"client_states,omitempty"` +// UserID string `json:"user_id,omitempty"` // Count int `json:"count,omitempty"` // } // // client.AuthSession.Query(). -// GroupBy(authsession.FieldClientStates). +// GroupBy(authsession.FieldUserID). // Aggregate(db.Count()). // Scan(ctx, &v) func (_q *AuthSessionQuery) GroupBy(field string, fields ...string) *AuthSessionGroupBy { @@ -285,11 +285,11 @@ func (_q *AuthSessionQuery) GroupBy(field string, fields ...string) *AuthSession // Example: // // var v []struct { -// ClientStates []byte `json:"client_states,omitempty"` +// UserID string `json:"user_id,omitempty"` // } // // client.AuthSession.Query(). -// Select(authsession.FieldClientStates). +// Select(authsession.FieldUserID). // Scan(ctx, &v) func (_q *AuthSessionQuery) Select(fields ...string) *AuthSessionSelect { _q.ctx.Fields = append(_q.ctx.Fields, fields...) diff --git a/storage/ent/db/authsession_update.go b/storage/ent/db/authsession_update.go index e91999bd..5457b04c 100644 --- a/storage/ent/db/authsession_update.go +++ b/storage/ent/db/authsession_update.go @@ -28,6 +28,48 @@ func (_u *AuthSessionUpdate) Where(ps ...predicate.AuthSession) *AuthSessionUpda return _u } +// SetUserID sets the "user_id" field. +func (_u *AuthSessionUpdate) SetUserID(v string) *AuthSessionUpdate { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableUserID(v *string) *AuthSessionUpdate { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetConnectorID sets the "connector_id" field. +func (_u *AuthSessionUpdate) SetConnectorID(v string) *AuthSessionUpdate { + _u.mutation.SetConnectorID(v) + return _u +} + +// SetNillableConnectorID sets the "connector_id" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableConnectorID(v *string) *AuthSessionUpdate { + if v != nil { + _u.SetConnectorID(*v) + } + return _u +} + +// SetNonce sets the "nonce" field. +func (_u *AuthSessionUpdate) SetNonce(v string) *AuthSessionUpdate { + _u.mutation.SetNonce(v) + return _u +} + +// SetNillableNonce sets the "nonce" field if the given value is not nil. +func (_u *AuthSessionUpdate) SetNillableNonce(v *string) *AuthSessionUpdate { + if v != nil { + _u.SetNonce(*v) + } + return _u +} + // SetClientStates sets the "client_states" field. func (_u *AuthSessionUpdate) SetClientStates(v []byte) *AuthSessionUpdate { _u.mutation.SetClientStates(v) @@ -122,7 +164,30 @@ func (_u *AuthSessionUpdate) ExecX(ctx context.Context) { } } +// check runs all checks and user-defined validators on the builder. +func (_u *AuthSessionUpdate) check() error { + if v, ok := _u.mutation.UserID(); ok { + if err := authsession.UserIDValidator(v); err != nil { + return &ValidationError{Name: "user_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.user_id": %w`, err)} + } + } + if v, ok := _u.mutation.ConnectorID(); ok { + if err := authsession.ConnectorIDValidator(v); err != nil { + return &ValidationError{Name: "connector_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.connector_id": %w`, err)} + } + } + if v, ok := _u.mutation.Nonce(); ok { + if err := authsession.NonceValidator(v); err != nil { + return &ValidationError{Name: "nonce", err: fmt.Errorf(`db: validator failed for field "AuthSession.nonce": %w`, err)} + } + } + return nil +} + func (_u *AuthSessionUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } _spec := sqlgraph.NewUpdateSpec(authsession.Table, authsession.Columns, sqlgraph.NewFieldSpec(authsession.FieldID, field.TypeString)) if ps := _u.mutation.predicates; len(ps) > 0 { _spec.Predicate = func(selector *sql.Selector) { @@ -131,6 +196,15 @@ func (_u *AuthSessionUpdate) sqlSave(ctx context.Context) (_node int, err error) } } } + if value, ok := _u.mutation.UserID(); ok { + _spec.SetField(authsession.FieldUserID, field.TypeString, value) + } + if value, ok := _u.mutation.ConnectorID(); ok { + _spec.SetField(authsession.FieldConnectorID, field.TypeString, value) + } + if value, ok := _u.mutation.Nonce(); ok { + _spec.SetField(authsession.FieldNonce, field.TypeString, value) + } if value, ok := _u.mutation.ClientStates(); ok { _spec.SetField(authsession.FieldClientStates, field.TypeBytes, value) } @@ -166,6 +240,48 @@ type AuthSessionUpdateOne struct { mutation *AuthSessionMutation } +// SetUserID sets the "user_id" field. +func (_u *AuthSessionUpdateOne) SetUserID(v string) *AuthSessionUpdateOne { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableUserID(v *string) *AuthSessionUpdateOne { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetConnectorID sets the "connector_id" field. +func (_u *AuthSessionUpdateOne) SetConnectorID(v string) *AuthSessionUpdateOne { + _u.mutation.SetConnectorID(v) + return _u +} + +// SetNillableConnectorID sets the "connector_id" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableConnectorID(v *string) *AuthSessionUpdateOne { + if v != nil { + _u.SetConnectorID(*v) + } + return _u +} + +// SetNonce sets the "nonce" field. +func (_u *AuthSessionUpdateOne) SetNonce(v string) *AuthSessionUpdateOne { + _u.mutation.SetNonce(v) + return _u +} + +// SetNillableNonce sets the "nonce" field if the given value is not nil. +func (_u *AuthSessionUpdateOne) SetNillableNonce(v *string) *AuthSessionUpdateOne { + if v != nil { + _u.SetNonce(*v) + } + return _u +} + // SetClientStates sets the "client_states" field. func (_u *AuthSessionUpdateOne) SetClientStates(v []byte) *AuthSessionUpdateOne { _u.mutation.SetClientStates(v) @@ -273,7 +389,30 @@ func (_u *AuthSessionUpdateOne) ExecX(ctx context.Context) { } } +// check runs all checks and user-defined validators on the builder. +func (_u *AuthSessionUpdateOne) check() error { + if v, ok := _u.mutation.UserID(); ok { + if err := authsession.UserIDValidator(v); err != nil { + return &ValidationError{Name: "user_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.user_id": %w`, err)} + } + } + if v, ok := _u.mutation.ConnectorID(); ok { + if err := authsession.ConnectorIDValidator(v); err != nil { + return &ValidationError{Name: "connector_id", err: fmt.Errorf(`db: validator failed for field "AuthSession.connector_id": %w`, err)} + } + } + if v, ok := _u.mutation.Nonce(); ok { + if err := authsession.NonceValidator(v); err != nil { + return &ValidationError{Name: "nonce", err: fmt.Errorf(`db: validator failed for field "AuthSession.nonce": %w`, err)} + } + } + return nil +} + func (_u *AuthSessionUpdateOne) sqlSave(ctx context.Context) (_node *AuthSession, err error) { + if err := _u.check(); err != nil { + return _node, err + } _spec := sqlgraph.NewUpdateSpec(authsession.Table, authsession.Columns, sqlgraph.NewFieldSpec(authsession.FieldID, field.TypeString)) id, ok := _u.mutation.ID() if !ok { @@ -299,6 +438,15 @@ func (_u *AuthSessionUpdateOne) sqlSave(ctx context.Context) (_node *AuthSession } } } + if value, ok := _u.mutation.UserID(); ok { + _spec.SetField(authsession.FieldUserID, field.TypeString, value) + } + if value, ok := _u.mutation.ConnectorID(); ok { + _spec.SetField(authsession.FieldConnectorID, field.TypeString, value) + } + if value, ok := _u.mutation.Nonce(); ok { + _spec.SetField(authsession.FieldNonce, field.TypeString, value) + } if value, ok := _u.mutation.ClientStates(); ok { _spec.SetField(authsession.FieldClientStates, field.TypeBytes, value) } diff --git a/storage/ent/db/migrate/schema.go b/storage/ent/db/migrate/schema.go index 786598c0..31c0d211 100644 --- a/storage/ent/db/migrate/schema.go +++ b/storage/ent/db/migrate/schema.go @@ -66,6 +66,9 @@ var ( // AuthSessionsColumns holds the columns for the "auth_sessions" table. AuthSessionsColumns = []*schema.Column{ {Name: "id", Type: field.TypeString, Unique: true, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "user_id", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "connector_id", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, + {Name: "nonce", Type: field.TypeString, Size: 2147483647, SchemaType: map[string]string{"mysql": "varchar(384)", "postgres": "text", "sqlite3": "text"}}, {Name: "client_states", Type: field.TypeBytes}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, {Name: "last_activity", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime(3)", "postgres": "timestamptz", "sqlite3": "timestamp"}}, diff --git a/storage/ent/db/mutation.go b/storage/ent/db/mutation.go index 748022c9..ffbb9b2e 100644 --- a/storage/ent/db/mutation.go +++ b/storage/ent/db/mutation.go @@ -2727,6 +2727,9 @@ type AuthSessionMutation struct { op Op typ string id *string + user_id *string + connector_id *string + nonce *string client_states *[]byte created_at *time.Time last_activity *time.Time @@ -2842,6 +2845,114 @@ func (m *AuthSessionMutation) IDs(ctx context.Context) ([]string, error) { } } +// SetUserID sets the "user_id" field. +func (m *AuthSessionMutation) SetUserID(s string) { + m.user_id = &s +} + +// UserID returns the value of the "user_id" field in the mutation. +func (m *AuthSessionMutation) UserID() (r string, exists bool) { + v := m.user_id + if v == nil { + return + } + return *v, true +} + +// OldUserID returns the old "user_id" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldUserID(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserID: %w", err) + } + return oldValue.UserID, nil +} + +// ResetUserID resets all changes to the "user_id" field. +func (m *AuthSessionMutation) ResetUserID() { + m.user_id = nil +} + +// SetConnectorID sets the "connector_id" field. +func (m *AuthSessionMutation) SetConnectorID(s string) { + m.connector_id = &s +} + +// ConnectorID returns the value of the "connector_id" field in the mutation. +func (m *AuthSessionMutation) ConnectorID() (r string, exists bool) { + v := m.connector_id + if v == nil { + return + } + return *v, true +} + +// OldConnectorID returns the old "connector_id" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldConnectorID(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldConnectorID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldConnectorID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldConnectorID: %w", err) + } + return oldValue.ConnectorID, nil +} + +// ResetConnectorID resets all changes to the "connector_id" field. +func (m *AuthSessionMutation) ResetConnectorID() { + m.connector_id = nil +} + +// SetNonce sets the "nonce" field. +func (m *AuthSessionMutation) SetNonce(s string) { + m.nonce = &s +} + +// Nonce returns the value of the "nonce" field in the mutation. +func (m *AuthSessionMutation) Nonce() (r string, exists bool) { + v := m.nonce + if v == nil { + return + } + return *v, true +} + +// OldNonce returns the old "nonce" field's value of the AuthSession entity. +// If the AuthSession object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AuthSessionMutation) OldNonce(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldNonce is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldNonce requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldNonce: %w", err) + } + return oldValue.Nonce, nil +} + +// ResetNonce resets all changes to the "nonce" field. +func (m *AuthSessionMutation) ResetNonce() { + m.nonce = nil +} + // SetClientStates sets the "client_states" field. func (m *AuthSessionMutation) SetClientStates(b []byte) { m.client_states = &b @@ -3056,7 +3167,16 @@ func (m *AuthSessionMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *AuthSessionMutation) Fields() []string { - fields := make([]string, 0, 5) + fields := make([]string, 0, 8) + if m.user_id != nil { + fields = append(fields, authsession.FieldUserID) + } + if m.connector_id != nil { + fields = append(fields, authsession.FieldConnectorID) + } + if m.nonce != nil { + fields = append(fields, authsession.FieldNonce) + } if m.client_states != nil { fields = append(fields, authsession.FieldClientStates) } @@ -3080,6 +3200,12 @@ func (m *AuthSessionMutation) Fields() []string { // schema. func (m *AuthSessionMutation) Field(name string) (ent.Value, bool) { switch name { + case authsession.FieldUserID: + return m.UserID() + case authsession.FieldConnectorID: + return m.ConnectorID() + case authsession.FieldNonce: + return m.Nonce() case authsession.FieldClientStates: return m.ClientStates() case authsession.FieldCreatedAt: @@ -3099,6 +3225,12 @@ func (m *AuthSessionMutation) Field(name string) (ent.Value, bool) { // database failed. func (m *AuthSessionMutation) OldField(ctx context.Context, name string) (ent.Value, error) { switch name { + case authsession.FieldUserID: + return m.OldUserID(ctx) + case authsession.FieldConnectorID: + return m.OldConnectorID(ctx) + case authsession.FieldNonce: + return m.OldNonce(ctx) case authsession.FieldClientStates: return m.OldClientStates(ctx) case authsession.FieldCreatedAt: @@ -3118,6 +3250,27 @@ func (m *AuthSessionMutation) OldField(ctx context.Context, name string) (ent.Va // type. func (m *AuthSessionMutation) SetField(name string, value ent.Value) error { switch name { + case authsession.FieldUserID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserID(v) + return nil + case authsession.FieldConnectorID: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetConnectorID(v) + return nil + case authsession.FieldNonce: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetNonce(v) + return nil case authsession.FieldClientStates: v, ok := value.([]byte) if !ok { @@ -3202,6 +3355,15 @@ func (m *AuthSessionMutation) ClearField(name string) error { // It returns an error if the field is not defined in the schema. func (m *AuthSessionMutation) ResetField(name string) error { switch name { + case authsession.FieldUserID: + m.ResetUserID() + return nil + case authsession.FieldConnectorID: + m.ResetConnectorID() + return nil + case authsession.FieldNonce: + m.ResetNonce() + return nil case authsession.FieldClientStates: m.ResetClientStates() return nil diff --git a/storage/ent/db/runtime.go b/storage/ent/db/runtime.go index 98c12ecc..3973c7e7 100644 --- a/storage/ent/db/runtime.go +++ b/storage/ent/db/runtime.go @@ -90,12 +90,24 @@ func init() { authrequest.IDValidator = authrequestDescID.Validators[0].(func(string) error) authsessionFields := schema.AuthSession{}.Fields() _ = authsessionFields + // authsessionDescUserID is the schema descriptor for user_id field. + authsessionDescUserID := authsessionFields[1].Descriptor() + // authsession.UserIDValidator is a validator for the "user_id" field. It is called by the builders before save. + authsession.UserIDValidator = authsessionDescUserID.Validators[0].(func(string) error) + // authsessionDescConnectorID is the schema descriptor for connector_id field. + authsessionDescConnectorID := authsessionFields[2].Descriptor() + // authsession.ConnectorIDValidator is a validator for the "connector_id" field. It is called by the builders before save. + authsession.ConnectorIDValidator = authsessionDescConnectorID.Validators[0].(func(string) error) + // authsessionDescNonce is the schema descriptor for nonce field. + authsessionDescNonce := authsessionFields[3].Descriptor() + // authsession.NonceValidator is a validator for the "nonce" field. It is called by the builders before save. + authsession.NonceValidator = authsessionDescNonce.Validators[0].(func(string) error) // authsessionDescIPAddress is the schema descriptor for ip_address field. - authsessionDescIPAddress := authsessionFields[4].Descriptor() + authsessionDescIPAddress := authsessionFields[7].Descriptor() // authsession.DefaultIPAddress holds the default value on creation for the ip_address field. authsession.DefaultIPAddress = authsessionDescIPAddress.Default.(string) // authsessionDescUserAgent is the schema descriptor for user_agent field. - authsessionDescUserAgent := authsessionFields[5].Descriptor() + authsessionDescUserAgent := authsessionFields[8].Descriptor() // authsession.DefaultUserAgent holds the default value on creation for the user_agent field. authsession.DefaultUserAgent = authsessionDescUserAgent.Default.(string) // authsessionDescID is the schema descriptor for id field. diff --git a/storage/ent/schema/authsession.go b/storage/ent/schema/authsession.go index f0e57110..ff76ab61 100644 --- a/storage/ent/schema/authsession.go +++ b/storage/ent/schema/authsession.go @@ -17,6 +17,15 @@ func (AuthSession) Fields() []ent.Field { SchemaType(textSchema). NotEmpty(). Unique(), + field.Text("user_id"). + SchemaType(textSchema). + NotEmpty(), + field.Text("connector_id"). + SchemaType(textSchema). + NotEmpty(), + field.Text("nonce"). + SchemaType(textSchema). + NotEmpty(), field.Bytes("client_states"), field.Time("created_at"). SchemaType(timeSchema), diff --git a/storage/etcd/etcd.go b/storage/etcd/etcd.go index c05f5631..e21acc9b 100644 --- a/storage/etcd/etcd.go +++ b/storage/etcd/etcd.go @@ -424,23 +424,23 @@ func (c *conn) ListUserIdentities(ctx context.Context) (identities []storage.Use } func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) error { - return c.txnCreate(ctx, keyAuthSession(s.ID), fromStorageAuthSession(s)) + return c.txnCreate(ctx, keyAuthSession(s.UserID, s.ConnectorID), fromStorageAuthSession(s)) } -func (c *conn) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { +func (c *conn) GetAuthSession(ctx context.Context, userID, connectorID string) (storage.AuthSession, error) { ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) defer cancel() var s AuthSession - if err := c.getKey(ctx, keyAuthSession(sessionID), &s); err != nil { + if err := c.getKey(ctx, keyAuthSession(userID, connectorID), &s); err != nil { return storage.AuthSession{}, err } return toStorageAuthSession(s), nil } -func (c *conn) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { +func (c *conn) UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) defer cancel() - return c.txnUpdate(ctx, keyAuthSession(sessionID), func(currentValue []byte) ([]byte, error) { + return c.txnUpdate(ctx, keyAuthSession(userID, connectorID), func(currentValue []byte) ([]byte, error) { var current AuthSession if len(currentValue) > 0 { if err := json.Unmarshal(currentValue, ¤t); err != nil { @@ -472,10 +472,10 @@ func (c *conn) ListAuthSessions(ctx context.Context) (sessions []storage.AuthSes return sessions, nil } -func (c *conn) DeleteAuthSession(ctx context.Context, sessionID string) error { +func (c *conn) DeleteAuthSession(ctx context.Context, userID, connectorID string) error { ctx, cancel := context.WithTimeout(ctx, defaultStorageTimeout) defer cancel() - return c.deleteKey(ctx, keyAuthSession(sessionID)) + return c.deleteKey(ctx, keyAuthSession(userID, connectorID)) } func (c *conn) CreateConnector(ctx context.Context, connector storage.Connector) error { @@ -673,8 +673,8 @@ func keyUserIdentity(userID, connectorID string) string { return userIdentityPrefix + strings.ToLower(userID+"|"+connectorID) } -func keyAuthSession(sessionID string) string { - return strings.ToLower(authSessionPrefix + sessionID) +func keyAuthSession(userID, connectorID string) string { + return authSessionPrefix + strings.ToLower(userID+"|"+connectorID) } func (c *conn) CreateDeviceRequest(ctx context.Context, d storage.DeviceRequest) error { diff --git a/storage/etcd/types.go b/storage/etcd/types.go index 3624de32..3dc8ef27 100644 --- a/storage/etcd/types.go +++ b/storage/etcd/types.go @@ -298,7 +298,9 @@ func toStorageUserIdentity(u UserIdentity) storage.UserIdentity { // AuthSession is a mirrored struct from storage with JSON struct tags. type AuthSession struct { - ID string `json:"id,omitempty"` + UserID string `json:"user_id,omitempty"` + ConnectorID string `json:"connector_id,omitempty"` + Nonce string `json:"nonce,omitempty"` ClientStates map[string]*storage.ClientAuthState `json:"client_states,omitempty"` CreatedAt time.Time `json:"created_at"` LastActivity time.Time `json:"last_activity"` @@ -308,7 +310,9 @@ type AuthSession struct { func fromStorageAuthSession(s storage.AuthSession) AuthSession { return AuthSession{ - ID: s.ID, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, ClientStates: s.ClientStates, CreatedAt: s.CreatedAt, LastActivity: s.LastActivity, @@ -319,7 +323,9 @@ func fromStorageAuthSession(s storage.AuthSession) AuthSession { func toStorageAuthSession(s AuthSession) storage.AuthSession { result := storage.AuthSession{ - ID: s.ID, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, ClientStates: s.ClientStates, CreatedAt: s.CreatedAt, LastActivity: s.LastActivity, diff --git a/storage/kubernetes/storage.go b/storage/kubernetes/storage.go index 55ea7455..b6d2990b 100644 --- a/storage/kubernetes/storage.go +++ b/storage/kubernetes/storage.go @@ -815,18 +815,27 @@ func (cli *client) CreateAuthSession(ctx context.Context, s storage.AuthSession) return cli.post(resourceAuthSession, cli.fromStorageAuthSession(s)) } -func (cli *client) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { +func (cli *client) getAuthSession(userID, connectorID string) (AuthSession, error) { var s AuthSession - if err := cli.get(resourceAuthSession, sessionID, &s); err != nil { + name := offlineTokenName(userID, connectorID, cli.hash) + if err := cli.get(resourceAuthSession, name, &s); err != nil { + return AuthSession{}, err + } + return s, nil +} + +func (cli *client) GetAuthSession(ctx context.Context, userID, connectorID string) (storage.AuthSession, error) { + s, err := cli.getAuthSession(userID, connectorID) + if err != nil { return storage.AuthSession{}, err } return toStorageAuthSession(s), nil } -func (cli *client) UpdateAuthSession(ctx context.Context, sessionID string, updater func(old storage.AuthSession) (storage.AuthSession, error)) error { +func (cli *client) UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(old storage.AuthSession) (storage.AuthSession, error)) error { return retryOnConflict(ctx, func() error { - var s AuthSession - if err := cli.get(resourceAuthSession, sessionID, &s); err != nil { + s, err := cli.getAuthSession(userID, connectorID) + if err != nil { return err } @@ -837,7 +846,7 @@ func (cli *client) UpdateAuthSession(ctx context.Context, sessionID string, upda newSession := cli.fromStorageAuthSession(updated) newSession.ObjectMeta = s.ObjectMeta - return cli.put(resourceAuthSession, sessionID, newSession) + return cli.put(resourceAuthSession, s.ObjectMeta.Name, newSession) }) } @@ -855,8 +864,12 @@ func (cli *client) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, return sessions, nil } -func (cli *client) DeleteAuthSession(ctx context.Context, sessionID string) error { - return cli.delete(resourceAuthSession, sessionID) +func (cli *client) DeleteAuthSession(ctx context.Context, userID, connectorID string) error { + s, err := cli.getAuthSession(userID, connectorID) + if err != nil { + return err + } + return cli.delete(resourceAuthSession, s.ObjectMeta.Name) } func isKubernetesAPIConflictError(err error) bool { diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go index 473f59cc..074c6815 100644 --- a/storage/kubernetes/types.go +++ b/storage/kubernetes/types.go @@ -971,6 +971,9 @@ type AuthSession struct { k8sapi.TypeMeta `json:",inline"` k8sapi.ObjectMeta `json:"metadata,omitempty"` + UserID string `json:"userID,omitempty"` + ConnectorID string `json:"connectorID,omitempty"` + Nonce string `json:"nonce,omitempty"` ClientStates map[string]*storage.ClientAuthState `json:"clientStates,omitempty"` CreatedAt time.Time `json:"createdAt,omitempty"` LastActivity time.Time `json:"lastActivity,omitempty"` @@ -992,9 +995,12 @@ func (cli *client) fromStorageAuthSession(s storage.AuthSession) AuthSession { APIVersion: cli.apiVersion, }, ObjectMeta: k8sapi.ObjectMeta{ - Name: s.ID, + Name: offlineTokenName(s.UserID, s.ConnectorID, cli.hash), Namespace: cli.namespace, }, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, ClientStates: s.ClientStates, CreatedAt: s.CreatedAt, LastActivity: s.LastActivity, @@ -1005,7 +1011,9 @@ func (cli *client) fromStorageAuthSession(s storage.AuthSession) AuthSession { func toStorageAuthSession(s AuthSession) storage.AuthSession { result := storage.AuthSession{ - ID: s.ObjectMeta.Name, + UserID: s.UserID, + ConnectorID: s.ConnectorID, + Nonce: s.Nonce, ClientStates: s.ClientStates, CreatedAt: s.CreatedAt, LastActivity: s.LastActivity, diff --git a/storage/memory/memory.go b/storage/memory/memory.go index 483ed246..e507340c 100644 --- a/storage/memory/memory.go +++ b/storage/memory/memory.go @@ -23,7 +23,7 @@ func New(logger *slog.Logger) storage.Storage { passwords: make(map[string]storage.Password), offlineSessions: make(map[compositeKeyID]storage.OfflineSessions), userIdentities: make(map[compositeKeyID]storage.UserIdentity), - authSessions: make(map[string]storage.AuthSession), + authSessions: make(map[compositeKeyID]storage.AuthSession), connectors: make(map[string]storage.Connector), deviceRequests: make(map[string]storage.DeviceRequest), deviceTokens: make(map[string]storage.DeviceToken), @@ -52,7 +52,7 @@ type memStorage struct { passwords map[string]storage.Password offlineSessions map[compositeKeyID]storage.OfflineSessions userIdentities map[compositeKeyID]storage.UserIdentity - authSessions map[string]storage.AuthSession + authSessions map[compositeKeyID]storage.AuthSession connectors map[string]storage.Connector deviceRequests map[string]storage.DeviceRequest deviceTokens map[string]storage.DeviceToken @@ -258,47 +258,51 @@ func (s *memStorage) ListAuthSessions(ctx context.Context) (sessions []storage.A } func (s *memStorage) CreateAuthSession(ctx context.Context, session storage.AuthSession) (err error) { + id := compositeKeyID{userID: session.UserID, connID: session.ConnectorID} s.tx(func() { - if _, ok := s.authSessions[session.ID]; ok { + if _, ok := s.authSessions[id]; ok { err = storage.ErrAlreadyExists } else { - s.authSessions[session.ID] = session + s.authSessions[id] = session } }) return } -func (s *memStorage) GetAuthSession(ctx context.Context, sessionID string) (session storage.AuthSession, err error) { +func (s *memStorage) GetAuthSession(ctx context.Context, userID, connectorID string) (session storage.AuthSession, err error) { + id := compositeKeyID{userID: userID, connID: connectorID} s.tx(func() { var ok bool - if session, ok = s.authSessions[sessionID]; !ok { + if session, ok = s.authSessions[id]; !ok { err = storage.ErrNotFound } }) return } -func (s *memStorage) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) (err error) { +func (s *memStorage) UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) (err error) { + id := compositeKeyID{userID: userID, connID: connectorID} s.tx(func() { - r, ok := s.authSessions[sessionID] + r, ok := s.authSessions[id] if !ok { err = storage.ErrNotFound return } if r, err = updater(r); err == nil { - s.authSessions[sessionID] = r + s.authSessions[id] = r } }) return } -func (s *memStorage) DeleteAuthSession(ctx context.Context, sessionID string) (err error) { +func (s *memStorage) DeleteAuthSession(ctx context.Context, userID, connectorID string) (err error) { + id := compositeKeyID{userID: userID, connID: connectorID} s.tx(func() { - if _, ok := s.authSessions[sessionID]; !ok { + if _, ok := s.authSessions[id]; !ok { err = storage.ErrNotFound return } - delete(s.authSessions, sessionID) + delete(s.authSessions, id) }) return } diff --git a/storage/sql/crud.go b/storage/sql/crud.go index ab11713a..91213d76 100644 --- a/storage/sql/crud.go +++ b/storage/sql/crud.go @@ -929,13 +929,15 @@ func (c *conn) DeleteUserIdentity(ctx context.Context, userID, connectorID strin func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) error { _, err := c.Exec(` insert into auth_session ( - id, client_states, + user_id, connector_id, nonce, + client_states, created_at, last_activity, ip_address, user_agent ) - values ($1, $2, $3, $4, $5, $6); + values ($1, $2, $3, $4, $5, $6, $7, $8); `, - s.ID, encoder(s.ClientStates), + s.UserID, s.ConnectorID, s.Nonce, + encoder(s.ClientStates), s.CreatedAt, s.LastActivity, s.IPAddress, s.UserAgent, ) @@ -948,9 +950,9 @@ func (c *conn) CreateAuthSession(ctx context.Context, s storage.AuthSession) err return nil } -func (c *conn) UpdateAuthSession(ctx context.Context, sessionID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { +func (c *conn) UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(s storage.AuthSession) (storage.AuthSession, error)) error { return c.ExecTx(func(tx *trans) error { - s, err := getAuthSession(ctx, tx, sessionID) + s, err := getAuthSession(ctx, tx, userID, connectorID) if err != nil { return err } @@ -966,12 +968,12 @@ func (c *conn) UpdateAuthSession(ctx context.Context, sessionID string, updater last_activity = $2, ip_address = $3, user_agent = $4 - where id = $5; + where user_id = $5 AND connector_id = $6; `, encoder(newSession.ClientStates), newSession.LastActivity, newSession.IPAddress, newSession.UserAgent, - sessionID, + userID, connectorID, ) if err != nil { return fmt.Errorf("update auth session: %v", err) @@ -980,24 +982,26 @@ func (c *conn) UpdateAuthSession(ctx context.Context, sessionID string, updater }) } -func (c *conn) GetAuthSession(ctx context.Context, sessionID string) (storage.AuthSession, error) { - return getAuthSession(ctx, c, sessionID) +func (c *conn) GetAuthSession(ctx context.Context, userID, connectorID string) (storage.AuthSession, error) { + return getAuthSession(ctx, c, userID, connectorID) } -func getAuthSession(ctx context.Context, q querier, sessionID string) (storage.AuthSession, error) { +func getAuthSession(ctx context.Context, q querier, userID, connectorID string) (storage.AuthSession, error) { return scanAuthSession(q.QueryRow(` select - id, client_states, + user_id, connector_id, nonce, + client_states, created_at, last_activity, ip_address, user_agent from auth_session - where id = $1; - `, sessionID)) + where user_id = $1 AND connector_id = $2; + `, userID, connectorID)) } func scanAuthSession(s scanner) (session storage.AuthSession, err error) { err = s.Scan( - &session.ID, decoder(&session.ClientStates), + &session.UserID, &session.ConnectorID, &session.Nonce, + decoder(&session.ClientStates), &session.CreatedAt, &session.LastActivity, &session.IPAddress, &session.UserAgent, ) @@ -1016,7 +1020,8 @@ func scanAuthSession(s scanner) (session storage.AuthSession, err error) { func (c *conn) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, error) { rows, err := c.Query(` select - id, client_states, + user_id, connector_id, nonce, + client_states, created_at, last_activity, ip_address, user_agent from auth_session; @@ -1040,10 +1045,10 @@ func (c *conn) ListAuthSessions(ctx context.Context) ([]storage.AuthSession, err return sessions, nil } -func (c *conn) DeleteAuthSession(ctx context.Context, sessionID string) error { - result, err := c.Exec(`delete from auth_session where id = $1`, sessionID) +func (c *conn) DeleteAuthSession(ctx context.Context, userID, connectorID string) error { + result, err := c.Exec(`delete from auth_session where user_id = $1 AND connector_id = $2`, userID, connectorID) if err != nil { - return fmt.Errorf("delete auth_session: id = %s: %w", sessionID, err) + return fmt.Errorf("delete auth_session: user_id = %s, connector_id = %s: %w", userID, connectorID, err) } n, err := result.RowsAffected() diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go index 7561d146..b296ebee 100644 --- a/storage/sql/migrate.go +++ b/storage/sql/migrate.go @@ -413,12 +413,15 @@ var migrations = []migration{ stmts: []string{ ` create table auth_session ( - id text not null primary key, + user_id text not null, + connector_id text not null, + nonce text not null default '', client_states bytea not null, created_at timestamptz not null, last_activity timestamptz not null, ip_address text not null default '', - user_agent text not null default '' + user_agent text not null default '', + PRIMARY KEY (user_id, connector_id) );`, }, }, diff --git a/storage/storage.go b/storage/storage.go index 963c7c67..1d8da99d 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -99,7 +99,7 @@ type Storage interface { GetPassword(ctx context.Context, email string) (Password, error) GetOfflineSessions(ctx context.Context, userID string, connID string) (OfflineSessions, error) GetUserIdentity(ctx context.Context, userID, connectorID string) (UserIdentity, error) - GetAuthSession(ctx context.Context, sessionID string) (AuthSession, error) + GetAuthSession(ctx context.Context, userID, connectorID string) (AuthSession, error) GetConnector(ctx context.Context, id string) (Connector, error) GetDeviceRequest(ctx context.Context, userCode string) (DeviceRequest, error) GetDeviceToken(ctx context.Context, deviceCode string) (DeviceToken, error) @@ -119,7 +119,7 @@ type Storage interface { DeletePassword(ctx context.Context, email string) error DeleteOfflineSessions(ctx context.Context, userID string, connID string) error DeleteUserIdentity(ctx context.Context, userID, connectorID string) error - DeleteAuthSession(ctx context.Context, sessionID string) error + DeleteAuthSession(ctx context.Context, userID, connectorID string) error DeleteConnector(ctx context.Context, id string) error // Update methods take a function for updating an object then performs that update within @@ -143,7 +143,7 @@ type Storage interface { UpdatePassword(ctx context.Context, email string, updater func(p Password) (Password, error)) error UpdateOfflineSessions(ctx context.Context, userID string, connID string, updater func(s OfflineSessions) (OfflineSessions, error)) error UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(u UserIdentity) (UserIdentity, error)) error - UpdateAuthSession(ctx context.Context, sessionID string, updater func(s AuthSession) (AuthSession, error)) error + UpdateAuthSession(ctx context.Context, userID, connectorID string, updater func(s AuthSession) (AuthSession, error)) error UpdateConnector(ctx context.Context, id string, updater func(c Connector) (Connector, error)) error UpdateDeviceToken(ctx context.Context, deviceCode string, updater func(t DeviceToken) (DeviceToken, error)) error @@ -341,19 +341,25 @@ type UserIdentity struct { BlockedUntil time.Time } -// ClientAuthState represents the authentication state for a specific client within a session. +// ClientAuthState represents authentication state for a specific client within an auth session. type ClientAuthState struct { - UserID string - ConnectorID string Active bool ExpiresAt time.Time LastActivity time.Time LastTokenIssuedAt time.Time } -// AuthSession represents a browser-bound authentication session. +// AuthSession represents a user's authentication session from a specific connector. +// Keyed by composite (UserID, ConnectorID), similar to OfflineSessions. +// The Nonce field is a random value included in the session cookie to prevent forgery. +// +// TODO(nabokihms): support multiple sessions in one browser by storing multiple +// session references in the cookie (e.g. "ref1|ref2") so that different users +// can maintain independent sessions in the same browser. type AuthSession struct { - ID string + UserID string + ConnectorID string + Nonce string // random, included in cookie for verification ClientStates map[string]*ClientAuthState // clientID -> auth state CreatedAt time.Time LastActivity time.Time diff --git a/web/templates/password.html b/web/templates/password.html index a6d8b667..ed529a2e 100644 --- a/web/templates/password.html +++ b/web/templates/password.html @@ -22,7 +22,16 @@ {{ end }} - + {{ if .ShowRememberMe }} +
+ +
+ {{ end }} + + {{ if .BackLink }} diff --git a/web/themes/dark/styles.css b/web/themes/dark/styles.css index 153f17eb..d6cb393c 100644 --- a/web/themes/dark/styles.css +++ b/web/themes/dark/styles.css @@ -122,6 +122,16 @@ margin-top: 8px; } +.theme-remember-me { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + width: 260px; + margin: 4px auto; +} + .dex-container { color: #b8bcc4; } diff --git a/web/themes/light/styles.css b/web/themes/light/styles.css index 61586722..37001aed 100644 --- a/web/themes/light/styles.css +++ b/web/themes/light/styles.css @@ -117,3 +117,13 @@ .theme-link-back { margin-top: 8px; } + +.theme-remember-me { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + width: 260px; + margin: 4px auto; +}