diff --git a/docs/OIDC.md b/docs/OIDC.md index ecbcf9196..ae3a17357 100644 --- a/docs/OIDC.md +++ b/docs/OIDC.md @@ -32,6 +32,23 @@ Restrict access to specific users or groups: * **Allowed Domains**: Restrict to specific email domains (e.g., `example.com`). * **Allowed Emails**: Allow specific email addresses. +### Long-Lived Sessions with `offline_access` +For persistent sessions that don't require frequent re-authentication: + +1. **Add `offline_access` scope**: Include `offline_access` in your OIDC scopes (e.g., `openid profile email offline_access`). +2. **Configure your IdP**: Ensure your identity provider issues refresh tokens when `offline_access` is requested. + +**How it works:** +- When you login with `offline_access`, Pulse stores the refresh token alongside your session. +- When your access token expires, Pulse automatically refreshes it using the stored refresh token. +- Your session remains valid as long as the refresh token is valid (typically 30-90 days depending on your IdP). +- If the IdP revokes access (user disabled, token revoked), Pulse detects this on the next refresh attempt and logs you out. + +**Security considerations:** +- Refresh tokens are stored encrypted at rest. +- If the IdP configuration changes, existing sessions with mismatched issuers are automatically invalidated. +- Failed refresh attempts immediately invalidate the session. + ## 📚 Provider Examples ### Authentik diff --git a/internal/api/auth.go b/internal/api/auth.go index e3e066c9b..69de26bd9 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -209,6 +209,15 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool if ValidateSession(cookie.Value) { // Check if this is an OIDC session if username := GetSessionUsername(cookie.Value); username != "" { + // Check if OIDC tokens need refresh + session := GetSessionStore().GetSession(cookie.Value) + if session != nil && session.OIDCRefreshToken != "" { + // Check if access token is expired or about to expire (5 min buffer) + if time.Now().Add(5 * time.Minute).After(session.OIDCAccessTokenExp) { + // Token needs refresh - attempt it asynchronously + go refreshOIDCSessionTokens(cfg, cookie.Value, session) + } + } w.Header().Set("X-Authenticated-User", username) w.Header().Set("X-Auth-Method", "oidc") return true @@ -689,3 +698,77 @@ func adminBypassEnabled() bool { }) return adminBypassState.enabled } + +// oidcRefreshMutex prevents concurrent refresh attempts for the same session +var oidcRefreshMutex sync.Map + +// refreshOIDCSessionTokens refreshes OIDC tokens for a session in the background +// If refresh fails, the session is invalidated and the user will need to re-login +func refreshOIDCSessionTokens(cfg *config.Config, sessionToken string, session *SessionData) { + // Prevent concurrent refresh attempts for the same session + if _, loaded := oidcRefreshMutex.LoadOrStore(sessionToken, true); loaded { + return // Another goroutine is already refreshing this session + } + defer oidcRefreshMutex.Delete(sessionToken) + + // Mark session as refreshing to prevent duplicate attempts + GetSessionStore().SetTokenRefreshing(sessionToken, true) + defer GetSessionStore().SetTokenRefreshing(sessionToken, false) + + log.Debug(). + Str("issuer", session.OIDCIssuer). + Time("token_expiry", session.OIDCAccessTokenExp). + Msg("Attempting OIDC token refresh") + + // Create a context with timeout for the refresh operation + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Get or create OIDC service for this session's issuer + oidcCfg := cfg.OIDC + if oidcCfg == nil || !oidcCfg.Enabled { + log.Warn().Msg("OIDC not enabled, cannot refresh tokens") + return + } + + // Verify the session's issuer matches our config + if oidcCfg.IssuerURL != session.OIDCIssuer { + log.Warn(). + Str("session_issuer", session.OIDCIssuer). + Str("config_issuer", oidcCfg.IssuerURL). + Msg("OIDC issuer mismatch, cannot refresh tokens") + GetSessionStore().InvalidateSession(sessionToken) + return + } + + // Create a temporary OIDC service for refreshing + service, err := NewOIDCService(ctx, oidcCfg) + if err != nil { + log.Error().Err(err).Msg("Failed to create OIDC service for token refresh") + return + } + + // Attempt to refresh the token + result, err := service.RefreshToken(ctx, session.OIDCRefreshToken) + if err != nil { + log.Warn(). + Err(err). + Str("issuer", session.OIDCIssuer). + Msg("OIDC token refresh failed - invalidating session") + + // Token refresh failed - this usually means the refresh token was revoked + // or expired. Invalidate the session to force re-login. + GetSessionStore().InvalidateSession(sessionToken) + LogAuditEvent("oidc_token_refresh", "", "", "", false, "Token refresh failed: "+err.Error()) + return + } + + // Update the session with new tokens + GetSessionStore().UpdateOIDCTokens(sessionToken, result.RefreshToken, result.Expiry) + + log.Info(). + Time("new_expiry", result.Expiry). + Msg("OIDC token refresh successful - session extended") + + LogAuditEvent("oidc_token_refresh", "", "", "", true, "Token refreshed successfully") +} diff --git a/internal/api/oidc_handlers.go b/internal/api/oidc_handlers.go index 13a8dc5e8..754abcf90 100644 --- a/internal/api/oidc_handlers.go +++ b/internal/api/oidc_handlers.go @@ -218,7 +218,22 @@ func (r *Router) handleOIDCCallback(w http.ResponseWriter, req *http.Request) { log.Debug().Msg("User group membership verified") } - if err := r.establishSession(w, req, username); err != nil { + // Prepare OIDC token info for session storage (enables refresh token support) + var oidcTokens *OIDCTokenInfo + if token.RefreshToken != "" { + oidcTokens = &OIDCTokenInfo{ + RefreshToken: token.RefreshToken, + AccessTokenExp: token.Expiry, + Issuer: cfg.IssuerURL, + ClientID: cfg.ClientID, + } + log.Debug(). + Time("access_token_expiry", token.Expiry). + Bool("has_refresh_token", true). + Msg("OIDC tokens will be stored for session refresh") + } + + if err := r.establishOIDCSession(w, req, username, oidcTokens); err != nil { log.Error().Err(err).Msg("Failed to establish session after OIDC login") LogAuditEvent("oidc_login", username, GetClientIP(req), req.URL.Path, false, "Session creation failed") r.redirectOIDCError(w, req, entry.ReturnTo, "session_failed") diff --git a/internal/api/oidc_service.go b/internal/api/oidc_service.go index 749d70690..e035f6ad9 100644 --- a/internal/api/oidc_service.go +++ b/internal/api/oidc_service.go @@ -210,6 +210,58 @@ func (s *OIDCService) contextWithHTTPClient(ctx context.Context) context.Context return oidc.ClientContext(ctx, s.httpClient) } +// OIDCRefreshResult contains the result of a token refresh operation +type OIDCRefreshResult struct { + AccessToken string + RefreshToken string + Expiry time.Time +} + +// RefreshToken uses the refresh token to obtain new access and refresh tokens from the IdP +func (s *OIDCService) RefreshToken(ctx context.Context, refreshToken string) (*OIDCRefreshResult, error) { + if refreshToken == "" { + return nil, errors.New("no refresh token provided") + } + + ctx = s.contextWithHTTPClient(ctx) + + // Create a token source from the refresh token + token := &oauth2.Token{ + RefreshToken: refreshToken, + // Set expiry in the past to force refresh + Expiry: time.Now().Add(-time.Hour), + } + + tokenSource := s.oauth2Cfg.TokenSource(ctx, token) + + // This will trigger a refresh since the token is expired + newToken, err := tokenSource.Token() + if err != nil { + log.Warn().Err(err).Msg("OIDC token refresh failed") + return nil, fmt.Errorf("failed to refresh token: %w", err) + } + + result := &OIDCRefreshResult{ + AccessToken: newToken.AccessToken, + Expiry: newToken.Expiry, + } + + // The new refresh token might be the same or different depending on the IdP + if newToken.RefreshToken != "" { + result.RefreshToken = newToken.RefreshToken + } else { + // Keep the old refresh token if a new one wasn't issued + result.RefreshToken = refreshToken + } + + log.Debug(). + Time("new_expiry", result.Expiry). + Bool("new_refresh_token", newToken.RefreshToken != ""). + Msg("OIDC token refresh successful") + + return result, nil +} + func newOIDCHTTPClient(caBundle string) (*http.Client, string, error) { transport, ok := http.DefaultTransport.(*http.Transport) var clone *http.Transport diff --git a/internal/api/router.go b/internal/api/router.go index 26d6f119e..4281d925a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2680,6 +2680,48 @@ func (r *Router) establishSession(w http.ResponseWriter, req *http.Request, user return nil } +// establishOIDCSession creates a session with OIDC token information for refresh token support +func (r *Router) establishOIDCSession(w http.ResponseWriter, req *http.Request, username string, oidcTokens *OIDCTokenInfo) error { + token := generateSessionToken() + if token == "" { + return fmt.Errorf("failed to generate session token") + } + + userAgent := req.Header.Get("User-Agent") + clientIP := GetClientIP(req) + + // Create session with OIDC tokens + GetSessionStore().CreateOIDCSession(token, 24*time.Hour, userAgent, clientIP, oidcTokens) + + if username != "" { + TrackUserSession(username, token) + } + + csrfToken := generateCSRFToken(token) + isSecure, sameSitePolicy := getCookieSettings(req) + + http.SetCookie(w, &http.Cookie{ + Name: "pulse_session", + Value: token, + Path: "/", + HttpOnly: true, + Secure: isSecure, + SameSite: sameSitePolicy, + MaxAge: 86400, + }) + + http.SetCookie(w, &http.Cookie{ + Name: "pulse_csrf", + Value: csrfToken, + Path: "/", + Secure: isSecure, + SameSite: sameSitePolicy, + MaxAge: 86400, + }) + + return nil +} + // handleLogin handles login requests and provides detailed feedback about lockouts func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { diff --git a/internal/api/session_oidc_test.go b/internal/api/session_oidc_test.go new file mode 100644 index 000000000..ebdb5fd88 --- /dev/null +++ b/internal/api/session_oidc_test.go @@ -0,0 +1,200 @@ +package api + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCreateOIDCSession(t *testing.T) { + // Create a temporary directory for test sessions + tmpDir, err := os.MkdirTemp("", "pulse-session-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + store := NewSessionStore(tmpDir) + + // Create an OIDC session with token info + token := "test-session-token-123" + oidcInfo := &OIDCTokenInfo{ + RefreshToken: "test-refresh-token", + AccessTokenExp: time.Now().Add(1 * time.Hour), + Issuer: "https://example.com", + ClientID: "test-client-id", + } + + store.CreateOIDCSession(token, 24*time.Hour, "TestAgent", "127.0.0.1", oidcInfo) + + // Verify session was created + if !store.ValidateSession(token) { + t.Error("Created OIDC session should be valid") + } + + // Verify OIDC token info was stored + session := store.GetSession(token) + if session == nil { + t.Fatal("GetSession returned nil for valid session") + } + + if session.OIDCRefreshToken != oidcInfo.RefreshToken { + t.Errorf("RefreshToken mismatch: got %q, want %q", session.OIDCRefreshToken, oidcInfo.RefreshToken) + } + + if session.OIDCIssuer != oidcInfo.Issuer { + t.Errorf("Issuer mismatch: got %q, want %q", session.OIDCIssuer, oidcInfo.Issuer) + } + + if session.OIDCClientID != oidcInfo.ClientID { + t.Errorf("ClientID mismatch: got %q, want %q", session.OIDCClientID, oidcInfo.ClientID) + } + + if session.OIDCAccessTokenExp.IsZero() { + t.Error("AccessTokenExp should not be zero") + } +} + +func TestUpdateOIDCTokens(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-session-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + store := NewSessionStore(tmpDir) + + token := "test-session-token-456" + originalExpiry := time.Now().Add(1 * time.Hour) + oidcInfo := &OIDCTokenInfo{ + RefreshToken: "original-refresh-token", + AccessTokenExp: originalExpiry, + Issuer: "https://example.com", + ClientID: "test-client-id", + } + + store.CreateOIDCSession(token, 24*time.Hour, "TestAgent", "127.0.0.1", oidcInfo) + + // Update the tokens (simulating a refresh) + newExpiry := time.Now().Add(2 * time.Hour) + store.UpdateOIDCTokens(token, "new-refresh-token", newExpiry) + + // Verify tokens were updated + session := store.GetSession(token) + if session == nil { + t.Fatal("GetSession returned nil for valid session") + } + + if session.OIDCRefreshToken != "new-refresh-token" { + t.Errorf("RefreshToken should be updated: got %q, want %q", session.OIDCRefreshToken, "new-refresh-token") + } + + if !session.OIDCAccessTokenExp.After(originalExpiry) { + t.Error("AccessTokenExp should be updated to new expiry") + } +} + +func TestOIDCSessionPersistence(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-session-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + token := "test-session-token-persist" + oidcInfo := &OIDCTokenInfo{ + RefreshToken: "persist-refresh-token", + AccessTokenExp: time.Now().Add(1 * time.Hour), + Issuer: "https://example.com", + ClientID: "test-client-id", + } + + // Create session in first store instance + store1 := NewSessionStore(tmpDir) + store1.CreateOIDCSession(token, 24*time.Hour, "TestAgent", "127.0.0.1", oidcInfo) + + // Verify file was written + sessionFile := filepath.Join(tmpDir, "sessions.json") + if _, err := os.Stat(sessionFile); os.IsNotExist(err) { + t.Fatal("Session file should exist after creating session") + } + + // Create new store instance (simulates restart) + store2 := NewSessionStore(tmpDir) + + // Verify session was loaded with OIDC info + session := store2.GetSession(token) + if session == nil { + t.Fatal("Session should be restored after reload") + } + + if session.OIDCRefreshToken != oidcInfo.RefreshToken { + t.Errorf("RefreshToken should persist: got %q, want %q", session.OIDCRefreshToken, oidcInfo.RefreshToken) + } + + if session.OIDCIssuer != oidcInfo.Issuer { + t.Errorf("Issuer should persist: got %q, want %q", session.OIDCIssuer, oidcInfo.Issuer) + } +} + +func TestCreateOIDCSession_NilTokenInfo(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-session-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + store := NewSessionStore(tmpDir) + + // Create OIDC session with nil token info (no refresh token available) + token := "test-session-no-refresh" + store.CreateOIDCSession(token, 24*time.Hour, "TestAgent", "127.0.0.1", nil) + + // Verify session was created + if !store.ValidateSession(token) { + t.Error("Session should be valid even without OIDC tokens") + } + + session := store.GetSession(token) + if session == nil { + t.Fatal("GetSession returned nil") + } + + if session.OIDCRefreshToken != "" { + t.Errorf("RefreshToken should be empty when nil info passed: got %q", session.OIDCRefreshToken) + } +} + +func TestInvalidateSession(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-session-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + store := NewSessionStore(tmpDir) + + token := "test-session-invalidate" + oidcInfo := &OIDCTokenInfo{ + RefreshToken: "refresh-token", + AccessTokenExp: time.Now().Add(1 * time.Hour), + Issuer: "https://example.com", + ClientID: "test-client-id", + } + + store.CreateOIDCSession(token, 24*time.Hour, "TestAgent", "127.0.0.1", oidcInfo) + + // Verify session exists + if !store.ValidateSession(token) { + t.Error("Session should be valid before invalidation") + } + + // Invalidate the session + store.InvalidateSession(token) + + // Verify session no longer exists + if store.ValidateSession(token) { + t.Error("Session should be invalid after invalidation") + } +} diff --git a/internal/api/session_store.go b/internal/api/session_store.go index 3e03cc5d3..0e7acdeda 100644 --- a/internal/api/session_store.go +++ b/internal/api/session_store.go @@ -33,6 +33,12 @@ type sessionPersisted struct { UserAgent string `json:"user_agent,omitempty"` IP string `json:"ip,omitempty"` OriginalDuration time.Duration `json:"original_duration,omitempty"` + // OIDC token fields for refresh token support + OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"` + OIDCAccessTokenExp time.Time `json:"oidc_access_token_exp,omitempty"` + OIDCIssuer string `json:"oidc_issuer,omitempty"` + OIDCClientID string `json:"oidc_client_id,omitempty"` + OIDCTokenRefreshing bool `json:"-"` // transient, not persisted } // SessionData represents a user session @@ -42,6 +48,12 @@ type SessionData struct { UserAgent string `json:"user_agent,omitempty"` IP string `json:"ip,omitempty"` OriginalDuration time.Duration `json:"original_duration,omitempty"` // Track original duration for sliding expiration + // OIDC token fields for refresh token support + OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"` // Encrypted at rest + OIDCAccessTokenExp time.Time `json:"oidc_access_token_exp,omitempty"` // When the access token expires + OIDCIssuer string `json:"oidc_issuer,omitempty"` // IdP issuer URL + OIDCClientID string `json:"oidc_client_id,omitempty"` // OIDC client ID + OIDCTokenRefreshing bool `json:"-"` // Prevents concurrent refresh attempts } // NewSessionStore creates a new persistent session store @@ -94,6 +106,96 @@ func (s *SessionStore) CreateSession(token string, duration time.Duration, userA s.saveUnsafe() } +// OIDCTokenInfo contains OAuth2 token information from the IdP +type OIDCTokenInfo struct { + RefreshToken string + AccessTokenExp time.Time + Issuer string + ClientID string +} + +// CreateOIDCSession creates a new session with OIDC token information +func (s *SessionStore) CreateOIDCSession(token string, duration time.Duration, userAgent, ip string, oidc *OIDCTokenInfo) { + s.mu.Lock() + defer s.mu.Unlock() + + key := sessionHash(token) + session := &SessionData{ + ExpiresAt: time.Now().Add(duration), + CreatedAt: time.Now(), + UserAgent: userAgent, + IP: ip, + OriginalDuration: duration, + } + + if oidc != nil { + session.OIDCRefreshToken = oidc.RefreshToken + session.OIDCAccessTokenExp = oidc.AccessTokenExp + session.OIDCIssuer = oidc.Issuer + session.OIDCClientID = oidc.ClientID + } + + s.sessions[key] = session + + // Save immediately for important operations + s.saveUnsafe() +} + +// GetSession returns a copy of the session data for the given token +func (s *SessionStore) GetSession(token string) *SessionData { + s.mu.RLock() + defer s.mu.RUnlock() + + session, exists := s.sessions[sessionHash(token)] + if !exists { + return nil + } + + // Return a copy to avoid race conditions + copy := *session + return © +} + +// UpdateOIDCTokens updates the OIDC tokens for a session after a successful refresh +func (s *SessionStore) UpdateOIDCTokens(token string, refreshToken string, accessTokenExp time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + + key := sessionHash(token) + session, exists := s.sessions[key] + if !exists { + return + } + + session.OIDCRefreshToken = refreshToken + session.OIDCAccessTokenExp = accessTokenExp + session.OIDCTokenRefreshing = false + + // Also extend the session expiry since the token is still valid + if session.OriginalDuration > 0 { + session.ExpiresAt = time.Now().Add(session.OriginalDuration) + } + + // Save immediately after token refresh + s.saveUnsafe() +} + +// InvalidateSession removes a session (used when OIDC refresh fails) +func (s *SessionStore) InvalidateSession(token string) { + s.DeleteSession(token) +} + +// SetTokenRefreshing marks a session as currently refreshing tokens +func (s *SessionStore) SetTokenRefreshing(token string, refreshing bool) { + s.mu.Lock() + defer s.mu.Unlock() + + key := sessionHash(token) + if session, exists := s.sessions[key]; exists { + session.OIDCTokenRefreshing = refreshing + } +} + // ValidateSession checks if a session is valid func (s *SessionStore) ValidateSession(token string) bool { s.mu.RLock() @@ -176,12 +278,16 @@ func (s *SessionStore) saveUnsafe() { persisted := make([]sessionPersisted, 0, len(s.sessions)) for key, session := range s.sessions { persisted = append(persisted, sessionPersisted{ - Key: key, - ExpiresAt: session.ExpiresAt, - CreatedAt: session.CreatedAt, - UserAgent: session.UserAgent, - IP: session.IP, - OriginalDuration: session.OriginalDuration, + Key: key, + ExpiresAt: session.ExpiresAt, + CreatedAt: session.CreatedAt, + UserAgent: session.UserAgent, + IP: session.IP, + OriginalDuration: session.OriginalDuration, + OIDCRefreshToken: session.OIDCRefreshToken, + OIDCAccessTokenExp: session.OIDCAccessTokenExp, + OIDCIssuer: session.OIDCIssuer, + OIDCClientID: session.OIDCClientID, }) } @@ -229,11 +335,15 @@ func (s *SessionStore) load() { continue } s.sessions[entry.Key] = &SessionData{ - ExpiresAt: entry.ExpiresAt, - CreatedAt: entry.CreatedAt, - UserAgent: entry.UserAgent, - IP: entry.IP, - OriginalDuration: entry.OriginalDuration, + ExpiresAt: entry.ExpiresAt, + CreatedAt: entry.CreatedAt, + UserAgent: entry.UserAgent, + IP: entry.IP, + OriginalDuration: entry.OriginalDuration, + OIDCRefreshToken: entry.OIDCRefreshToken, + OIDCAccessTokenExp: entry.OIDCAccessTokenExp, + OIDCIssuer: entry.OIDCIssuer, + OIDCClientID: entry.OIDCClientID, } } log.Info().Int("loaded", len(s.sessions)).Int("total", len(persisted)).Msg("Sessions loaded from disk (hashed format)")