Files
pulse/internal/api/auth.go
T
rcourtman 14a82e7684 fix(api): log routine authorization refusals at debug, warn on the rate
A refusal is the access control working, not a fault, but every one of them
warned twice: once in RequireAuth/RequireAdmin/RequirePermission, and again in
the middleware, which warned on every 4xx unconditionally. A correctly
configured instance therefore could not produce a quiet log, and #1601's rc.9
reporter read that stream as an RBAC regression.

Gating clients one at a time does not fix this. The previous commit stopped six
endpoints being polled by a non-admin UI, and /api/updates/status,
/api/updates/plan and /api/availability-targets still warned, as would every
caller nobody has audited yet.

Refusals now route through logAuthDenial, which records them at debug and counts
them per caller. Attribution prefers the authenticated username so a principal
stays tracked across rotating addresses, falling back to the client IP. Crossing
20 refusals in a minute emits exactly one warn for that window, which is the
shape that separates probing from a UI mounting a surface its session cannot
read; a closed window re-arms it. The tracked set is bounded with oldest-window
eviction so spoofed forwarded-for values cannot grow it. The middleware now
warns only on 5xx.

Enforcement is untouched: every route returns the same status to the same
callers, and the contract test pins that pairing so a future attempt to quiet
the log by relaxing enforcement fails rather than passes. Verified live on a
proxy-auth instance - /api/connections, /api/updates/status and
/api/system/settings still 403 for a viewer and 200 for an admin; 19 refusals
produce no warn, the 20th produces one, and 30 more produce none; an idle
non-admin browser session logged zero warn lines across 90 seconds.
2026-08-07 21:38:47 +01:00

1537 lines
49 KiB
Go

package api
import (
"context"
cryptorand "crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
// Global session store instance
var (
sessionStore *SessionStore
sessionStoreDataPath string
sessionStoreMu sync.Mutex
adminBypassState struct {
once sync.Once
enabled bool
declined bool
}
)
const privilegedBrowserSessionMaxAge = 5 * time.Minute
const (
oidcMaxRefreshLead = 5 * time.Minute
oidcFallbackRefreshLead = time.Minute
oidcRefreshLifetimePart = 5
)
type authRequirementFailure struct {
Status int
Code string
Message string
}
type ssoOIDCProviderAuthSnapshot struct {
ProviderID string
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
Scopes []string
UsernameClaim string
EmailClaim string
CABundle string
}
type ssoAuthSnapshot struct {
HasEnabledProviders bool
OIDCProviders []ssoOIDCProviderAuthSnapshot
}
func emptySSOAuthSnapshot() ssoAuthSnapshot {
snapshot := ssoAuthSnapshot{}
snapshot.normalizeCollections()
return snapshot
}
func (s *ssoAuthSnapshot) normalizeCollections() {
if s.OIDCProviders == nil {
s.OIDCProviders = []ssoOIDCProviderAuthSnapshot{}
}
}
var authSSOState = struct {
mu sync.RWMutex
byConfigID map[string]ssoAuthSnapshot
}{
byConfigID: make(map[string]ssoAuthSnapshot),
}
func authConfigID(cfg *config.Config) string {
if cfg == nil {
return ""
}
if id := strings.TrimSpace(cfg.DataPath); id != "" {
return id
}
return strings.TrimSpace(cfg.ConfigPath)
}
func buildSSOAuthSnapshot(ssoCfg *config.SSOConfig) ssoAuthSnapshot {
if ssoCfg == nil {
return emptySSOAuthSnapshot()
}
enabledProviders := ssoCfg.GetEnabledProviders()
snapshot := ssoAuthSnapshot{HasEnabledProviders: len(enabledProviders) > 0}
for _, provider := range enabledProviders {
if provider.Type != config.SSOProviderTypeOIDC || provider.OIDC == nil {
continue
}
scopes := append([]string{}, provider.OIDC.Scopes...)
if len(scopes) == 0 {
scopes = []string{"openid", "profile", "email"}
}
snapshot.OIDCProviders = append(snapshot.OIDCProviders, ssoOIDCProviderAuthSnapshot{
ProviderID: provider.ID,
IssuerURL: provider.OIDC.IssuerURL,
ClientID: provider.OIDC.ClientID,
ClientSecret: provider.OIDC.ClientSecret,
RedirectURL: provider.OIDC.RedirectURL,
Scopes: scopes,
UsernameClaim: provider.OIDC.UsernameClaim,
EmailClaim: provider.OIDC.EmailClaim,
CABundle: provider.OIDC.CABundle,
})
}
snapshot.normalizeCollections()
return snapshot
}
func setSSOAuthSnapshot(cfg *config.Config, ssoCfg *config.SSOConfig) {
configID := authConfigID(cfg)
if configID == "" {
return
}
authSSOState.mu.Lock()
authSSOState.byConfigID[configID] = buildSSOAuthSnapshot(ssoCfg)
authSSOState.mu.Unlock()
}
func getSSOAuthSnapshot(cfg *config.Config) ssoAuthSnapshot {
configID := authConfigID(cfg)
if configID == "" {
return emptySSOAuthSnapshot()
}
authSSOState.mu.RLock()
snapshot := authSSOState.byConfigID[configID]
authSSOState.mu.RUnlock()
snapshot.normalizeCollections()
return snapshot
}
func hasEnabledSSOProvidersForAuth(cfg *config.Config) bool {
return getSSOAuthSnapshot(cfg).HasEnabledProviders
}
func resolveOIDCRefreshConfig(cfg *config.Config, session *SessionData) (*config.OIDCConfig, string) {
if session == nil {
return nil, ""
}
issuer := strings.TrimSpace(session.OIDCIssuer)
if issuer == "" {
return nil, ""
}
sessionClientID := strings.TrimSpace(session.OIDCClientID)
snapshot := getSSOAuthSnapshot(cfg)
if !snapshot.HasEnabledProviders {
return nil, ""
}
for _, provider := range snapshot.OIDCProviders {
if strings.TrimSpace(provider.IssuerURL) != issuer {
continue
}
if sessionClientID != "" && strings.TrimSpace(provider.ClientID) != sessionClientID {
continue
}
return &config.OIDCConfig{
Enabled: true,
IssuerURL: provider.IssuerURL,
ClientID: provider.ClientID,
ClientSecret: provider.ClientSecret,
RedirectURL: provider.RedirectURL,
Scopes: append([]string{}, provider.Scopes...),
UsernameClaim: provider.UsernameClaim,
EmailClaim: provider.EmailClaim,
CABundle: provider.CABundle,
}, provider.ProviderID
}
return nil, ""
}
type authContextKey string
const (
adminBypassContextKey authContextKey = "admin_bypass"
)
// InitSessionStore initializes the persistent session store
func InitSessionStore(dataPath string) {
_ = ensureSessionStore(dataPath)
}
func ensureSessionStore(dataPath string) *SessionStore {
newDataPath := strings.TrimSpace(dataPath)
if newDataPath == "" {
return nil
}
sessionStoreMu.Lock()
defer sessionStoreMu.Unlock()
if sessionStore != nil && sessionStoreDataPath == newDataPath {
return sessionStore
}
oldStore := sessionStore
sessionStore = NewSessionStore(newDataPath)
sessionStoreDataPath = newDataPath
if oldStore != nil {
oldStore.Shutdown()
}
return sessionStore
}
func InitPersistentAuthStores(dataPath string) {
InitSessionStore(dataPath)
InitCSRFStore(dataPath)
}
// GetSessionStore returns the global session store instance
func GetSessionStore() *SessionStore {
sessionStoreMu.Lock()
store := sessionStore
sessionStoreMu.Unlock()
if store == nil {
panic("session store not initialized; call InitSessionStore with the configured data path first")
}
return store
}
func resetSessionStoreForTests() {
sessionStoreMu.Lock()
oldStore := sessionStore
sessionStore = nil
sessionStoreDataPath = ""
sessionStoreMu.Unlock()
if oldStore != nil {
oldStore.Shutdown()
}
}
// detectProxy checks if the request is coming through a reverse proxy.
// Only trusts proxy-set headers when the direct peer is a known trusted proxy
// to prevent attackers from injecting these headers on direct connections.
func detectProxy(r *http.Request) bool {
peerIP := extractRemoteIP(r.RemoteAddr)
if !isTrustedProxyIP(peerIP) {
return false
}
return r.Header.Get("X-Forwarded-For") != "" ||
r.Header.Get("X-Real-IP") != "" ||
r.Header.Get("X-Forwarded-Proto") != "" ||
r.Header.Get("X-Forwarded-Host") != "" ||
r.Header.Get("Forwarded") != "" || // RFC 7239
r.Header.Get("CF-Ray") != "" || // Cloudflare
r.Header.Get("CF-Connecting-IP") != "" || // Cloudflare
r.Header.Get("X-Forwarded-Server") != "" || // Some proxies
r.Header.Get("X-Forwarded-Port") != "" // Some proxies
}
// isConnectionSecure checks if the connection is over HTTPS.
// Forwarded-proto headers are only trusted when the direct peer is a known
// trusted proxy, preventing attackers from injecting X-Forwarded-Proto: https
// on plain HTTP connections to influence cookie security attributes.
func isConnectionSecure(r *http.Request) bool {
if r.TLS != nil {
return true
}
peerIP := extractRemoteIP(r.RemoteAddr)
if !isTrustedProxyIP(peerIP) {
return false
}
return r.Header.Get("X-Forwarded-Proto") == "https" ||
strings.Contains(r.Header.Get("Forwarded"), "proto=https")
}
// isWebSocketUpgrade reports whether the request is a WebSocket upgrade handshake.
// Query-string tokens are only accepted for WebSocket connections because those
// can't set custom headers during the upgrade. Accepting tokens in the URL for
// regular HTTP requests would expose them in logs, referrers, and browser history.
func isWebSocketUpgrade(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
// getCookieSettings returns the appropriate cookie settings based on proxy detection.
//
// SameSite defaults to Lax for ALL requests, regardless of proxy mode. The
// previous behaviour auto-escalated to SameSite=None whenever the request
// arrived via a trusted proxy on https — intended to "be more permissive"
// for proxied deployments, but in practice that disabled the browser-side
// CSRF defence for every deployment behind a reverse proxy (which is nearly
// all of them). SameSite=None tells the browser to send the cookie on
// arbitrary cross-site requests, which is only required for cross-origin
// iframe embedding scenarios Pulse does not document or support. Lax
// continues to send cookies on top-level navigations (the OIDC/SAML
// callback case), so the proxied-login flow is unaffected.
//
// Secure is still set automatically based on the actual connection state
// (TLS, or X-Forwarded-Proto: https from a trusted proxy).
func getCookieSettings(r *http.Request) (secure bool, sameSite http.SameSite) {
policy := getBrowserCookiePolicy(r)
return policy.secure, policy.sameSite
}
type browserCookiePolicy struct {
secure bool
sameSite http.SameSite
}
func getBrowserCookiePolicy(r *http.Request) browserCookiePolicy {
if r == nil {
return browserCookiePolicy{}
}
isProxied := detectProxy(r)
isSecure := isConnectionSecure(r)
// Debug logging for Cloudflare tunnel issues
if isProxied {
log.Debug().
Bool("proxied", isProxied).
Bool("secure", isSecure).
Str("cf_ray", r.Header.Get("CF-Ray")).
Str("cf_connecting_ip", r.Header.Get("CF-Connecting-IP")).
Str("x_forwarded_for", r.Header.Get("X-Forwarded-For")).
Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).
Msg("Proxy/tunnel detected - cookies use Lax SameSite")
}
return browserCookiePolicy{
secure: isSecure,
sameSite: http.SameSiteLaxMode,
}
}
// setClientReadable writes a browser cookie that the frontend must read.
// Plain HTTP remains limited to the supported loopback/self-hosted boundary;
// public deployments receive Secure cookies.
func (p browserCookiePolicy) setClientReadable(w http.ResponseWriter, cookie *http.Cookie) {
if w == nil || cookie == nil {
return
}
cookie.HttpOnly = false
cookie.Secure = p.secure
cookie.SameSite = p.sameSite
http.SetCookie(w, cookie)
}
// setHTTPOnly writes an authentication cookie that must never be available to
// browser scripts. Keeping this as a distinct sink makes the security contract
// independent of each caller's cookie literal.
func (p browserCookiePolicy) setHTTPOnly(w http.ResponseWriter, cookie *http.Cookie) {
if w == nil || cookie == nil {
return
}
cookie.HttpOnly = true
cookie.Secure = p.secure
cookie.SameSite = p.sameSite
http.SetCookie(w, cookie)
}
// Cookie name constants. The session cookie uses the __Host- prefix when served
// over HTTPS, which instructs browsers to reject the cookie unless Secure is set,
// Path is "/", and no Domain attribute is present — preventing cookie injection via
// related subdomains. The CSRF and org cookies do not use the prefix: the CSRF
// cookie must be JS-readable for AJAX headers, and the org cookie must be
// JS-readable for WebSocket org context synchronization.
const (
cookieNameSession = "pulse_session"
cookieNameSessionSecure = "__Host-pulse_session"
CookieNameCSRF = "pulse_csrf"
CookieNameOrgID = "pulse_org_id"
)
// sessionCookieName returns the appropriate session cookie name based on whether
// the connection is secure. When secure, the __Host- prefix is used.
func sessionCookieName(secure bool) string {
if secure {
return cookieNameSessionSecure
}
return cookieNameSession
}
// readSessionCookie reads the session cookie from the request. Secure requests
// must present the __Host- prefixed cookie; insecure requests continue to use
// the legacy unprefixed name.
func readSessionCookie(r *http.Request) (*http.Cookie, error) {
if r == nil {
return nil, http.ErrNoCookie
}
if isConnectionSecure(r) {
return r.Cookie(cookieNameSessionSecure)
}
if c, err := r.Cookie(cookieNameSession); err == nil {
return c, nil
}
return r.Cookie(cookieNameSessionSecure)
}
// generateSessionToken creates a cryptographically secure session token
func generateSessionToken() string {
b := make([]byte, 32)
if _, err := cryptorand.Read(b); err != nil {
log.Error().Err(err).Msg("Failed to generate secure session token")
// Fallback - should never happen
return ""
}
return hex.EncodeToString(b)
}
// ValidateSession checks if a session token is valid
func ValidateSession(token string) bool {
return GetSessionStore().ValidateSession(token)
}
// ValidateAndExtendSession validates a session and extends its expiration (sliding window)
func ValidateAndExtendSession(token string) bool {
return GetSessionStore().ValidateAndExtendSession(token)
}
func constantTimeStringEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func validateFreshBrowserSession(r *http.Request, username, action string, maxAge time.Duration) *authRequirementFailure {
if maxAge <= 0 {
maxAge = privilegedBrowserSessionMaxAge
}
action = strings.TrimSpace(action)
if action == "" {
action = "continue"
}
cookie, err := readSessionCookie(r)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return &authRequirementFailure{
Status: http.StatusUnauthorized,
Code: "fresh_session_required",
Message: fmt.Sprintf("Sign in again to %s", action),
}
}
if !ValidateSession(cookie.Value) {
return &authRequirementFailure{
Status: http.StatusUnauthorized,
Code: "fresh_session_required",
Message: fmt.Sprintf("Sign in again to %s", action),
}
}
session := GetSessionStore().GetSession(cookie.Value)
if session == nil || strings.TrimSpace(session.Username) == "" || !constantTimeStringEqual(session.Username, username) {
return &authRequirementFailure{
Status: http.StatusUnauthorized,
Code: "fresh_session_required",
Message: fmt.Sprintf("Sign in again to %s", action),
}
}
if session.CreatedAt.IsZero() || time.Since(session.CreatedAt) > maxAge {
return &authRequirementFailure{
Status: http.StatusUnauthorized,
Code: "fresh_session_required",
Message: fmt.Sprintf("Sign in again to %s", action),
}
}
return nil
}
func requestMatchesRecoverySession(r *http.Request, session *SessionData) bool {
if r == nil || session == nil || !session.RecoveryBypass || !isDirectLoopbackRequest(r) {
return false
}
expectedIP := normalizeRecoveryBindingIP(session.IP)
actualIP := normalizeRecoveryBindingIP(GetClientIP(r))
return expectedIP != "" && actualIP != "" && constantTimeStringEqual(expectedIP, actualIP)
}
func explicitAPITokenFromRequest(r *http.Request) (string, bool) {
if r == nil {
return "", false
}
if values := r.Header.Values("X-API-Token"); len(values) > 0 {
return strings.TrimSpace(values[0]), true
}
if authHeader := r.Header.Get("Authorization"); authHeader != "" && strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
return strings.TrimSpace(authHeader[7:]), true
}
if isWebSocketUpgrade(r) {
if values, ok := r.URL.Query()["token"]; ok && len(values) > 0 {
return strings.TrimSpace(values[0]), true
}
}
return "", false
}
func validateGlobalAPITokenLocked(cfg *config.Config, token string) (*config.APITokenRecord, bool) {
if cfg == nil || token == "" || !cfg.IsValidAPIToken(token) {
return nil, false
}
config.Mu.RUnlock()
config.Mu.Lock()
record, ok := cfg.ValidateAPIToken(token)
config.Mu.Unlock()
config.Mu.RLock()
if !ok {
return nil, false
}
return record, true
}
func validateAPITokenAgainstConfigsLocked(globalCfg, targetCfg *config.Config, token string) (*config.APITokenRecord, bool) {
if token == "" {
return nil, false
}
if targetCfg != nil && targetCfg != globalCfg {
if record, ok := targetCfg.ValidateAPIToken(token); ok {
return record, true
}
}
return validateGlobalAPITokenLocked(globalCfg, token)
}
// CheckProxyAuth validates proxy authentication headers
func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
// Check if proxy auth is configured
if cfg.ProxyAuthSecret == "" {
return false, "", false
}
// Validate proxy secret header
proxySecret := r.Header.Get("X-Proxy-Secret")
if subtle.ConstantTimeCompare([]byte(proxySecret), []byte(cfg.ProxyAuthSecret)) != 1 {
log.Debug().
Int("provided_secret_length", len(proxySecret)).
Msg("Invalid proxy secret")
return false, "", false
}
// Get username from header if configured
username := ""
if cfg.ProxyAuthUserHeader != "" {
username = r.Header.Get(cfg.ProxyAuthUserHeader)
if username == "" {
log.Debug().Str("header", cfg.ProxyAuthUserHeader).Msg("Proxy auth user header not found")
return false, "", false
}
}
// Check admin role if configured. Once a role header and admin role are
// configured, admin access must be proven by an explicit role value.
isAdmin := true // Default to admin if no role checking configured
if cfg.ProxyAuthRoleHeader != "" && cfg.ProxyAuthAdminRole != "" {
roles := r.Header.Get(cfg.ProxyAuthRoleHeader)
isAdmin = false
if strings.TrimSpace(roles) == "" {
log.Debug().
Str("header", cfg.ProxyAuthRoleHeader).
Msg("Proxy auth role header missing or empty")
} else {
// Split roles by separator
separator := cfg.ProxyAuthRoleSeparator
if separator == "" {
separator = "|"
}
roleList := strings.Split(roles, separator)
for _, role := range roleList {
if strings.TrimSpace(role) == cfg.ProxyAuthAdminRole {
isAdmin = true
break
}
}
}
log.Debug().
Str("roles", roles).
Bool("is_admin", isAdmin).
Msg("Proxy auth roles checked")
}
log.Debug().
Str("user", username).
Bool("is_admin", isAdmin).
Msg("Proxy authentication successful")
return true, username, isAdmin
}
// responseCapture tracks whether authentication selected an explicit HTTP
// status. Body writes flow through the embedded ResponseWriter unchanged; auth
// paths only emit fixed response bodies after selecting a status.
type responseCapture struct {
http.ResponseWriter
wrote bool
}
func (rc *responseCapture) WriteHeader(statusCode int) {
if !rc.wrote {
rc.wrote = true
rc.ResponseWriter.WriteHeader(statusCode)
}
}
func wantsJSONAuthResponse(r *http.Request) bool {
if r == nil {
return false
}
if r.URL != nil && strings.HasPrefix(r.URL.Path, "/api/") {
return true
}
return strings.Contains(r.Header.Get("Accept"), "application/json")
}
func writeAuthenticationRequired(w http.ResponseWriter, r *http.Request) {
if w == nil {
return
}
if wantsJSONAuthResponse(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"Authentication required"}`))
return
}
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
// CheckAuth checks both basic auth and API token.
func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool {
return checkAuth(cfg, w, r, true)
}
// snapshotLocalAuthCredentialsLocked returns the local credentials while the
// caller holds config.Mu for reading or writing. It deliberately does not take
// the lock itself: checkAuth keeps a read lock across its authentication
// decision, and recursively taking an RWMutex read lock can deadlock once an
// API-token validation is waiting to upgrade to the write lock.
func snapshotLocalAuthCredentialsLocked(cfg *config.Config) (string, string) {
return cfg.AuthUser, cfg.AuthPass
}
func checkAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request, writeDefaultFailure bool) bool {
// Dev mode bypass for all auth (disabled by default)
if adminBypassEnabled() {
if w != nil {
// Set headers for standard admin user
w.Header().Set("X-Authenticated-User", "admin")
w.Header().Set("X-Auth-Method", "bypass")
}
return true
}
if cfg == nil {
path := ""
if r != nil && r.URL != nil {
path = r.URL.Path
}
log.Error().
Str("path", path).
Msg("CheckAuth called without configuration")
if w != nil {
http.Error(w, "Authentication unavailable", http.StatusServiceUnavailable)
}
return false
}
config.Mu.RLock()
defer config.Mu.RUnlock()
// Check proxy auth first if configured
if cfg.ProxyAuthSecret != "" {
if valid, username, _ := CheckProxyAuth(cfg, r); valid {
// Set username in response header for frontend
if username != "" {
w.Header().Set("X-Authenticated-User", username)
}
w.Header().Set("X-Auth-Method", "proxy")
return true
}
}
log.Debug().
Str("configured_user", cfg.AuthUser).
Bool("has_pass", cfg.AuthPass != "").
Bool("has_token", cfg.HasAPITokens()).
Str("url", r.URL.Path).
Msg("Checking authentication")
authenticateToken := func(token string) bool {
if record, ok := validateGlobalAPITokenLocked(cfg, token); ok {
attachAPITokenRecord(r, record)
if authenticatedUser := apiTokenAuthenticatedUser(record); authenticatedUser != "" {
w.Header().Set("X-Authenticated-User", authenticatedUser)
}
w.Header().Set("X-Auth-Method", "api_token")
return true
}
return false
}
// Explicit token credentials always take precedence over session/basic auth.
if cfg.HasAPITokens() {
if providedToken, provided := explicitAPITokenFromRequest(r); provided {
if authenticateToken(providedToken) {
return true
}
if w != nil {
http.Error(w, "Invalid API token", http.StatusUnauthorized)
}
return false
}
}
// Check session cookie (for WebSocket and UI). Hosted cloud-handoff and
// other browser-session flows must stay authoritative even when the runtime
// also has API tokens configured.
if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" {
session := GetSessionStore().GetSession(cookie.Value)
if session != nil && session.RecoveryBypass {
if requestMatchesRecoverySession(r, session) && ValidateAndExtendSession(cookie.Value) {
if session.Username != "" {
w.Header().Set("X-Authenticated-User", session.Username)
}
w.Header().Set("X-Auth-Method", "recovery_session")
w.Header().Set("X-Auth-Recovery", "true")
return true
}
log.Warn().
Str("path", r.URL.Path).
Str("client_ip", GetClientIP(r)).
Str("session_ip", session.IP).
Msg("Rejected recovery session outside direct loopback binding")
} else if ValidateAndExtendSession(cookie.Value) {
username := GetSessionUsername(cookie.Value)
if session != nil && hasEnabledSSOProvidersForAuth(cfg) {
if shouldRefreshOIDCSessionToken(time.Now(), session) {
go refreshOIDCSessionTokens(cfg, cookie.Value, session)
}
}
if username != "" {
w.Header().Set("X-Authenticated-User", username)
}
if session != nil && strings.TrimSpace(session.OIDCIssuer) != "" {
w.Header().Set("X-Auth-Method", "oidc")
} else {
w.Header().Set("X-Auth-Method", "session")
}
return true
}
// Debug logging for failed session validation
log.Debug().
Str("session_token", safePrefixForLog(cookie.Value, 8)+"...").
Str("path", r.URL.Path).
Msg("Session validation failed - token not found or expired")
} else if err != nil {
// Debug logging when no session cookie found
log.Debug().
Err(err).
Str("path", r.URL.Path).
Bool("has_cf_headers", r.Header.Get("CF-Ray") != "").
Msg("No session cookie found")
}
// API-only mode: when only API tokens are configured (no password auth),
// explicit token credentials still win above and a valid session still wins
// above for hosted/cloud-handoff browser flows. If neither is present, fail
// closed and require a token.
if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.HasAPITokens() {
if w != nil {
w.Header().Set("WWW-Authenticate", `Bearer realm="API token required; supply via Authorization header or X-API-Token header"`)
http.Error(w, "API token required via Authorization header or X-API-Token header", http.StatusUnauthorized)
}
return false
}
// If no auth is configured at all, allow access unless SSO is enabled.
// A valid session still wins above, so hosted/cloud-handoff browser sessions
// do not get flattened into anonymous access.
if cfg.AuthUser == "" && cfg.AuthPass == "" && !cfg.HasAPITokens() && cfg.ProxyAuthSecret == "" {
if hasEnabledSSOProvidersForAuth(cfg) {
log.Debug().Msg("SSO enabled without local credentials, authentication required")
} else {
if isDirectLoopbackRequest(r) {
log.Debug().Msg("No auth configured, allowing loopback access as 'anonymous'")
if w != nil {
w.Header().Set("X-Authenticated-User", "anonymous")
w.Header().Set("X-Auth-Method", "none")
}
return true
}
log.Warn().
Str("path", r.URL.Path).
Str("ip", GetClientIP(r)).
Msg("Rejected non-loopback access before auth was configured")
}
}
// Check basic auth
if cfg.AuthUser != "" && cfg.AuthPass != "" {
auth := r.Header.Get("Authorization")
authScheme := "none"
if auth != "" {
if idx := strings.IndexByte(auth, ' '); idx != -1 {
authScheme = strings.ToLower(auth[:idx])
} else {
authScheme = strings.ToLower(auth)
}
}
log.Debug().Str("auth_scheme", authScheme).Str("url", r.URL.Path).Msg("Checking Authorization header")
if auth != "" {
const prefix = "Basic "
if strings.HasPrefix(auth, prefix) {
decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
if err == nil {
parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) == 2 {
clientIP := GetClientIP(r)
// Only apply rate limiting for actual login attempts, not regular auth checks
// Login attempts come to /api/login endpoint
if r.URL.Path == "/api/login" {
// Check rate limiting for auth attempts
if !authLimiter.Allow(clientIP) {
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for auth")
LogAuditEventForTenant(GetOrgID(r.Context()), "login", parts[0], clientIP, r.URL.Path, false, "Rate limited")
if w != nil {
http.Error(w, "Too many authentication attempts", http.StatusTooManyRequests)
}
return false
}
}
// Check if account is locked out
_, userLockedUntil, userLocked := GetLockoutInfo(parts[0])
_, ipLockedUntil, ipLocked := GetLockoutInfo(clientIP)
if userLocked || ipLocked {
lockedUntil := userLockedUntil
if ipLocked && ipLockedUntil.After(lockedUntil) {
lockedUntil = ipLockedUntil
}
remainingMinutes := int(time.Until(lockedUntil).Minutes())
if remainingMinutes < 1 {
remainingMinutes = 1
}
log.Warn().Str("user", parts[0]).Str("ip", clientIP).Msg("Account locked out")
LogAuditEventForTenant(GetOrgID(r.Context()), "login", parts[0], clientIP, r.URL.Path, false, "Account locked")
if w != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(fmt.Sprintf(`{"error":"Account temporarily locked","message":"Too many failed attempts. Please try again in %d minutes.","lockedUntil":"%s"}`,
remainingMinutes, lockedUntil.Format(time.RFC3339))))
}
return false
}
cfgAuthUser, cfgAuthPass := snapshotLocalAuthCredentialsLocked(cfg)
userMatch := constantTimeStringEqual(parts[0], cfgAuthUser)
passMatch := internalauth.CheckPasswordHash(parts[1], cfgAuthPass)
log.Debug().
Str("provided_user", parts[0]).
Str("expected_user", cfgAuthUser).
Bool("user_match", userMatch).
Bool("pass_match", passMatch).
Msg("Auth check")
if userMatch && passMatch {
// Clear failed login attempts
ClearFailedLogins(parts[0])
ClearFailedLogins(GetClientIP(r))
// Authentication response headers are presentation metadata, not
// an identity channel. Persist the independently verified Basic
// auth subject on the request itself so downstream RBAC and action
// governance checks authorize the same principal that checkAuth
// accepted. Mutating the request in place is required because
// RequireAuth passes the original request pointer to its handler.
*r = *attachUserContext(r, parts[0])
// Valid credentials - create session
if w != nil {
// Invalidate any pre-existing session to prevent session fixation attacks.
InvalidateOldSessionFromRequest(r)
token := generateSessionToken()
if token == "" {
return false
}
// Store session persistently (including username for restart survival)
userAgent := r.Header.Get("User-Agent")
clientIP := GetClientIP(r)
GetSessionStore().CreateSession(token, 24*time.Hour, userAgent, clientIP, parts[0])
// Track session for user (in-memory for fast lookups)
TrackUserSession(parts[0], token)
// Generate CSRF token
csrfToken := generateCSRFToken(token)
// Get appropriate cookie settings based on proxy detection
cookiePolicy := getBrowserCookiePolicy(r)
// Debug logging for Cloudflare tunnel issues
sameSiteName := "Default"
switch cookiePolicy.sameSite {
case http.SameSiteNoneMode:
sameSiteName = "None"
case http.SameSiteLaxMode:
sameSiteName = "Lax"
case http.SameSiteStrictMode:
sameSiteName = "Strict"
}
log.Debug().
Bool("secure", cookiePolicy.secure).
Str("same_site", sameSiteName).
Str("token", safePrefixForLog(token, 8)+"...").
Str("remote_addr", r.RemoteAddr).
Msg("Setting session cookie after successful login")
// Set session cookie
cookiePolicy.setHTTPOnly(w, &http.Cookie{
Name: sessionCookieName(cookiePolicy.secure),
Value: token,
Path: "/",
HttpOnly: true,
MaxAge: 86400, // 24 hours
})
// Set CSRF cookie (not HttpOnly so JS can read it)
cookiePolicy.setClientReadable(w, &http.Cookie{
Name: CookieNameCSRF,
Value: csrfToken,
Path: "/",
MaxAge: 86400, // 24 hours
})
// Audit log successful login
LogAuditEventForTenant(GetOrgID(r.Context()), "login", parts[0], GetClientIP(r), r.URL.Path, true, "Basic auth login")
}
w.Header().Set("X-Authenticated-User", parts[0])
w.Header().Set("X-Auth-Method", "basic")
return true
} else {
// Failed login
RecordFailedLogin(parts[0])
RecordFailedLogin(clientIP)
LogAuditEventForTenant(GetOrgID(r.Context()), "login", parts[0], clientIP, r.URL.Path, false, "Invalid credentials")
// Get updated attempt counts
newUserAttempts, _, _ := GetLockoutInfo(parts[0])
newIPAttempts, _, _ := GetLockoutInfo(clientIP)
// Use the higher count for warning
attempts := newUserAttempts
if newIPAttempts > attempts {
attempts = newIPAttempts
}
if r.URL.Path == "/api/login" && w != nil {
// For login endpoint, provide detailed error response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
remaining := maxFailedAttempts - attempts
if remaining > 0 {
w.Write([]byte(fmt.Sprintf(`{"error":"Invalid credentials","attempts":%d,"remaining":%d,"maxAttempts":%d}`,
attempts, remaining, maxFailedAttempts)))
} else {
w.Write([]byte(`{"error":"Invalid credentials","locked":true,"message":"Account locked for 15 minutes"}`))
}
return false
}
}
}
}
}
}
}
if writeDefaultFailure {
writeAuthenticationRequired(w, r)
}
return false
}
// RequireAuth middleware checks for authentication
func RequireAuth(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Dev mode bypass for all auth (disabled by default)
if adminBypassEnabled() {
log.Debug().
Str("path", r.URL.Path).
Msg("Auth bypass enabled for dev mode")
handler(w, r)
return
}
authWriter := &responseCapture{ResponseWriter: w}
if checkAuth(cfg, authWriter, r, false) {
handler(w, r)
return
}
// An unauthenticated caller on a protected route is the expected outcome
// before login, not a fault. See logAuthDenial for the escalation rule.
logAuthDenial(r, "", "Unauthorized access attempt", nil)
if !authWriter.wrote {
// Never send WWW-Authenticate header - we want to use our custom login page.
// The frontend will detect 401 responses and show the login component.
writeAuthenticationRequired(w, r)
}
}
}
// RequireAdmin middleware checks for authentication and admin privileges.
// Proxy-auth users must have the configured admin role. Session/OIDC users
// must match the configured admin identity.
func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Dev mode bypass for admin endpoints (disabled by default)
if adminBypassEnabled() {
log.Debug().
Str("path", r.URL.Path).
Msg("Admin bypass enabled for dev mode")
handler(w, r)
return
}
// First check if user is authenticated
authWriter := &responseCapture{ResponseWriter: w}
if !checkAuth(cfg, authWriter, r, false) {
logAuthDenial(r, "", "Unauthorized access attempt", nil)
if !authWriter.wrote {
writeAuthenticationRequired(w, r)
}
return
}
// Check if using proxy auth and if so, verify admin status
if cfg.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(cfg, r); valid {
if !isAdmin {
// Authenticated, just not an admin. Every settings surface a
// non-admin UI still mounts lands here, so this must not warn
// per request; the rate escalation carries the security signal.
logAuthDenial(r, username, "Non-admin user attempted to access admin endpoint", nil)
// Return forbidden error
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"Admin privileges required"}`))
} else {
http.Error(w, "Admin privileges required", http.StatusForbidden)
}
return
}
}
}
// Enforce configured admin identity for session-based auth.
if !ensureAdminSession(cfg, w, r) {
return
}
// User is authenticated and has admin privileges.
handler(w, r)
}
}
// RequirePermission middleware checks for authentication and specific RBAC permissions
func RequirePermission(cfg *config.Config, authorizer auth.Authorizer, action, resource string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// First check if user is authenticated (using RequireAdmin logic as base)
authWriter := &responseCapture{ResponseWriter: w}
if !checkAuth(cfg, authWriter, r, false) {
if !authWriter.wrote {
if wantsJSONAuthResponse(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"authentication_required","message":"Authentication required"}`))
} else {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
}
return
}
// Check if using proxy auth and if so, verify admin status.
// When a real RBAC authorizer is active (non-DefaultAuthorizer), non-admin
// proxy users are allowed through to the RBAC check below, which may grant
// access based on their role assignments. Without RBAC, non-admin proxy
// users are hard-rejected since there's no other authorization mechanism.
if cfg.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(cfg, r); valid {
if !isAdmin {
// Check if a real RBAC authorizer is active
_, isDefaultAuth := authorizer.(*internalauth.DefaultAuthorizer)
if isDefaultAuth {
// No RBAC: non-admin proxy users are rejected
logAuthDenial(r, username,
"Non-admin proxy user attempted to access permissioned endpoint (no RBAC active)",
map[string]string{"action": action, "resource": resource})
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"Admin privileges required"}`))
} else {
http.Error(w, "Admin privileges required", http.StatusForbidden)
}
return
}
// RBAC active: defer to authorizer check below
log.Debug().
Str("username", username).
Str("action", action).
Str("resource", resource).
Msg("Non-admin proxy user deferred to RBAC authorizer")
}
}
}
// Take the authenticated identity from the REQUEST context, not the
// response header. checkAuth above (and every other auth path
// upstream) attaches the username via attachUserContext, which
// stores it on the request context. Reading from w.Header() — a
// mutable response header that any handler between checkAuth and
// RequirePermission can overwrite — would let a downstream
// middleware substitute an arbitrary username and have the RBAC
// authorizer make its decision against that value. Response
// headers are not an identity channel.
ctx := r.Context()
username := internalauth.GetUser(ctx)
if username == "" {
// Defensive fallback: if some upstream auth path set the
// header but skipped attachUserContext, surface that here so
// the authorizer still gets the right identity, but log it so
// the gap can be plugged at the source.
if headerUser := w.Header().Get("X-Authenticated-User"); headerUser != "" {
log.Warn().
Str("user", headerUser).
Str("path", r.URL.Path).
Msg("Authenticated user found only in response header; upstream auth path should attachUserContext")
username = headerUser
ctx = internalauth.WithUser(ctx, username)
}
}
// Check permission via authorizer
allowed, err := authorizer.Authorize(ctx, action, resource)
if err != nil {
log.Error().Err(err).Str("user", username).Str("action", action).Str("resource", resource).Msg("RBAC authorization failed due to system error")
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error":"internal_error","message":"Failed to verify permissions"}`))
} else {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
if !allowed {
logAuthDenial(r, username, "Forbidden access attempt (RBAC)",
map[string]string{"action": action, "resource": resource})
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "forbidden",
"message": "You do not have permission to perform this action",
"action": action,
"resource": resource,
})
} else {
http.Error(w, "Forbidden", http.StatusForbidden)
}
return
}
next(w, r.WithContext(ctx))
}
}
// RequireScope ensures that token-authenticated requests include the specified scope.
// Session-based (browser) requests bypass the scope check.
func RequireScope(scope string, handler http.HandlerFunc) http.HandlerFunc {
return RequireAnyScope([]string{scope}, handler)
}
// RequireAnyScope ensures that token-authenticated requests include at least
// one of the specified scopes. Session-based (browser) requests bypass the
// scope check.
func RequireAnyScope(scopes []string, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !ensureAnyScope(w, r, scopes...) {
return
}
handler(w, r)
}
}
func respondMissingScope(w http.ResponseWriter, scope string) {
if w == nil {
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "missing_scope",
"requiredScope": scope,
})
}
// ensureScope enforces that the request either originates from a session or a token
// possessing the specified scope. Returns true when access should continue.
func ensureScope(w http.ResponseWriter, r *http.Request, scope string) bool {
return ensureAnyScope(w, r, scope)
}
// ensureAnyScope enforces that the request either originates from a session or
// a token possessing at least one of the specified scopes.
func ensureAnyScope(w http.ResponseWriter, r *http.Request, scopes ...string) bool {
normalized := make([]string, 0, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "" {
return true
}
normalized = append(normalized, scope)
}
if len(normalized) == 0 {
return true
}
record := getAPITokenRecordFromRequest(r)
if record == nil {
return true
}
for _, scope := range normalized {
if record.HasScope(scope) {
return true
}
}
if len(normalized) == 1 {
respondMissingScope(w, normalized[0])
return false
}
if w != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "missing_scope",
"requiredScopes": normalized,
})
}
return false
}
func attachAPITokenRecord(r *http.Request, record *config.APITokenRecord) {
if record == nil {
return
}
clone := record.Clone()
ctx := internalauth.WithAPIToken(r.Context(), &clone)
*r = *r.WithContext(ctx)
}
// attachUserContext stores the authenticated username in the request context.
func attachUserContext(r *http.Request, username string) *http.Request {
if username == "" {
return r
}
ctx := internalauth.WithUser(r.Context(), username)
return r.WithContext(ctx)
}
func attachAdminBypassContext(r *http.Request) *http.Request {
ctx := context.WithValue(r.Context(), adminBypassContextKey, true)
return r.WithContext(ctx)
}
func isAdminBypassRequest(ctx context.Context) bool {
bypass, ok := ctx.Value(adminBypassContextKey).(bool)
return ok && bypass
}
// AuthContextMiddleware creates a middleware that extracts auth info and stores it in context.
// This should run early in the middleware chain so subsequent middleware can access auth context.
// Note: This middleware does NOT enforce authentication - it only populates context.
// Use RequireAuth for enforcement.
// AuthContextMiddleware creates a middleware that extracts auth info and stores it in context.
// This should run early in the middleware chain so subsequent middleware can access auth context.
// Note: This middleware does NOT enforce authentication - it only populates context.
// Use RequireAuth for enforcement.
func AuthContextMiddleware(cfg *config.Config, mtm *monitoring.MultiTenantMonitor, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Try to extract auth info and store in context WITHOUT enforcing auth
// This allows tenant middleware to check authorization later
r = extractAndStoreAuthContext(cfg, mtm, r)
next.ServeHTTP(w, r)
})
}
// extractAndStoreAuthContext extracts user/token info from the request and stores in context.
// Returns the request with updated context. Does not enforce auth.
func extractAndStoreAuthContext(cfg *config.Config, mtm *monitoring.MultiTenantMonitor, r *http.Request) *http.Request {
// Use RLock for common case, upgrade to Lock only if we need to update token stats
config.Mu.RLock()
defer config.Mu.RUnlock()
// Dev mode bypass
if adminBypassEnabled() {
return attachAdminBypassContext(attachUserContext(r, "admin"))
}
// Check proxy auth
if cfg.ProxyAuthSecret != "" {
if valid, username, _ := CheckProxyAuth(cfg, r); valid && username != "" {
return attachUserContext(r, username)
}
}
// Check API tokens
// Check API tokens
// We need to check if EITHER the global config has tokens OR if we have a tenant monitor (which might have tokens)
if cfg.HasAPITokens() || mtm != nil {
// Determine which config to use for validation (Global vs Tenant)
targetConfig := cfg
if mtm != nil {
// Check for Tenant ID in header or cookie
orgID := "default"
if headerOrgID := r.Header.Get("X-Pulse-Org-ID"); headerOrgID != "" {
orgID = headerOrgID
} else if cookie, err := r.Cookie(CookieNameOrgID); err == nil && cookie.Value != "" {
orgID = cookie.Value
}
// If targeting a specific tenant, try to load that tenant's config
if orgID != "default" {
// Prevent DoS: Check if org exists before loading (which triggers directory creation)
if mtm.OrgExists(orgID) {
if m, err := mtm.GetMonitor(orgID); err == nil && m != nil {
targetConfig = m.GetConfig()
}
}
}
}
validateToken := func(token string) (*http.Request, bool) {
if record, ok := validateAPITokenAgainstConfigsLocked(cfg, targetConfig, token); ok {
attachAPITokenRecord(r, record)
return attachUserContext(r, apiTokenAuthenticatedUser(record)), true
}
return nil, false
}
if providedToken, provided := explicitAPITokenFromRequest(r); provided {
if req, ok := validateToken(providedToken); ok {
return req
}
return r
}
}
// Check session cookie
if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" {
if ValidateSession(cookie.Value) {
if username := GetSessionUsername(cookie.Value); username != "" {
return attachUserContext(r, username)
}
}
}
return r
}
func getAPITokenRecordFromRequest(r *http.Request) *config.APITokenRecord {
val := internalauth.GetAPIToken(r.Context())
if val == nil {
return nil
}
record, ok := val.(*config.APITokenRecord)
if !ok {
return nil
}
clone := record.Clone()
return &clone
}
func adminBypassEnabled() bool {
adminBypassState.once.Do(func() {
adminBypassState.enabled, adminBypassState.declined = resolveAdminBypassEnv()
if adminBypassState.enabled {
log.Warn().Msg("Admin authentication bypass ENABLED (development mode)")
return
}
if adminBypassState.declined {
log.Warn().Msg("Ignoring ALLOW_ADMIN_BYPASS outside development mode")
}
})
return adminBypassState.enabled
}
func oidcRefreshLead(session *SessionData) time.Duration {
if session == nil || session.OIDCAccessTokenExp.IsZero() {
return 0
}
issuedAt := session.OIDCAccessTokenIssuedAt
if issuedAt.IsZero() || !session.OIDCAccessTokenExp.After(issuedAt) {
return oidcFallbackRefreshLead
}
lifetime := session.OIDCAccessTokenExp.Sub(issuedAt)
lead := lifetime / oidcRefreshLifetimePart
if lead > oidcMaxRefreshLead {
return oidcMaxRefreshLead
}
return lead
}
func shouldRefreshOIDCSessionToken(now time.Time, session *SessionData) bool {
if session == nil || session.OIDCRefreshToken == "" || session.OIDCTokenRefreshing {
return false
}
if session.OIDCAccessTokenExp.IsZero() {
return false
}
if !now.Before(session.OIDCAccessTokenExp) {
return true
}
lead := oidcRefreshLead(session)
if lead <= 0 {
return false
}
return !now.Add(lead).Before(session.OIDCAccessTokenExp)
}
// 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()
// Resolve OIDC provider config from v6 enabled SSO providers.
oidcCfg, providerID := resolveOIDCRefreshConfig(cfg, session)
if oidcCfg == nil {
// Session may belong to a disabled/removed provider. Skip refresh silently;
// the session continues until natural expiry.
log.Debug().Msg("No matching enabled SSO OIDC provider for session refresh")
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).
Str("provider_id", providerID).
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).
Str("provider_id", providerID).
Msg("OIDC token refresh successful - session extended")
LogAuditEvent("oidc_token_refresh", "", "", "", true, "Token refreshed successfully")
}