docs/enhancements/auth-sessions-2026-02-18.md
This DEP introduces auth sessions - a persistent authentication state that enables Dex to track logged-in users across browser sessions. Currently, Dex relies entirely on refresh tokens for session management, which prevents proper implementation of OIDC conformance features like prompt=none, prompt=login, id_token_hint, SSO across clients, and proper logout. User Sessions will be stored server-side with a browser cookie reference, enabling these features while maintaining Dex's simplicity and compatibility with all storage backends (SQL, etcd, Kubernetes CRDs).
prompt parameter specificationid_token_hint specificationCurrent limitations:
prompt=none (silent authentication)prompt=login (force re-authentication)max_age parameterid_token_hint validationprompt=none, prompt=login, max_age, and id_token_hint supportSessions are controlled by a feature flag and configuration:
# Feature flag (environment variable)
# DEX_SESSIONS_ENABLED=true
# config.yaml
sessions:
# Session cookie name (default: "dex_session")
# Other cookie settings (Secure, HttpOnly, SameSite=Lax) are not configurable
# and are set to secure defaults automatically
cookieName: "dex_session"
# Session lifetime settings (matches refresh token expiry naming)
absoluteLifetime: "24h" # Maximum session lifetime, default: 24h
validIfNotUsedFor: "1h" # Session expires if not used, default: 1h
# Default SSO sharing policy for clients without explicit ssoSharedWith config
# Options:
# "all" - clients without ssoSharedWith share sessions with all other clients (Keycloak-like)
# "none" - clients without ssoSharedWith don't share sessions (default)
ssoSharedWithDefault: "none"
# Whether "Remember Me" checkbox is checked by default in login/approval forms
# When true: checkbox is pre-checked, user can uncheck
# When false: checkbox is unchecked, user must check to persist session (default)
rememberMeCheckedByDefault: false
ssoSharedWithDefault controls the default SSO behavior:
"none" (default): Clients without explicit ssoSharedWith config don't participate in SSO"all": Clients without explicit ssoSharedWith config share sessions with all other clients (realm-wide SSO like Keycloak)Clients with explicit ssoSharedWith configuration always use their configured value.
Note: The ssoSharedWith option is separate from the existing trustedPeers option. trustedPeers controls which clients can issue tokens on behalf of this client (existing behavior), while ssoSharedWith controls which clients can reuse this client's authentication session (new behavior). These can be configured independently based on different security requirements.
rememberMeCheckedByDefault controls the initial checkbox state in templates.
This value is passed to templates as .RememberMeChecked boolean.
SSO via ssoSharedWith: SSO between clients is controlled by the new ssoSharedWith configuration on clients. The ssoSharedWith setting defines which clients can USE this client's session, not which clients this client can use.
If client B is listed in client A's ssoSharedWith:
This is intentionally separate from trustedPeers (which controls token issuance on behalf of another client). Organizations may want different policies for session sharing vs token delegation:
Wildcard Support: ssoSharedWith: ["*"] enables SSO with all clients. This is similar to Keycloak's default behavior where all clients in a realm share sessions.
SSO Direction: SSO sharing is unidirectional. Client A sharing with client B does NOT mean client B shares with client A.
staticClients:
# Public app - allows any client to reuse its sessions
- id: public-app
name: Public App
ssoSharedWith: ["*"]
# trustedPeers can be configured separately for token delegation
# ...
# Admin app - only specific apps can reuse its sessions
- id: admin-app
name: Admin App
ssoSharedWith: ["monitoring-app"] # Only monitoring can SSO from admin sessions
# ...
# Secret internal service - NO other clients can reuse its sessions
- id: secret-service
name: Secret Service
ssoSharedWith: [] # Empty = no SSO allowed from this client's sessions
# But this client CAN use sessions from other clients that share with it!
# ...
# Monitoring app - can SSO from admin-app (because admin-app shares with it)
- id: monitoring-app
name: Monitoring App
ssoSharedWith: ["admin-app"] # Bidirectional sharing with admin-app
# ...
Example Scenarios:
| User logged in via | Accessing | SSO works? | Why |
|---|---|---|---|
| public-app | admin-app | ✅ Yes | public-app has ssoSharedWith: ["*"] |
| admin-app | public-app | ❌ No | admin-app only shares with monitoring-app |
| admin-app | monitoring-app | ✅ Yes | admin-app shares with monitoring-app |
| secret-service | any client | ❌ No | secret-service has ssoSharedWith: [] |
| public-app | secret-service | ✅ Yes | public-app has ssoSharedWith: ["*"] |
Key Insight: A "secret" client that doesn't want others to SSO into it simply doesn't list them in ssoSharedWith. But it can still BENEFIT from SSO by being listed in OTHER clients' ssoSharedWith.
Comparison with Keycloak: In Keycloak, SSO is realm-wide by default - all clients in a realm share sessions. Dex's approach is more granular: SSO is opt-in per client via ssoSharedWith. Use ["*"] to achieve Keycloak-like behavior.
Comparison with trustedPeers: The trustedPeers option continues to control cross-client token issuance (e.g., client B issuing tokens for client A). This is a separate security concern from session sharing. Organizations can configure these independently:
Cookie Security: The session cookie is always set with secure defaults:
HttpOnly: true - Not accessible via JavaScriptSecure: (issuerURL.Scheme == "https") - Only sent over HTTPS; for http (commonly used on localhost in dev) this is disabledSameSite: Lax - CSRF protectionPath: <issuerURL.Path> - Derived from issuer URL (e.g., /dex for https://example.com/dex)These settings are not configurable to prevent security misconfigurations.
┌─────────┐ ┌─────────┐ ┌───────────┐ ┌───────────┐
│ Browser │ │ Dex │ │ Storage │ │ Connector │
└────┬────┘ └────┬────┘ └─────┬─────┘ └─────┬─────┘
│ │ │ │
│ GET /auth │ │ │
│ (no session) │ │ │
├────────────────>│ │ │
│ │ │ │
│ │ Check session │ │
│ │ cookie │ │
│ ├─────────────────>│ │
│ │ (not found) │ │
│ │<─────────────────│ │
│ │ │ │
│ Redirect to │ │ │
│ connector │ │ │
│<────────────────│ │ │
│ │ │ │
│ ... connector auth flow ... │ │
│ │ │ │
│ Callback with │ │ │
│ identity │ │ │
├────────────────>│ │ │
│ │ │ │
│ │ Create/update │ │
│ │ AuthSession │ │
│ │ (ALWAYS) │ │
│ ├─────────────────>│ │
│ │ │ │
│ Set-Cookie: │ │ │
│ - Session cookie (no MaxAge) │ │
│ if Remember Me unchecked │ │
│ - Persistent cookie (with MaxAge) │ │
│ if Remember Me checked │ │
│ + redirect to /approval │ │
│<────────────────│ │ │
│ │ │ │
Key Point: AuthSession is always created on successful authentication. The "Remember Me" checkbox only controls whether the cookie is a session cookie (deleted on browser close) or a persistent cookie (survives browser restart). This is consistent with Keycloak's behavior.
┌─────────┐ ┌─────────┐ ┌───────────┐
│ Browser │ │ Dex │ │ Storage │
└────┬────┘ └────┬────┘ └─────┬─────┘
│ │ │
│ GET /auth │ │
│ (with cookie) │ │
│ client_id=B │ │
├────────────────>│ │
│ │ │
│ │ Get session │
│ ├─────────────────>│
│ │ (valid session) │
│ │<─────────────────│
│ │ │
│ │ Check SSO │
│ │ policy for │
│ │ client B │
│ │ │
│ │ Check consent │
│ │ for client B │
│ ├─────────────────>│
│ │ │
│ If consented: │ │
│ redirect with │ │
│ code │ │
│<────────────────│ │
│ │ │
│ If not: │ │
│ show approval │ │
│<────────────────│ │
│ │ │
┌─────────┐ ┌─────────┐ ┌───────────┐
│ Browser │ │ Dex │ │ Storage │
└────┬────┘ └────┬────┘ └─────┬─────┘
│ │ │
│ GET /auth │ │
│ prompt=none │ │
├────────────────>│ │
│ │ │
│ │ Get session │
│ ├─────────────────>│
│ │ │
│ If valid session + consent: │
│ redirect with code │
│<────────────────│ │
│ │ │
│ If no session or no consent: │
│ redirect with error=login_required│
│ or error=consent_required │
│<────────────────│ │
│ │ │
┌─────────┐ ┌─────────┐ ┌───────────┐
│ Browser │ │ Dex │ │ Storage │
└────┬────┘ └────┬────┘ └─────┬─────┘
│ │ │
│ GET /logout │ │
│ id_token_hint= │ │
├────────────────>│ │
│ │ │
│ │ Validate │
│ │ id_token_hint │
│ │ │
│ │ Get identity │
│ │ by session ID │
│ ├─────────────────>│
│ │ │
│ │ Deactivate │
│ │ (Active=false) │
│ ├─────────────────>│
│ │ │
│ │ Revoke refresh │
│ │ tokens │
│ ├─────────────────>│
│ │ │
│ Clear cookie + │ │
│ redirect or │ │
│ show logout │ │
│ confirmation │ │
│<────────────────│ │
│ │ │
// pkg/featureflags/set.go
var (
// ...existing flags...
// SessionsEnabled enables user sessions feature
SessionsEnabled = newFlag("sessions_enabled", false)
)
Two entities are required to properly handle the case where a user might be logged into different clients as different identities in the same browser:
// storage/storage.go
// AuthSession represents a browser's authentication state.
// One per browser, referenced by session cookie.
// Key: SessionID (random 32-byte string, stored in cookie)
type AuthSession struct {
// ID is the session identifier stored in cookie
ID string
// ClientStates maps clientID → authentication state for that client
// Allows different users/identities per client in same browser
//
// Design note: This map-based approach is consistent with how OfflineSessions
// stores refresh tokens per client (OfflineSessions.Refresh map). Given that
// the number of OAuth clients in a typical deployment is bounded and relatively
// small (tens to hundreds, not thousands), the serialized size of this map
// will not exceed practical storage limits for any supported backend.
ClientStates map[string]*ClientAuthState
// CreatedAt is when this browser session started
CreatedAt time.Time
// LastActivity is when any client was last accessed
LastActivity time.Time
// IPAddress at session creation (for audit)
IPAddress string
// UserAgent at session creation (for audit)
UserAgent string
}
// ClientAuthState represents authentication state for a specific client within an auth session.
// Expiration follows OIDC conventions with both absolute and idle timeout:
// - ExpiresAt enforces absolute lifetime (sessions.absoluteLifetime)
// - LastActivity + sessions.validIfNotUsedFor enforces idle timeout
// A client state is considered expired if EITHER condition is met.
type ClientAuthState struct {
// UserID + ConnectorID identify which UserIdentity is authenticated for this client
UserID string
ConnectorID string
// Active indicates if authentication is active for this client
Active bool
// ExpiresAt is the absolute expiration time for this client session.
// Set to time.Now() + absoluteLifetime at session creation.
// Cannot be extended - hard upper bound on session duration.
ExpiresAt time.Time
// LastActivity is when this client session was last used (token issued, SSO check, etc.)
// Used with validIfNotUsedFor to enforce idle timeout.
// Updated on each request that touches this client state.
LastActivity time.Time
// LastTokenIssuedAt is when a token was last issued for this client.
// Used for logout notifications and audit.
LastTokenIssuedAt time.Time
}
// storage/storage.go
// UserIdentity represents a user's persistent identity data.
// Stores data that persists across sessions:
// - Consent decisions
// - Future: 2FA enrollment
//
// Key: composite of UserID + ConnectorID (one per user per connector)
type UserIdentity struct {
// UserID is the subject identifier from the connector
UserID string
// ConnectorID is the connector that authenticated the user
ConnectorID string
// Claims holds the user's identity claims
// Updated on:
// 1. Each login (from connector callback)
// 2. Each refresh token usage (from RefreshConnector.Refresh)
// This ensures claims stay in sync with OfflineSessions and upstream IDP
Claims Claims
// Consents stores user consent per client: map[clientID][]scopes
// Persists across sessions so user doesn't need to re-consent
Consents map[string][]string
// CreatedAt is when this identity was first created
CreatedAt time.Time
// LastLogin is when the user last authenticated (used for auth_time claim)
LastLogin time.Time
// BlockedUntil is set when user is blocked from logging in
BlockedUntil time.Time
// Future: 2FA fields
// TOTPSecret string
// WebAuthnCredentials []WebAuthnCredential
}
Two-Entity Design Rationale
| Entity | Purpose | Lifecycle | Key |
|---|---|---|---|
| AuthSession | Browser binding, per-client auth state | Short-lived (session timeout) | SessionID (cookie) |
| UserIdentity | User data, consents, 2FA | Long-lived (persists) | UserID + ConnectorID |
How It Works: Different Users in Different Clients
Auth Session (cookie: dex_session=abc123)
├── ClientStates["client-A"]:
│ └── UserID: "alice", ConnectorID: "google", Active: true
├── ClientStates["client-B"]:
│ └── UserID: "bob", ConnectorID: "ldap", Active: true
└── ClientStates["client-C"]:
└── (empty - never authenticated)
UserIdentity (alice + google):
├── Claims: {email: [email protected], ...}
├── Consents: {"client-A": ["openid", "email"]}
└── LastLogin: 2024-01-01
UserIdentity (bob + ldap):
├── Claims: {email: [email protected], ...}
├── Consents: {"client-B": ["openid", "groups"]}
└── LastLogin: 2024-01-02
How SSO Works
When user accesses client-B with existing session:
AuthSession by cookieClientStates["client-B"]:
ClientStates[X] where client-X has ssoSharedWith containing "client-B"ClientStates["client-B"]SSO Session Lookup Algorithm
// findSSOSession searches for a valid SSO source session for the target client
func (s *Server) findSSOSession(authSession *AuthSession, targetClientID string) (*ClientAuthState, *UserIdentity) {
targetClient, err := s.storage.GetClient(ctx, targetClientID)
if err != nil {
return nil, nil
}
// Iterate through all active client states in this browser session
for sourceClientID, state := range authSession.ClientStates {
// Skip inactive or expired states
if !state.Active || time.Now().After(state.ExpiresAt) {
continue
}
// Get the source client configuration
sourceClient, err := s.storage.GetClient(ctx, sourceClientID)
if err != nil {
continue
}
// Check if source client shares its session with the target client
// SSO is allowed if:
// 1. Source client has ssoSharedWith: ["*"] (shares with everyone)
// 2. Source client has targetClientID in its ssoSharedWith list
if !s.clientSharesSessionWith(sourceClient, targetClientID) {
continue
}
// Found a valid SSO source! Get the user identity
identity, err := s.storage.GetUserIdentity(ctx, state.UserID, state.ConnectorID)
if err != nil {
continue
}
// Check if user is not blocked
if identity.BlockedUntil.After(time.Now()) {
continue
}
return state, identity
}
return nil, nil
}
// clientSharesSessionWith checks if sourceClient shares its session with targetClientID
func (s *Server) clientSharesSessionWith(sourceClient Client, targetClientID string) bool {
for _, peer := range sourceClient.SSOSharedWith {
if peer == "*" || peer == targetClientID {
return true
}
}
return false
}
SSO Lookup Flow Diagram
User accesses client-B with existing session
│
▼
┌─────────────────────────────────┐
│ Get AuthSession from cookie │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Check ClientStates["client-B"] │
│ exists and active? │
└─────────────────────────────────┘
│ │
Yes No
│ │
▼ ▼
┌──────────────┐ ┌─────────────────────────────────┐
│ Use existing │ │ For each ClientStates[X]: │
│ session │ │ - Is state active? │
└──────────────┘ │ - Get client-X config │
│ - Does client-X share with B? │
│ (X.ssoSharedWith has B or *)│
└─────────────────────────────────┘
│ │
Found match No match
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ SSO! Copy │ │ Require │
│ state to B │ │ authentication│
└──────────────┘ └──────────────┘
Example: SSO Flow
1. User logs into client-A as alice
AuthSession.ClientStates["client-A"] = {UserID: "alice", Active: true}
2. User accesses client-B
- client-A.ssoSharedWith includes "client-B" ✓
- SSO! Copy: ClientStates["client-B"] = {UserID: "alice", Active: true}
- Issue tokens for alice to client-B
Example: No SSO, Different User
1. User logged into client-A as alice
AuthSession.ClientStates["client-A"] = {UserID: "alice", Active: true}
2. User accesses client-B (client-A does NOT share with client-B)
- No SSO available
- Redirect to connector for authentication
3. User logs in as bob (different account)
AuthSession.ClientStates["client-B"] = {UserID: "bob", Active: true}
Same browser, two different users, no conflict!
Claims Synchronization with Refresh Tokens
When a refresh token is used:
RefreshConnector.Refresh() returns updated claimsOfflineSessions.ConnectorData (existing behavior)UserIdentity.Claims:// In refresh token handler
func (s *Server) handleRefreshToken(...) {
// ...existing refresh logic...
newIdentity, err := refreshConn.Refresh(ctx, scopes, oldIdentity)
if err != nil {
// Handle refresh failure
}
// Update OfflineSessions (existing)
s.storage.UpdateOfflineSessions(...)
// Update UserIdentity claims (NEW)
if s.sessionsEnabled {
s.storage.UpdateUserIdentity(ctx, newIdentity.UserID, connectorID,
func(u UserIdentity) (UserIdentity, error) {
u.Claims = storage.Claims{
UserID: newIdentity.UserID,
Username: newIdentity.Username,
Email: newIdentity.Email,
Groups: newIdentity.Groups,
// ...
}
return u, nil
})
}
}
This ensures UserIdentity.Claims stays synchronized with:
OfflineSessions.ConnectorDataWhy UserIdentity instead of AuthSession?
The name UserIdentity is chosen because this entity stores more than just session state:
Session ID Regeneration
The AuthSession.ID is regenerated when:
Individual ClientStates can be invalidated without changing the auth session ID.
Multiple Users in Same Browser
With the two-entity design:
AuthSession tracks which user is authenticated for which clientSSO and Different Users
With SSO enabled between clients, the same user is used for all sharing clients:
If user needs to login as different identity to a sharing client:
prompt=login to force re-authenticationWithout SSO, user can be different identities in different clients (see examples above).
Two new entities require CRUD operations:
// storage/storage.go
type Storage interface {
// ...existing methods...
// AuthSession management
CreateAuthSession(ctx context.Context, s AuthSession) error
GetAuthSession(ctx context.Context, sessionID string) (AuthSession, error)
UpdateAuthSession(ctx context.Context, sessionID string, updater func(s AuthSession) (AuthSession, error)) error
DeleteAuthSession(ctx context.Context, sessionID string) error
// UserIdentity management
CreateUserIdentity(ctx context.Context, u UserIdentity) error
GetUserIdentity(ctx context.Context, userID, connectorID string) (UserIdentity, error)
UpdateUserIdentity(ctx context.Context, userID, connectorID string, updater func(u UserIdentity) (UserIdentity, error)) error
DeleteUserIdentity(ctx context.Context, userID, connectorID string) error
// List for admin API
ListUserIdentities(ctx context.Context) ([]UserIdentity, error)
}
Garbage Collection
type GCResult struct {
// ...existing fields...
AuthSessions int64 // NEW: expired auth sessions cleaned up
}
AuthSession objects are garbage collected when:
LastActivity + validIfNotUsedFor exceeded (inactivity)ClientStates have expiredUserIdentity objects are NOT garbage collected (preserve consents, future 2FA).
AuthSession expiration:
LastActivity + validIfNotUsedFor is reached (idle timeout)AuthSession is deleted by GCClientAuthState expiration (per-client within AuthSession):
Each client state enforces both absolute lifetime and idle timeout, consistent with standard OIDC session semantics:
func (s *Server) isClientStateValid(state *ClientAuthState) bool {
now := time.Now()
// 1. Check absolute lifetime - hard upper bound, cannot be extended
if now.After(state.ExpiresAt) {
return false
}
// 2. Check idle timeout - session unused for too long
if now.After(state.LastActivity.Add(s.sessionsConfig.validIfNotUsedFor)) {
return false
}
// 3. Check explicit deactivation (admin revoked)
if !state.Active {
return false
}
return true
}
When a client state expires:
ClientAuthState is created with fresh ExpiresAtAdmin can force re-authentication:
AuthSession → user must re-auth for all clientsClientStates[clientID].Active = false → user must re-auth for that client onlyDeleting AuthSession:
Deleting UserIdentity:
| What's Lost | Impact |
|---|---|
| Consent decisions | User must re-approve scopes for all clients |
| Future: 2FA enrollment | User must re-enroll TOTP/WebAuthn |
When to delete UserIdentity:
When NOT to delete (delete AuthSession instead):
The session cookie contains only the session ID (not the session data):
Cookie: dex_session=<session_id>; Path=<issuer_path>; Secure; HttpOnly; SameSite=Lax
Cookie Path: Derived from the issuer URL path (issuerURL.Path). For example:
https://dex.example.com/ → Path=/https://example.com/dex → Path=/dexThis is consistent with how Dex already handles routing - all endpoints are prefixed with the issuer path.
Session Creation vs Cookie Persistence (Keycloak-like behavior)
Unlike some implementations where "Remember Me" controls session creation, we follow Keycloak's approach:
absoluteLifetime)This approach is better because:
prompt=none works correctly within browser sessionfunc (s *Server) setSessionCookie(w http.ResponseWriter, sessionID string, rememberMe bool) {
cookie := &http.Cookie{
Name: s.sessionsConfig.CookieName,
Value: sessionID,
Path: s.issuerURL.Path,
HttpOnly: true,
Secure: s.issuerURL.Scheme == "https",
SameSite: http.SameSiteLaxMode,
}
if rememberMe {
// Persistent cookie - survives browser restart
cookie.MaxAge = int(s.sessionsConfig.absoluteLifetime.Seconds())
}
// else: Session cookie - no MaxAge, browser deletes on close
http.SetCookie(w, cookie)
}
Session ID generation:
func NewSessionID() string {
return newSecureID(32) // 256-bit random value
}
A new client configuration field is introduced for SSO control:
// storage/storage.go
type Client struct {
// ...existing fields...
// TrustedPeers are a list of peers which can issue tokens on this client's behalf.
// This is used for cross-client token issuance (existing behavior).
TrustedPeers []string `json:"trustedPeers" yaml:"trustedPeers"`
// SSOSharedWith defines which other clients can reuse this client's authentication session.
// When a user is authenticated for this client, clients listed here can skip authentication.
// This is separate from TrustedPeers - organizations may want different policies for
// session sharing vs token delegation.
// Special value "*" means share with all clients (Keycloak-like realm-wide SSO).
// nil means use ssoSharedWithDefault from sessions config.
// Empty slice [] means explicitly share with no one.
SSOSharedWith []string `json:"ssoSharedWith,omitempty" yaml:"ssoSharedWith,omitempty"`
}
Logout URLs should be configured on connectors, not clients. A new connector interface will be added:
// connector/connector.go
// LogoutConnector is an optional interface for connectors that support
// terminating upstream sessions on logout.
type LogoutConnector interface {
// Logout terminates the user's session at the upstream identity provider.
// Returns a URL to redirect the user to for upstream logout, or empty string
// if no redirect is needed.
Logout(ctx context.Context, connectorData []byte) (logoutURL string, err error)
}
Connectors that implement this interface (e.g., OIDC with end_session_endpoint, SAML with SLO):
This is tracked as a future improvement.
// cmd/dex/config.go
type Sessions struct {
// CookieName is the session cookie name (default: "dex_session")
CookieName string `json:"cookieName"`
// AbsoluteLifetime is the maximum session lifetime (default: "24h")
AbsoluteLifetime string `json:"absoluteLifetime"`
// ValidIfNotUsedFor is the inactivity timeout (default: "1h")
ValidIfNotUsedFor string `json:"validIfNotUsedFor"`
// SSOSharedWithDefault is the default SSO sharing policy
// "all" = share with all clients, "none" = share with no one (default: "none")
SSOSharedWithDefault string `json:"ssoSharedWithDefault"`
// RememberMeCheckedByDefault controls the initial checkbox state in templates
// true = pre-checked, false = unchecked (default: false)
RememberMeCheckedByDefault bool `json:"rememberMeCheckedByDefault"`
}
Using ssoSharedWithDefault in SSO logic:
func (s *Server) clientSharesSessionWith(sourceClient Client, targetClientID string) bool {
ssoSharedWith := sourceClient.SSOSharedWith
// If client has no explicit ssoSharedWith, use default
if ssoSharedWith == nil {
switch s.sessionsConfig.SSOSharedWithDefault {
case "all":
return true // Share with everyone by default
default: // "none"
return false // Share with no one by default
}
}
// Explicit configuration: empty slice means explicitly share with no one
// This is different from nil (not configured)
if len(ssoSharedWith) == 0 {
return false
}
// Check explicit sharing list
for _, peer := range ssoSharedWith {
if peer == "*" || peer == targetClientID {
return true
}
}
return false
}
Three states for ssoSharedWith:
nil (not configured) → use ssoSharedWithDefault[] (empty slice) → explicitly share with no one["client-a", ...] or ["*"] → explicit sharing listDex will support the following prompt values per OIDC Core specification:
none - Silent authentication, no UI displayedlogin - Force re-authenticationconsent - Force consent screenThe select_account value is not supported initially (would require account linking feature).
func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) {
// ...existing parsing...
prompt := r.Form.Get("prompt")
maxAge := r.Form.Get("max_age")
idTokenHint := r.Form.Get("id_token_hint")
clientID := r.Form.Get("client_id")
// Get auth session from cookie
authSession, err := s.getAuthSessionFromCookie(r)
// Get client auth state for this specific client
var clientState *ClientAuthState
var userIdentity *UserIdentity
if authSession != nil {
clientState = authSession.ClientStates[clientID]
if clientState != nil && clientState.Active {
userIdentity, _ = s.storage.GetUserIdentity(ctx, clientState.UserID, clientState.ConnectorID)
}
}
// Handle max_age parameter (OIDC Core 3.1.2.1)
if maxAge != "" && userIdentity != nil {
maxAgeSeconds, err := strconv.Atoi(maxAge)
if err == nil && maxAgeSeconds >= 0 {
authAge := time.Since(userIdentity.LastLogin)
if authAge > time.Duration(maxAgeSeconds)*time.Second {
// Session is too old, force re-authentication
clientState = nil
userIdentity = nil
}
}
}
switch prompt {
case "none":
// Silent authentication - must have valid session and consent
if clientState == nil || userIdentity == nil {
s.authErr(w, r, redirectURI, "login_required", state)
return
}
// Check consent in identity
consentedScopes, hasConsent := userIdentity.Consents[clientID]
if !hasConsent || !s.scopesCovered(consentedScopes, requestedScopes) {
s.authErr(w, r, redirectURI, "consent_required", state)
return
}
// Issue tokens without UI
case "login":
// Force re-authentication - ignore existing session for this client
clientState = nil
userIdentity = nil
// Continue to connector login
case "consent":
// Force consent screen even if previously consented
// Continue but don't check consent
default: // "" - normal flow
// Check for SSO from trusted clients if no direct session
if clientState == nil && authSession != nil {
clientState, userIdentity = s.findSSOSession(authSession, clientID)
}
}
// Validate id_token_hint if provided
if idTokenHint != "" {
claims, err := s.validateIDTokenHint(idTokenHint)
if err != nil {
s.authErr(w, r, redirectURI, "invalid_request", state)
return
}
if userIdentity != nil && userIdentity.UserID != claims.Subject {
// Identity user doesn't match hint
if prompt == "none" {
s.authErr(w, r, redirectURI, "login_required", state)
return
}
// Force re-login for different user
clientState = nil
userIdentity = nil
}
}
// ...continue with flow...
}
// findSSOSession looks for a valid SSO session from a sharing client
func (s *Server) findSSOSession(authSession *AuthSession, targetClientID string) (*ClientAuthState, *UserIdentity) {
for sourceClientID, state := range authSession.ClientStates {
if !state.Active {
continue
}
sourceClient, _ := s.storage.GetClient(ctx, sourceClientID)
if sourceClient == nil {
continue
}
// Check if source client shares its session with target client
if s.clientSharesSessionWith(sourceClient, targetClientID) {
identity, _ := s.storage.GetUserIdentity(ctx, state.UserID, state.ConnectorID)
if identity != nil {
return state, identity
}
}
}
return nil, nil
}
max_age Parameter
The max_age parameter is supported per OIDC Core specification:
LastLogin) exceeds max_age, force re-authenticationmax_age is used, the auth_time claim MUST be included in the ID tokenPOST /logout
GET /logout
Logout endpoint following the OpenID RP-Initiated Logout specification (OpenID spec):
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
idTokenHint := r.FormValue("id_token_hint")
postLogoutRedirectURI := r.FormValue("post_logout_redirect_uri")
state := r.FormValue("state")
clientID := r.FormValue("client_id") // Optional: logout from specific client
// Get auth session from cookie
authSession, _ := s.getAuthSessionFromCookie(r)
// Validate id_token_hint if provided
var hintUserID, hintConnectorID string
if idTokenHint != "" {
claims, err := s.validateIDTokenHint(idTokenHint)
if err == nil {
hintUserID = claims.Subject
// Extract connector from token if possible
}
}
if authSession != nil {
if clientID != "" {
// Logout from specific client only
delete(authSession.ClientStates, clientID)
s.storage.UpdateAuthSession(ctx, authSession.ID, ...)
} else {
// Logout from all clients - delete entire auth session
s.storage.DeleteAuthSession(ctx, authSession.ID)
}
// Revoke refresh tokens for logged-out clients
// ...
}
// Clear cookie and redirect
s.clearSessionCookie(w)
// Show logout confirmation or redirect
if postLogoutRedirectURI != "" && s.isValidPostLogoutURI(postLogoutRedirectURI, idTokenHint) {
u, _ := url.Parse(postLogoutRedirectURI)
if state != "" {
q := u.Query()
q.Set("state", state)
u.RawQuery = q.Encode()
}
http.Redirect(w, r, u.String(), http.StatusFound)
return
}
// Show logout confirmation page
s.templates.logout(w, r)
}
Future: Upstream Connector Logout
For CallbackConnectors (OIDC, OAuth, SAML), the upstream identity provider may also have an active session. Future work should include:
LogoutConnector interface (see above)end_session_endpoint from discoveryThis is tracked as a future improvement.
func (s *Server) constructDiscovery(ctx context.Context) discovery {
d := discovery{
// ...existing fields...
}
if s.sessionsEnabled {
d.EndSessionEndpoint = s.absURL("/logout")
}
return d
}
When sessions are enabled, add "Remember Me" checkbox to authentication flow.
Template Data
The server passes these values to templates:
type templateData struct {
// ...existing fields...
// SessionsEnabled indicates if sessions feature is active
SessionsEnabled bool
// RememberMeChecked is the default checkbox state
// Set from config: sessions.rememberMeCheckedByDefault
RememberMeChecked bool
}
For PasswordConnector (login form exists in Dex):
<!-- templates/password.html -->
<form method="post">
<!-- existing fields -->
{{ if .SessionsEnabled }}
<div class="remember-me">
<input type="checkbox" id="remember_me" name="remember_me" value="true"
{{ if .RememberMeChecked }}checked{{ end }}>
<label for="remember_me">Remember me</label>
</div>
{{ end }}
<button type="submit">Login</button>
</form>
For CallbackConnector (no login form in Dex):
For OAuth/OIDC/SAML connectors, the user is redirected to upstream IDP and there's no Dex login form.
Show on Approval Page (recommended): Add "Remember Me" checkbox to the approval/consent page. User sees it after returning from upstream IDP, before granting consent.
<!-- templates/approval.html -->
<form method="post">
<!-- existing scope approval fields -->
{{ if .SessionsEnabled }}
<div class="remember-me">
<input type="checkbox" id="remember_me" name="remember_me" value="true"
{{ if .RememberMeChecked }}checked{{ end }}>
<label for="remember_me">Remember me on this device</label>
</div>
{{ end }}
<button type="submit" name="approval" value="approve">Grant Access</button>
</form>
When skipApprovalScreen is true: If approval screen is skipped, the rememberMeCheckedByDefault config determines cookie persistence:
false (default): Session cookie (deleted on browser close)true: Persistent cookie (survives browser restart)Remember Me Behavior (Keycloak-like):
absoluteLifetime expires.CallbackConnector (OIDC, OAuth, SAML, GitHub, etc.):
PasswordConnector (LDAP, local passwords):
Both types work the same way with sessions - the connector type only affects:
Sessions reference a ConnectorID, but connector configuration may change after session creation (e.g., OIDC issuer URL changes, LDAP server replaced, connector removed entirely).
Behavior: Dex does NOT automatically invalidate sessions when connector configuration changes. This is by design - Dex has no mechanism to detect configuration changes at runtime, and connectors are typically reconfigured during planned maintenance.
Administrator responsibility: When connector configuration changes in a way that invalidates existing user identities (e.g., connector removed, upstream IdP replaced), administrators should:
DexSessions.TerminateByConnector(connectorID))DEX_SESSIONS_ENABLED=false temporarily to force re-authenticationIf a session references a connector that no longer exists, the session will fail gracefully at the next use: GetConnector() will return an error, and the user will be redirected to authenticate again.
| Risk | Mitigation |
|---|---|
| Session hijacking | Secure cookie flags (HttpOnly, Secure, SameSite), short idle timeout |
| Session fixation | Generate new session ID after authentication (see below) |
| CSRF on logout | GET shows confirmation page, POST performs logout |
| Cookie theft | Bind session to fingerprint (IP range, partial user agent) - optional |
| Storage exposure | Session IDs are random 256-bit values, no sensitive data in cookie |
Session Fixation Protection
Session fixation attacks occur when an attacker sets a known session ID in a victim's browser before authentication, then hijacks the session after the victim logs in.
References:
Mitigations implemented:
AuthSession.ID even if a session already exists. Never reuse a pre-authentication session ID.// This is not the real method signature, but the implementation example of a specific behavior.
func (s *Server) onSuccessfulAuthentication(w http.ResponseWriter, userID, connectorID, clientID string, rememberMe bool) {
// ALWAYS generate new session ID - prevents session fixation
newSessionID := NewSessionID()
// Create or update AuthSession with NEW ID
authSession := &AuthSession{
ID: newSessionID, // Always new, never reuse
ClientStates: make(map[string]*ClientAuthState),
CreatedAt: time.Now(),
// ...
}
// Set cookie with new session ID
s.setSessionCookie(w, newSessionID, rememberMe)
}
Don't accept session IDs from URL parameters: Session IDs are ONLY accepted from cookies, never from query parameters or POST data.
Strict cookie settings: HttpOnly, Secure, SameSite=Lax prevent common session theft vectors.
Session binding (optional future enhancement): Bind session to client characteristics (IP range, user agent) to detect stolen cookies.
Handling existing sessions during authentication:
When a user authenticates and an existing AuthSession is found:
AuthSession from storageThis ensures that even if an attacker set a session cookie before authentication, they cannot use it after the victim logs in.
| Risk | Mitigation |
|---|---|
| Storage growth | AuthSessions are GC'd on inactivity; UserIdentities are per-user like OfflineSessions; admin API allows cleanup |
| Storage performance | Additional read per request to resolve session cookie. Impact depends on backend — see note below |
| Migration complexity | Feature flag allows gradual rollout, no breaking changes |
Storage Performance Note
Enabling sessions introduces an additional storage read on each authorization request (to resolve the session cookie to an AuthSession). The actual performance impact depends on the storage backend:
At this stage, we do not have production metrics to quantify the exact impact. The storage access pattern is identical to existing OfflineSessions lookups (single record by key), which are already proven in production. It is recommended to monitor storage latency after enabling sessions and adjusting validIfNotUsedFor if the GC frequency needs tuning.
None - Sessions are opt-in via feature flag and configuration. Existing deployments continue to work without changes.
Sessions are fully controlled by the DEX_SESSIONS_ENABLED feature flag. Rollback is straightforward:
DEX_SESSIONS_ENABLED=false (or remove it)AuthSession and UserIdentity records remain in storage but are unused. They can be cleaned up manually or left to accumulate no further growthKey guarantee: Disabling the feature flag returns Dex to its pre-sessions behavior with zero side effects. No existing functionality (refresh tokens, connector authentication, token issuance) depends on sessions. Additional tables in the database cost nothing when the feature flag is disabled: they remain unused schema objects and can be deleted later if desired.
AuthSession and UserIdentity tables/resources automatically (no feature flag needed for schema)DEX_SESSIONS_ENABLED=true when ready to use sessionssessions: configuration blockNote: Storage schema changes (new tables/CRDs) are applied on startup regardless of feature flag. The feature flag only controls whether sessions are actually created and used. This simplifies deployment - you can deploy the new version, then enable sessions later without another deployment.
Approach: Store session data directly in a signed/encrypted JWT cookie.
Pros:
Cons:
Decision: Rejected. Server-side sessions are required for proper logout and SSO.
Approach: Add session data to existing OfflineSessions entity.
Pros:
Cons:
Decision: Rejected. Clean separation is better for maintainability.
Approach: Use Redis for session storage instead of existing backends.
Pros:
Cons:
Decision: Rejected. Must work with existing storage backends.
Approach: Keep using refresh tokens as implicit sessions.
Cons:
Decision: Rejected. These features are essential for enterprise adoption.
Identity Refresh for Long-Lived Sessions
Upstream Connector Logout
Session Introspection Endpoint
GET /session/introspect or similarFront-Channel Logout
logoutURL configuration2FA/MFA Support
Session Management API
Back-Channel Logout
Account Linking
Device/Session Fingerprinting
Per-Connector Session Policies
Session Impersonation for Admin
Consent Management UI