feat(oidc): Add refresh token support for long-lived sessions

When offline_access scope is configured, Pulse now stores and uses
OIDC refresh tokens to automatically extend sessions. Sessions remain
valid as long as the IdP allows token refresh (typically 30-90 days).

Changes:
- Store OIDC tokens (refresh token, expiry, issuer) alongside sessions
- Automatically refresh tokens when access token nears expiry
- Invalidate session if IdP revokes access (forces re-login)
- Add background token refresh with concurrency protection
- Persist OIDC tokens across restarts

Related to #854
This commit is contained in:
rcourtman
2025-12-20 10:45:46 +00:00
parent d18521c29d
commit b6140cd6e8
7 changed files with 531 additions and 12 deletions

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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 &copy
}
// 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)")