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:
parent
7b249763f6
commit
881b32cc71
7 changed files with 531 additions and 12 deletions
17
docs/OIDC.md
17
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 Domains**: Restrict to specific email domains (e.g., `example.com`).
|
||||||
* **Allowed Emails**: Allow specific email addresses.
|
* **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
|
## 📚 Provider Examples
|
||||||
|
|
||||||
### Authentik
|
### Authentik
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,15 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
||||||
if ValidateSession(cookie.Value) {
|
if ValidateSession(cookie.Value) {
|
||||||
// Check if this is an OIDC session
|
// Check if this is an OIDC session
|
||||||
if username := GetSessionUsername(cookie.Value); username != "" {
|
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-Authenticated-User", username)
|
||||||
w.Header().Set("X-Auth-Method", "oidc")
|
w.Header().Set("X-Auth-Method", "oidc")
|
||||||
return true
|
return true
|
||||||
|
|
@ -689,3 +698,77 @@ func adminBypassEnabled() bool {
|
||||||
})
|
})
|
||||||
return adminBypassState.enabled
|
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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,22 @@ func (r *Router) handleOIDCCallback(w http.ResponseWriter, req *http.Request) {
|
||||||
log.Debug().Msg("User group membership verified")
|
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")
|
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")
|
LogAuditEvent("oidc_login", username, GetClientIP(req), req.URL.Path, false, "Session creation failed")
|
||||||
r.redirectOIDCError(w, req, entry.ReturnTo, "session_failed")
|
r.redirectOIDCError(w, req, entry.ReturnTo, "session_failed")
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,58 @@ func (s *OIDCService) contextWithHTTPClient(ctx context.Context) context.Context
|
||||||
return oidc.ClientContext(ctx, s.httpClient)
|
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) {
|
func newOIDCHTTPClient(caBundle string) (*http.Client, string, error) {
|
||||||
transport, ok := http.DefaultTransport.(*http.Transport)
|
transport, ok := http.DefaultTransport.(*http.Transport)
|
||||||
var clone *http.Transport
|
var clone *http.Transport
|
||||||
|
|
|
||||||
|
|
@ -2680,6 +2680,48 @@ func (r *Router) establishSession(w http.ResponseWriter, req *http.Request, user
|
||||||
return nil
|
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
|
// handleLogin handles login requests and provides detailed feedback about lockouts
|
||||||
func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
|
|
|
||||||
200
internal/api/session_oidc_test.go
Normal file
200
internal/api/session_oidc_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,12 @@ type sessionPersisted struct {
|
||||||
UserAgent string `json:"user_agent,omitempty"`
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
IP string `json:"ip,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
OriginalDuration time.Duration `json:"original_duration,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
|
// SessionData represents a user session
|
||||||
|
|
@ -42,6 +48,12 @@ type SessionData struct {
|
||||||
UserAgent string `json:"user_agent,omitempty"`
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
IP string `json:"ip,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
OriginalDuration time.Duration `json:"original_duration,omitempty"` // Track original duration for sliding expiration
|
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
|
// NewSessionStore creates a new persistent session store
|
||||||
|
|
@ -94,6 +106,96 @@ func (s *SessionStore) CreateSession(token string, duration time.Duration, userA
|
||||||
s.saveUnsafe()
|
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
|
// ValidateSession checks if a session is valid
|
||||||
func (s *SessionStore) ValidateSession(token string) bool {
|
func (s *SessionStore) ValidateSession(token string) bool {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|
@ -176,12 +278,16 @@ func (s *SessionStore) saveUnsafe() {
|
||||||
persisted := make([]sessionPersisted, 0, len(s.sessions))
|
persisted := make([]sessionPersisted, 0, len(s.sessions))
|
||||||
for key, session := range s.sessions {
|
for key, session := range s.sessions {
|
||||||
persisted = append(persisted, sessionPersisted{
|
persisted = append(persisted, sessionPersisted{
|
||||||
Key: key,
|
Key: key,
|
||||||
ExpiresAt: session.ExpiresAt,
|
ExpiresAt: session.ExpiresAt,
|
||||||
CreatedAt: session.CreatedAt,
|
CreatedAt: session.CreatedAt,
|
||||||
UserAgent: session.UserAgent,
|
UserAgent: session.UserAgent,
|
||||||
IP: session.IP,
|
IP: session.IP,
|
||||||
OriginalDuration: session.OriginalDuration,
|
OriginalDuration: session.OriginalDuration,
|
||||||
|
OIDCRefreshToken: session.OIDCRefreshToken,
|
||||||
|
OIDCAccessTokenExp: session.OIDCAccessTokenExp,
|
||||||
|
OIDCIssuer: session.OIDCIssuer,
|
||||||
|
OIDCClientID: session.OIDCClientID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,11 +335,15 @@ func (s *SessionStore) load() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.sessions[entry.Key] = &SessionData{
|
s.sessions[entry.Key] = &SessionData{
|
||||||
ExpiresAt: entry.ExpiresAt,
|
ExpiresAt: entry.ExpiresAt,
|
||||||
CreatedAt: entry.CreatedAt,
|
CreatedAt: entry.CreatedAt,
|
||||||
UserAgent: entry.UserAgent,
|
UserAgent: entry.UserAgent,
|
||||||
IP: entry.IP,
|
IP: entry.IP,
|
||||||
OriginalDuration: entry.OriginalDuration,
|
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)")
|
log.Info().Int("loaded", len(s.sessions)).Int("total", len(persisted)).Msg("Sessions loaded from disk (hashed format)")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue