@spidy092/auth-client 3.1.3 → 3.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../api.js","../token.js","../config.js","../diagnostics.js","../core.js"],"sourcesContent":["// auth-client/api.js\nimport axios from 'axios';\nimport { getConfig } from './config.js';\nimport { getToken, setToken, clearToken } from './token.js';\nimport { refreshToken as performRefresh } from './core.js';\nimport { diagnosticHeaders, emitAuthDiagnostic } from './diagnostics.js';\n\nconst api = axios.create({\n withCredentials: true,\n});\n\napi.interceptors.request.use((config) => {\n const runtimeConfig = getConfig();\n\n if (!config.baseURL) {\n\n config.baseURL = runtimeConfig?.authBaseUrl || 'http://auth.local.test:4000/auth';\n }\n\n if (!config.headers) {\n config.headers = {};\n }\n\n if (runtimeConfig?.clientKey && !config.headers['X-Client-Key']) {\n config.headers['X-Client-Key'] = runtimeConfig.clientKey;\n }\n Object.assign(config.headers, diagnosticHeaders());\n\n const token = getToken();\n if (token) {\n config.headers.Authorization = `Bearer ${token}`;\n }\n\n return config;\n});\n\nlet refreshPromise = null;\n\napi.interceptors.response.use(\n (response) => response,\n async (error) => {\n const { response, config } = error || {};\n\n if (!response || !config) {\n return Promise.reject(error);\n }\n\n if (response.status !== 401 || config._retry) {\n return Promise.reject(error);\n }\n\n config._retry = true;\n emitAuthDiagnostic('API_401_REFRESH_STARTED', 'PENDING', 'HTTP_401', {\n clientKey: getConfig().clientKey,\n status: 401,\n });\n\n if (!refreshPromise) {\n refreshPromise = performRefresh()\n .then((newToken) => {\n refreshPromise = null;\n if (newToken) {\n setToken(newToken);\n }\n return newToken;\n })\n .catch((refreshError) => {\n refreshPromise = null;\n // ❌ REMOVED: clearToken() here caused cascading logouts.\n // The calling code (AuthContext) handles token clearing\n // based on the specific error context (401 vs network error).\n throw refreshError;\n });\n }\n\n try {\n const refreshedToken = await refreshPromise;\n\n if (refreshedToken) {\n config.headers.Authorization = `Bearer ${refreshedToken}`;\n return api(config);\n }\n } catch (refreshErr) {\n // Refresh failed — propagate the ORIGINAL 401 error so the caller\n // knows the request was unauthorized (not a refresh-specific error).\n return Promise.reject(error);\n }\n\n return Promise.reject(error);\n }\n);\n\napi.validateSession = async () => {\n try {\n const response = await api.get('/account/validate-session');\n return response.data.valid;\n } catch (err) {\n if (err.response?.status === 401) {\n return false;\n }\n throw err;\n }\n};\n\nexport default api;\n","// auth-client/token.js - MINIMAL WORKING VERSION\n\nimport { jwtDecode } from 'jwt-decode';\n\nlet accessToken = null;\nconst listeners = new Set();\n\nconst REFRESH_COOKIE = 'account_refresh_token';\nconst COOKIE_MAX_AGE = 7 * 24 * 60 * 60;\n\nfunction secureAttribute() {\n try {\n return typeof window !== 'undefined' && window.location?.protocol === 'https:'\n ? '; Secure'\n : '';\n } catch (err) {\n return '';\n }\n}\n\n// ========== ACCESS TOKEN ==========\nfunction writeAccessToken(token) {\n if (!token) {\n try {\n localStorage.removeItem('authToken');\n } catch (err) {\n console.warn('Could not clear token from localStorage:', err);\n }\n return;\n }\n\n try {\n localStorage.setItem('authToken', token);\n } catch (err) {\n console.warn('Could not persist token to localStorage:', err);\n }\n}\n\nfunction readAccessToken() {\n try {\n return localStorage.getItem('authToken');\n } catch (err) {\n console.warn('Could not read token from localStorage:', err);\n return null;\n }\n}\n\n// ========== REFRESH TOKEN (KEEP SIMPLE) ==========\n// export function setRefreshToken(token) {\n// if (!token) {\n// clearRefreshToken();\n// return;\n// }\n\n// const expires = new Date(Date.now() + COOKIE_MAX_AGE * 1000);\n\n// try {\n// document.cookie = `${REFRESH_COOKIE}=${encodeURIComponent(token)}; Path=/; SameSite=Lax${secureAttribute()}; Expires=${expires.toUTCString()}`;\n// } catch (err) {\n// console.warn('Could not set refresh token:', err);\n// }\n// }\n\n// export function getRefreshToken() {\n// try {\n// const match = document.cookie\n// ?.split('; ')\n// ?.find((row) => row.startsWith(`${REFRESH_COOKIE}=`));\n\n// if (match) {\n// return decodeURIComponent(match.split('=')[1]);\n// }\n// } catch (err) {\n// console.warn('Could not read refresh token:', err);\n// }\n\n// return null;\n// }\n\n// export function clearRefreshToken() {\n// try {\n// document.cookie = `${REFRESH_COOKIE}=; Path=/; SameSite=Lax${secureAttribute()}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;\n// } catch (err) {\n// console.warn('Could not clear refresh token:', err);\n// }\n// }\n\n// ========== ACCESS TOKEN FUNCTIONS ==========\nfunction decode(token) {\n try {\n return jwtDecode(token);\n } catch (err) {\n return null;\n }\n}\n\nfunction isExpired(token, bufferSeconds = 60) {\n if (!token) return true;\n const decoded = decode(token);\n if (!decoded?.exp) return true;\n const now = Date.now() / 1000;\n return decoded.exp < now + bufferSeconds;\n}\n\n// ========== TOKEN EXPIRY UTILITIES ==========\n// Get the exact expiry time of a token as a Date object\nexport function getTokenExpiryTime(token) {\n if (!token) return null;\n const decoded = decode(token);\n if (!decoded?.exp) return null;\n return new Date(decoded.exp * 1000);\n}\n\n// Get seconds until token expires (negative if already expired)\nexport function getTimeUntilExpiry(token) {\n if (!token) return -1;\n const decoded = decode(token);\n if (!decoded?.exp) return -1;\n const now = Date.now() / 1000;\n return Math.floor(decoded.exp - now);\n}\n\n// Check if token will expire within the next N seconds\nexport function willExpireSoon(token, withinSeconds = 60) {\n const timeLeft = getTimeUntilExpiry(token);\n return timeLeft >= 0 && timeLeft <= withinSeconds;\n}\n\nexport function setToken(token) {\n const previousToken = accessToken;\n accessToken = token || null;\n writeAccessToken(accessToken);\n\n if (previousToken !== accessToken) {\n listeners.forEach((listener) => {\n try {\n listener(accessToken, previousToken);\n } catch (err) {\n console.warn('Token listener error:', err);\n }\n });\n }\n}\n\nexport function getToken() {\n if (accessToken) return accessToken;\n accessToken = readAccessToken();\n return accessToken;\n}\n\nexport function clearToken() {\n if (!accessToken) {\n writeAccessToken(null);\n clearRefreshToken();\n return;\n }\n\n const previousToken = accessToken;\n accessToken = null;\n writeAccessToken(null);\n clearRefreshToken();\n\n listeners.forEach((listener) => {\n try {\n listener(null, previousToken);\n } catch (err) {\n console.warn('Token listener error:', err);\n }\n });\n}\n\n// ========== REFRESH TOKEN STORAGE ==========\n// By default:\n// HTTP → localStorage (cookies don't work cross-origin in dev)\n// HTTPS → httpOnly cookies (secure, managed by server)\n// When persistRefreshToken is enabled:\n// Always use localStorage (for local HTTPS with mkcert/self-signed certs)\nconst REFRESH_TOKEN_KEY = 'auth_refresh_token';\n\n// ✅ Persistence flag - controlled by config.persistRefreshToken\nlet _persistRefreshToken = false;\n\nexport function enableRefreshTokenPersistence(enabled) {\n _persistRefreshToken = !!enabled;\n console.log(`🔧 Refresh token persistence: ${_persistRefreshToken ? 'ENABLED' : 'DISABLED'}`);\n}\n\nfunction shouldUseLocalStorage() {\n // If persistence is forced, always use localStorage\n if (_persistRefreshToken) return true;\n // Otherwise, only use localStorage on HTTP (dev mode)\n try {\n return typeof window !== 'undefined' &&\n window.location?.protocol === 'http:';\n } catch (err) {\n return false;\n }\n}\n\nexport function setRefreshToken(token) {\n if (!token) {\n clearRefreshToken();\n return;\n }\n\n if (shouldUseLocalStorage()) {\n try {\n localStorage.setItem(REFRESH_TOKEN_KEY, token);\n console.log(`📦 Refresh token stored in localStorage (${_persistRefreshToken ? 'persistence enabled' : 'HTTP dev mode'})`);\n } catch (err) {\n console.warn('Could not store refresh token:', err);\n }\n } else {\n // HTTPS without persistence: refresh token is in httpOnly cookie only\n console.log('🔒 Refresh token managed by server httpOnly cookie (production mode)');\n }\n}\n\nexport function getRefreshToken() {\n if (shouldUseLocalStorage()) {\n try {\n const token = localStorage.getItem(REFRESH_TOKEN_KEY);\n return token;\n } catch (err) {\n console.warn('Could not read refresh token:', err);\n return null;\n }\n }\n\n // HTTPS without persistence: refresh token is in httpOnly cookie (not accessible via JS)\n // The refresh endpoint uses credentials: 'include' to send the cookie\n return null;\n}\n\nexport function clearRefreshToken() {\n // Clear localStorage (for HTTP dev)\n try {\n localStorage.removeItem(REFRESH_TOKEN_KEY);\n } catch (err) {\n // Ignore\n }\n\n // Clear cookie (for production)\n try {\n document.cookie = `${REFRESH_COOKIE}=; Path=/; SameSite=Strict${secureAttribute()}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;\n } catch (err) {\n console.warn('Could not clear refresh token cookie:', err);\n }\n\n // Clear sessionStorage\n try {\n sessionStorage.removeItem(REFRESH_COOKIE);\n } catch (err) {\n // Ignore\n }\n}\n\nexport function addTokenListener(listener) {\n if (typeof listener !== 'function') {\n throw new Error('Token listener must be a function');\n }\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\nexport function removeTokenListener(listener) {\n listeners.delete(listener);\n}\n\nexport function getListenerCount() {\n return listeners.size;\n}\n\n// localStorage changes do not fire in the tab that made the change, but they\n// do fire in sibling tabs on the same origin. Keep the in-memory token cache\n// and React consumers synchronized when another tab logs in or out.\nif (typeof window !== 'undefined' && window.addEventListener) {\n window.addEventListener('storage', (event) => {\n if (event.key !== 'authToken') return;\n\n const previousToken = accessToken;\n accessToken = event.newValue || null;\n if (previousToken === accessToken) return;\n\n listeners.forEach((listener) => {\n try {\n listener(accessToken, previousToken);\n } catch (err) {\n console.warn('Token listener error:', err);\n }\n });\n });\n}\n\nexport function isAuthenticated() {\n const token = getToken();\n return !!token && !isExpired(token, 10);\n}\n\n\n","// auth-client/config.js\nimport { enableRefreshTokenPersistence } from './token.js';\n\n// ========== SESSION SECURITY CONFIGURATION ==========\n// These settings control how the auth-client handles token refresh and session validation\n// to ensure deleted sessions in Keycloak are detected quickly.\n\nlet config = {\n clientKey: null,\n authBaseUrl: null,\n redirectUri: null,\n accountUiUrl: null,\n isRouter: false, // ✅ Add router flag\n\n // ========== SESSION SECURITY SETTINGS ==========\n // Buffer time (in seconds) before token expiry to trigger proactive refresh\n // With 5-minute access tokens, refreshing 60s before expiry ensures seamless UX\n tokenRefreshBuffer: 60,\n\n // Interval (in milliseconds) for periodic session validation\n // Validates that the session still exists in Keycloak (not deleted by admin)\n // Default: 15 minutes (900000ms) - Increased from 2m to avoid frequent checks\n sessionValidationInterval: 15 * 60 * 1000,\n\n // Enable/disable periodic session validation\n // When enabled, the client will ping the server to verify session is still active\n enableSessionValidation: true,\n\n // Enable/disable proactive token refresh\n // When enabled, tokens are refreshed before they expire (using tokenRefreshBuffer)\n enableProactiveRefresh: true,\n\n // Validate session when browser tab becomes visible again\n // Catches session deletions that happened while the tab was inactive\n validateOnVisibility: true,\n\n // ========== REFRESH TOKEN PERSISTENCE ==========\n // When true, stores refresh token in localStorage even on HTTPS\n // Required for local dev with mkcert/self-signed certs where httpOnly cookies\n // may not work reliably across origins\n // ⚠️ In true production, set to false and rely on httpOnly cookies\n persistRefreshToken: false,\n};\n\nconst RUNTIME_POLICY_DEFAULTS = {\n tokenRefreshBuffer: 60,\n sessionValidationInterval: 15 * 60 * 1000,\n enableSessionValidation: true,\n enableProactiveRefresh: true,\n validateOnVisibility: true,\n};\n\nfunction validateRuntimePolicy(policy = {}) {\n const numberInRange = (value, min, max) => Number.isInteger(value) && value >= min && value <= max;\n const result = { ...RUNTIME_POLICY_DEFAULTS };\n if (numberInRange(policy.tokenRefreshBuffer, 10, 300)) result.tokenRefreshBuffer = policy.tokenRefreshBuffer;\n if (numberInRange(policy.sessionValidationInterval, 60000, 86400000)) result.sessionValidationInterval = policy.sessionValidationInterval;\n for (const key of ['enableSessionValidation', 'enableProactiveRefresh', 'validateOnVisibility']) {\n if (typeof policy[key] === 'boolean') result[key] = policy[key];\n }\n return result;\n}\n\nexport function applyRuntimePolicy(policy = {}) {\n config = { ...config, ...validateRuntimePolicy(policy) };\n return getConfig();\n}\n\nexport async function loadRuntimePolicy() {\n const { authBaseUrl, clientKey } = config;\n if (!authBaseUrl || !clientKey || typeof fetch !== 'function') return getConfig();\n try {\n const response = await fetch(`${authBaseUrl.replace(/\\/+$/, '')}/clients/${encodeURIComponent(clientKey)}/config`, {\n method: 'GET',\n credentials: 'include',\n headers: { Accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`runtime policy HTTP ${response.status}`);\n const payload = await response.json();\n return applyRuntimePolicy(payload.authentication_policy || {});\n } catch (error) {\n // Runtime config is an enhancement. Safe bounded defaults keep login\n // available during a control-plane outage.\n console.warn('[auth-client] Runtime policy unavailable; using safe defaults', error.message);\n return applyRuntimePolicy({});\n }\n}\n\nexport function setConfig(customConfig = {}) {\n if (!customConfig.clientKey || !customConfig.authBaseUrl) {\n throw new Error('Missing required config: clientKey and authBaseUrl are required');\n }\n\n config = {\n ...config,\n ...customConfig,\n redirectUri: customConfig.redirectUri || window.location.origin + '/callback',\n // ✅ Auto-detect router mode\n isRouter: customConfig.isRouter || customConfig.clientKey === 'account-ui'\n };\n\n // Keep token storage synchronized when consumers reconfigure the singleton.\n enableRefreshTokenPersistence(config.persistRefreshToken);\n if (config.persistRefreshToken) {\n console.log('📦 Refresh token persistence ENABLED (localStorage on HTTPS)');\n }\n\n console.log(`🔧 Auth Client Mode: ${config.isRouter ? 'ROUTER' : 'CLIENT'}`, {\n clientKey: config.clientKey,\n isRouter: config.isRouter,\n persistRefreshToken: config.persistRefreshToken\n });\n}\n\nexport function getConfig() {\n return { ...config };\n}\n\n// ✅ Helper function\nexport function isRouterMode() {\n return config.isRouter;\n}\n","const STORAGE_KEY = 'auth_diagnostic_context';\nconst LOGIN_LOCK_KEY = 'auth_login_lock';\n\nconst randomId = () => {\n if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();\n return `auth-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n};\n\nconst fingerprint = async (value) => {\n if (!value || !globalThis.crypto?.subtle) return null;\n const bytes = new TextEncoder().encode(String(value));\n const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);\n return [...new Uint8Array(digest)].slice(0, 6)\n .map((byte) => byte.toString(16).padStart(2, '0')).join('');\n};\n\nexport function getDiagnosticContext() {\n try {\n const existing = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null');\n if (existing?.correlationId) return existing;\n } catch {}\n const context = { correlationId: randomId(), startedAt: Date.now() };\n try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(context)); } catch {}\n return context;\n}\n\nexport function resetDiagnosticContext() {\n const context = { correlationId: randomId(), startedAt: Date.now() };\n try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(context)); } catch {}\n return context;\n}\n\nexport function diagnosticHeaders() {\n const { correlationId } = getDiagnosticContext();\n return { 'X-Correlation-ID': correlationId, 'X-Request-ID': correlationId };\n}\n\nexport function acquireLoginLock(clientKey, redirectUri, ttlMs = 5000) {\n const now = Date.now();\n const signature = `${clientKey}|${redirectUri}`;\n try {\n const current = JSON.parse(sessionStorage.getItem(LOGIN_LOCK_KEY) || 'null');\n if (current?.signature === signature && now - current.createdAt < ttlMs) return false;\n sessionStorage.setItem(LOGIN_LOCK_KEY, JSON.stringify({ signature, createdAt: now }));\n } catch {}\n return true;\n}\n\nexport function clearLoginLock() {\n try { sessionStorage.removeItem(LOGIN_LOCK_KEY); } catch {}\n}\n\nexport async function emitAuthDiagnostic(event, outcome, reasonCode, details = {}) {\n const context = getDiagnosticContext();\n const safe = {\n eventType: 'auth_client_diagnostic',\n event,\n outcome,\n reasonCode,\n correlationId: context.correlationId,\n elapsedMs: Date.now() - context.startedAt,\n clientKey: details.clientKey || null,\n status: details.status || null,\n stateFingerprint: await fingerprint(details.state),\n online: typeof navigator === 'undefined' ? null : navigator.onLine,\n };\n const method = outcome === 'FAILURE' ? 'error' : outcome === 'WARNING' ? 'warn' : 'info';\n console[method]('[auth-client]', safe);\n return safe;\n}\n","// auth-client/core.js - MINIMAL WORKING VERSION\n\nimport {\n setToken,\n clearToken,\n getToken,\n getRefreshToken,\n setRefreshToken,\n clearRefreshToken,\n getTimeUntilExpiry,\n} from './token.js';\nimport { getConfig, isRouterMode } from './config.js';\nimport {\n acquireLoginLock,\n clearLoginLock,\n diagnosticHeaders,\n emitAuthDiagnostic,\n getDiagnosticContext,\n resetDiagnosticContext,\n} from './diagnostics.js';\n\nlet callbackProcessed = false;\n\nexport function login(clientKeyArg, redirectUriArg, options = {}) {\n // ✅ Reset callback state when starting new login\n resetCallbackState();\n\n const {\n clientKey: defaultClientKey,\n authBaseUrl,\n redirectUri: defaultRedirectUri,\n accountUiUrl\n } = getConfig();\n\n const clientKey = clientKeyArg || defaultClientKey;\n const redirectUri = redirectUriArg || defaultRedirectUri;\n\n if (!clientKey || !redirectUri) {\n emitAuthDiagnostic('LOGIN_REJECTED', 'FAILURE', 'CLIENT_CONFIG_MISSING', { clientKey });\n throw new Error('Missing clientKey or redirectUri');\n }\n\n if (!acquireLoginLock(clientKey, redirectUri)) {\n emitAuthDiagnostic('LOGIN_DUPLICATE_SUPPRESSED', 'WARNING', 'LOGIN_ALREADY_IN_PROGRESS', { clientKey });\n return false;\n }\n resetDiagnosticContext();\n emitAuthDiagnostic('LOGIN_INITIATED', 'PENDING', 'NONE', { clientKey });\n\n sessionStorage.setItem('originalApp', clientKey);\n sessionStorage.setItem('returnUrl', redirectUri);\n\n if (isRouterMode()) {\n // Router mode: Direct backend authentication\n return routerLogin(clientKey, redirectUri, options);\n } else {\n // Client mode: Redirect to centralized login\n return clientLogin(clientKey, redirectUri, options);\n }\n}\n\n// ✅ Router mode: Direct backend call\nfunction routerLogin(clientKey, redirectUri, options = {}) {\n const { authBaseUrl } = getConfig();\n\n const params = new URLSearchParams();\n if (redirectUri) {\n params.append('redirect_uri', redirectUri);\n }\n if (options.switchAccount || options.switch_account) params.append('switch_account', 'true');\n params.append('correlation_id', getDiagnosticContext().correlationId);\n const query = params.toString();\n const backendLoginUrl = `${authBaseUrl}/login/${clientKey}${query ? `?${query}` : ''}`;\n\n window.location.href = backendLoginUrl;\n}\n\n// ✅ Client mode: Centralized login\nfunction clientLogin(clientKey, redirectUri, options = {}) {\n const { accountUiUrl } = getConfig();\n\n const params = new URLSearchParams({\n client: clientKey\n });\n if (redirectUri) {\n params.append('redirect_uri', redirectUri);\n }\n if (options.switchAccount || options.switch_account) params.append('switch_account', 'true');\n const centralizedLoginUrl = `${accountUiUrl}/login?${params.toString()}`;\n\n window.location.href = centralizedLoginUrl;\n}\n\nexport async function logout(options = {}) {\n resetCallbackState();\n\n const { clientKey, authBaseUrl, accountUiUrl } = getConfig();\n const scope = options.scope === 'client' ? 'client' : 'sso';\n const token = getToken();\n const refreshToken = getRefreshToken();\n\n console.log('🚪 Smart Logout initiated', {\n mode: isRouterMode() ? 'ROUTER' : 'CLIENT',\n clientKey,\n scope,\n });\n emitAuthDiagnostic('LOGOUT_INITIATED', 'PENDING', 'NONE', { clientKey });\n\n clearToken();\n clearRefreshToken();\n sessionStorage.removeItem('originalApp');\n sessionStorage.removeItem('returnUrl');\n\n // Every client — router or not — must hit the backend so it can:\n // 1. revoke the refresh token record, and\n // 2. hand back a Keycloak end-session URL so the IdP's SSO cookie is\n // actually killed. Without step 2 the browser stays signed in at\n // Keycloak and silently re-authenticates on the next login attempt.\n try {\n const response = await fetch(`${authBaseUrl}/logout/${clientKey}`, {\n method: 'POST',\n credentials: 'include',\n headers: {\n ...diagnosticHeaders(),\n 'Authorization': token ? `Bearer ${token}` : '',\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({ refreshToken, scope })\n });\n\n if (!response.ok) {\n throw new Error(`Logout failed: ${response.status}`);\n }\n\n const data = await response.json();\n console.log('✅ Logout response:', data);\n emitAuthDiagnostic('LOGOUT_COMPLETED', 'SUCCESS', 'NONE', {\n clientKey,\n status: response.status,\n });\n\n if (data?.keycloakLogoutUrl) {\n window.location.replace(data.keycloakLogoutUrl);\n return;\n }\n\n if (data?.logoutRedirectUrl) {\n window.location.replace(data.logoutRedirectUrl);\n return;\n }\n } catch (error) {\n console.warn('⚠️ Logout backend call failed:', error);\n emitAuthDiagnostic('LOGOUT_BACKEND_FAILED', 'FAILURE', 'LOGOUT_REQUEST_FAILED', {\n clientKey,\n });\n }\n\n // Fallback only if the backend call failed or returned no logout URL —\n // the Keycloak SSO session may still be alive in this case.\n const fallbackUrl = isRouterMode()\n ? new URL('/login', window.location.origin)\n : new URL('/login', accountUiUrl);\n fallbackUrl.searchParams.set('logged_out', 'true');\n fallbackUrl.searchParams.set('client', clientKey);\n fallbackUrl.searchParams.set('scope', scope);\n window.location.replace(fallbackUrl.toString());\n}\n\nexport function handleCallback() {\n const params = new URLSearchParams(window.location.search);\n const accessToken = params.get('access_token');\n const error = params.get('error');\n\n console.log('🔄 Callback handling:', {\n hasAccessToken: !!accessToken,\n error\n });\n\n // ✅ Prevent duplicate callback processing\n if (callbackProcessed) {\n const existingToken = getToken();\n if (existingToken) {\n console.log('✅ Callback already processed, returning existing token');\n return existingToken;\n }\n // Reset if no token found (might be a retry)\n callbackProcessed = false;\n }\n\n callbackProcessed = true;\n clearLoginLock();\n sessionStorage.removeItem('originalApp');\n sessionStorage.removeItem('returnUrl');\n\n if (error) {\n const errorDescription = params.get('error_description') || error;\n const authError = new Error(`Authentication failed: ${errorDescription}`);\n authError.code = error;\n authError.correlationId = getDiagnosticContext().correlationId;\n emitAuthDiagnostic('CALLBACK_REJECTED', 'FAILURE', error.toUpperCase(), {\n clientKey: getConfig().clientKey,\n state: params.get('state'),\n });\n throw authError;\n }\n\n if (accessToken) {\n setToken(accessToken);\n\n // Refresh tokens must never be accepted from a callback URL. The auth\n // service transports them through the httpOnly client cookie; accepting a\n // URL value would put a credential in browser history, referrers, and\n // diagnostics. Keep deleting the legacy parameter below for clean URLs.\n const refreshTokenInUrl = params.get('refresh_token');\n if (refreshTokenInUrl) {\n emitAuthDiagnostic('CALLBACK_REFRESH_TOKEN_IGNORED', 'WARNING', 'REFRESH_TOKEN_IN_URL', {\n clientKey: getConfig().clientKey,\n });\n }\n\n const url = new URL(window.location);\n url.searchParams.delete('access_token');\n url.searchParams.delete('refresh_token');\n url.searchParams.delete('state');\n url.searchParams.delete('error');\n url.searchParams.delete('error_description');\n window.history.replaceState({}, '', url);\n\n console.log('✅ Callback processed successfully, token stored');\n emitAuthDiagnostic('CALLBACK_COMPLETED', 'SUCCESS', 'NONE', {\n clientKey: getConfig().clientKey,\n state: params.get('state'),\n });\n return accessToken;\n }\n\n emitAuthDiagnostic('CALLBACK_REJECTED', 'FAILURE', 'ACCESS_TOKEN_MISSING', {\n clientKey: getConfig().clientKey,\n state: params.get('state'),\n });\n const missingTokenError = new Error('No access token found in callback URL');\n missingTokenError.code = 'ACCESS_TOKEN_MISSING';\n throw missingTokenError;\n}\n\nexport function resetCallbackState() {\n callbackProcessed = false;\n}\n\n// ✅ Add refresh lock to prevent concurrent refresh calls\nlet refreshInProgress = false;\nlet refreshPromise = null;\n\n// Coordinate refreshes across tabs of the same application. The in-memory\n// promise above protects one tab; navigator.locks protects multiple tabs on\n// the same origin. Auth-service still remains the final authority and its\n// replay grace handles browsers that do not implement Web Locks.\nasync function withCrossTabRefreshLock(clientKey, tokenBeforeRefresh, refreshRequest) {\n const lockName = `auth-refresh-${clientKey}`;\n const run = async () => {\n // Another tab may have completed the rotation while this tab was waiting\n // for the lock. Reuse its access token instead of submitting the consumed\n // refresh cookie a second time.\n try {\n const persistedToken = localStorage.getItem('authToken');\n if (tokenBeforeRefresh && persistedToken && persistedToken !== tokenBeforeRefresh) {\n setToken(persistedToken);\n emitAuthDiagnostic('TOKEN_REFRESH_REUSED_CROSS_TAB', 'SUCCESS', 'CROSS_TAB_ROTATION', {\n clientKey,\n });\n return persistedToken;\n }\n } catch {\n // Storage can be unavailable in privacy-restricted browser contexts.\n }\n\n return refreshRequest();\n };\n\n if (typeof navigator !== 'undefined' && navigator.locks?.request) {\n return navigator.locks.request(lockName, run);\n }\n\n return run();\n}\n\nexport async function refreshToken() {\n const { clientKey, authBaseUrl } = getConfig();\n\n // ✅ Prevent concurrent refresh calls\n if (refreshInProgress && refreshPromise) {\n console.log('🔄 Token refresh already in progress, waiting...');\n return refreshPromise;\n }\n\n refreshInProgress = true;\n refreshPromise = (async () => {\n const tokenBeforeRefresh = getToken();\n const refreshRequest = async () => {\n try {\n // Get stored refresh token (for HTTP development)\n const storedRefreshToken = getRefreshToken();\n\n console.log('🔄 Refreshing token:', {\n clientKey,\n mode: isRouterMode() ? 'ROUTER' : 'CLIENT',\n hasStoredRefreshToken: !!storedRefreshToken\n });\n\n // Build request options - send refresh token in body and header for HTTP dev\n const requestOptions = {\n method: 'POST',\n credentials: 'include', // ✅ Include httpOnly cookies (for HTTPS)\n headers: {\n ...diagnosticHeaders(),\n 'Content-Type': 'application/json'\n }\n };\n\n // For HTTP development, send refresh token in body ONLY (header removed per user request)\n if (storedRefreshToken) {\n // requestOptions.headers['X-Refresh-Token'] = storedRefreshToken;\n requestOptions.body = JSON.stringify({ refreshToken: storedRefreshToken });\n console.log('📦 Sending refresh token in body only (Header skipped) v3.0.2');\n }\n\n const response = await fetch(`${authBaseUrl}/refresh/${clientKey}`, requestOptions);\n\n if (!response.ok) {\n const errorText = await response.text();\n let serverCode = null;\n try { serverCode = JSON.parse(errorText)?.error || JSON.parse(errorText)?.code; } catch {}\n emitAuthDiagnostic('TOKEN_REFRESH_REJECTED', 'FAILURE', serverCode || `HTTP_${response.status}`, {\n clientKey,\n status: response.status,\n });\n const refreshError = new Error(`Refresh failed: ${response.status}`);\n refreshError.code = serverCode || `HTTP_${response.status}`;\n refreshError.status = response.status;\n throw refreshError;\n }\n\n const data = await response.json();\n const { access_token, refresh_token: new_refresh_token } = data;\n\n if (!access_token) {\n throw new Error('No access token in refresh response');\n }\n\n // ✅ This will trigger token listeners\n setToken(access_token);\n\n // ✅ Store new refresh token if provided (token rotation)\n if (new_refresh_token) {\n setRefreshToken(new_refresh_token);\n console.log('🔄 New refresh token stored from rotation');\n }\n\n console.log('✅ Token refresh successful, listeners notified');\n emitAuthDiagnostic('TOKEN_REFRESH_COMPLETED', 'SUCCESS', 'NONE', { clientKey });\n return access_token;\n } catch (err) {\n console.error('❌ Token refresh error:', err);\n // Only clear tokens on definitive auth failure (server explicitly rejected).\n // Network errors / timeouts should NOT clear tokens — the session may still\n // be valid and the next attempt may succeed.\n const isAuthRejection = err.message?.includes('401') ||\n err.message?.includes('403') ||\n err.message?.includes('invalid_grant') ||\n err.message?.includes('Refresh failed: 4');\n if (isAuthRejection) {\n clearToken();\n clearRefreshToken();\n }\n throw err;\n }\n };\n\n return withCrossTabRefreshLock(clientKey, tokenBeforeRefresh, refreshRequest);\n })().finally(() => {\n refreshInProgress = false;\n refreshPromise = null;\n });\n\n return refreshPromise;\n}\n\nexport async function validateCurrentSession() {\n try {\n const { authBaseUrl } = getConfig();\n const token = getToken();\n\n if (!token || !authBaseUrl) {\n return false;\n }\n\n const response = await fetch(`${authBaseUrl}/account/validate-session`, {\n method: 'GET',\n headers: {\n ...diagnosticHeaders(),\n 'Authorization': `Bearer ${token}`,\n 'Content-Type': 'application/json'\n },\n credentials: 'include'\n });\n\n if (!response.ok) {\n if (response.status === 401) {\n return false;\n }\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const json = await response.json();\n // Support both wrapped { success, data: { valid } } and flat { valid } formats\n const payload = json.data || json;\n return payload.valid === true;\n } catch (error) {\n console.warn('Session validation failed:', error.message);\n if (error.message.includes('401')) {\n return false;\n }\n throw error;\n }\n}\n\n// ========== SESSION SECURITY: PROACTIVE REFRESH & VALIDATION ==========\n// These functions ensure that:\n// 1. Tokens are refreshed before they expire (proactive refresh)\n// 2. Sessions deleted in Keycloak Admin UI are detected quickly (periodic validation)\n\nlet proactiveRefreshTimer = null;\nlet sessionValidationTimer = null;\nlet visibilityHandler = null;\nlet sessionInvalidCallbacks = new Set();\n\n// Register a callback to be called when session is invalidated\nexport function onSessionInvalid(callback) {\n if (typeof callback === 'function') {\n sessionInvalidCallbacks.add(callback);\n }\n return () => sessionInvalidCallbacks.delete(callback);\n}\n\n// Notify all registered callbacks that session is invalid\nfunction notifySessionInvalid(reason = 'session_deleted') {\n console.log('🚨 Session invalidated:', reason);\n sessionInvalidCallbacks.forEach(callback => {\n try {\n callback(reason);\n } catch (err) {\n console.error('Session invalid callback error:', err);\n }\n });\n}\n\n// ========== PROACTIVE TOKEN REFRESH ==========\n// Schedules token refresh before expiry to ensure seamless UX\n\nexport function startProactiveRefresh() {\n const { enableProactiveRefresh, tokenRefreshBuffer } = getConfig();\n\n if (!enableProactiveRefresh) {\n console.log('⏸️ Proactive refresh disabled by config');\n return null;\n }\n\n // Clear any existing timer\n stopProactiveRefresh();\n\n const token = getToken();\n if (!token) {\n console.log('⏸️ No token, skipping proactive refresh setup');\n return null;\n }\n\n const timeUntilExpiry = getTimeUntilExpiry(token);\n\n if (timeUntilExpiry <= 0) {\n console.log('⚠️ Token already expired, attempting immediate refresh');\n refreshToken().catch(err => {\n console.error('❌ Immediate refresh failed:', err);\n notifySessionInvalid('token_expired');\n });\n return null;\n }\n\n // Schedule refresh for (expiry - buffer) seconds from now\n const refreshIn = Math.max(0, (timeUntilExpiry - tokenRefreshBuffer)) * 1000;\n\n console.log(`🔄 Scheduling proactive refresh in ${Math.round(refreshIn / 1000)}s (token expires in ${timeUntilExpiry}s)`);\n\n proactiveRefreshTimer = setTimeout(async () => {\n try {\n console.log('🔄 Proactive token refresh triggered');\n await refreshToken();\n console.log('✅ Proactive refresh successful, scheduling next refresh');\n // Schedule next refresh after successful refresh\n startProactiveRefresh();\n } catch (err) {\n console.error('❌ Proactive refresh failed:', err);\n\n // Check if this is a permanent failure (token revoked, invalid, etc.)\n const errorMessage = err.message?.toLowerCase() || '';\n const isPermanentFailure =\n errorMessage.includes('401') ||\n errorMessage.includes('revoked') ||\n errorMessage.includes('invalid') ||\n errorMessage.includes('expired') ||\n errorMessage.includes('unauthorized');\n\n if (isPermanentFailure) {\n console.log('🚨 Token permanently invalid, triggering session expiry');\n notifySessionInvalid('refresh_token_revoked');\n } else {\n // Temporary failure (network issue), try again in 30 seconds\n proactiveRefreshTimer = setTimeout(() => startProactiveRefresh(), 30000);\n }\n }\n }, refreshIn);\n\n return proactiveRefreshTimer;\n}\n\nexport function stopProactiveRefresh() {\n if (proactiveRefreshTimer) {\n clearTimeout(proactiveRefreshTimer);\n proactiveRefreshTimer = null;\n console.log('⏹️ Proactive refresh stopped');\n }\n}\n\n// ========== PERIODIC SESSION VALIDATION ==========\n// Validates with server that session still exists in Keycloak\n// Catches session deletions from Keycloak Admin UI\n\nexport function startSessionMonitor(onInvalid) {\n const { enableSessionValidation, sessionValidationInterval, validateOnVisibility } = getConfig();\n\n if (!enableSessionValidation) {\n console.log('⏸️ Session validation disabled by config');\n return null;\n }\n\n // Register callback if provided\n if (onInvalid && typeof onInvalid === 'function') {\n sessionInvalidCallbacks.add(onInvalid);\n }\n\n // Clear any existing timer\n stopSessionMonitor();\n\n const token = getToken();\n if (!token) {\n console.log('⏸️ No token, skipping session monitor setup');\n return null;\n }\n\n console.log(`👁️ Starting session monitor (interval: ${sessionValidationInterval / 1000}s)`);\n\n // Track when the tab was last hidden — used to decide if a full\n // server-side validation is warranted after the tab becomes visible.\n let hiddenAt = null;\n\n // ── Periodic validation (catches admin-deleted sessions) ──\n sessionValidationTimer = setInterval(async () => {\n try {\n const currentToken = getToken();\n if (!currentToken) {\n console.log('⏸️ No token, stopping session validation');\n stopSessionMonitor();\n return;\n }\n\n // If token is expired, refresh first so the validation call succeeds\n const ttl = getTimeUntilExpiry(currentToken);\n if (ttl <= 0) {\n console.log('Token expired before periodic check - refreshing');\n try {\n await refreshToken();\n } catch (refreshErr) {\n console.log('Periodic refresh failed - notifying consumer');\n notifySessionInvalid('session_expired');\n return;\n }\n }\n\n console.log('Validating session...');\n const isValid = await validateCurrentSession();\n\n if (!isValid) {\n console.log('❌ Session no longer valid on server');\n notifySessionInvalid('session_deleted');\n } else {\n console.log('✅ Session still valid');\n }\n } catch (error) {\n console.warn('⚠️ Session validation check failed:', error.message);\n // Don't invalidate on network errors - wait for next check\n }\n }, sessionValidationInterval);\n\n // ── Visibility-based validation (smart, enterprise-grade) ──\n if (validateOnVisibility && typeof document !== 'undefined') {\n visibilityHandler = async () => {\n // ── Tab hidden: record timestamp ──\n if (document.visibilityState === 'hidden') {\n hiddenAt = Date.now();\n return;\n }\n\n // ── Tab visible: decide what to do ──\n if (document.visibilityState === 'visible') {\n const currentToken = getToken();\n if (!currentToken) return;\n\n const ttl = getTimeUntilExpiry(currentToken);\n const hiddenDuration = hiddenAt ? Date.now() - hiddenAt : 0;\n hiddenAt = null;\n\n // ── Case 1: Token still valid & tab was hidden briefly ──\n // No network call needed — everything is fine.\n if (ttl > 0 && hiddenDuration < sessionValidationInterval) {\n console.log(`👁️ Tab visible — token valid (${Math.round(ttl)}s left), hidden for ${Math.round(hiddenDuration / 1000)}s — skipping validation`);\n return;\n }\n\n // ── Case 2: Token still valid BUT tab was hidden longer than validation interval ──\n // Validate with server to catch admin-deleted sessions.\n if (ttl > 0 && hiddenDuration >= sessionValidationInterval) {\n console.log(`👁️ Tab visible — token valid but hidden for ${Math.round(hiddenDuration / 1000)}s — server-validating`);\n try {\n const isValid = await validateCurrentSession();\n if (isValid) {\n console.log('✅ Session confirmed valid on server');\n return;\n }\n // Server says invalid despite valid token — admin deleted session\n console.log('❌ Session deleted by admin while tab was hidden');\n stopSessionMonitor();\n stopProactiveRefresh();\n clearToken();\n clearRefreshToken();\n notifySessionInvalid('session_deleted_while_hidden');\n return;\n } catch (error) {\n // Network error — give benefit of the doubt, token is valid\n console.warn('⚠️ Server validation failed (network), token still valid — continuing');\n return;\n }\n }\n\n // ── Case 3: Token expired (browser throttled the refresh timer) ──\n // Try silent refresh — this is the most common case.\n console.log('⚠️ Token expired while tab was hidden — attempting silent refresh');\n try {\n await refreshToken();\n console.log('✅ Token silently refreshed — session restored');\n\n // If hidden for a long time, also verify the session on server\n if (hiddenDuration >= sessionValidationInterval) {\n const isValid = await validateCurrentSession();\n if (!isValid) {\n console.log('❌ Token refreshed but session deleted on server');\n stopSessionMonitor();\n stopProactiveRefresh();\n clearToken();\n clearRefreshToken();\n notifySessionInvalid('session_deleted_while_hidden');\n }\n }\n return;\n } catch (refreshErr) {\n console.log('❌ Silent refresh failed — session genuinely expired:', refreshErr.message);\n }\n\n // Both refresh AND validation failed — session truly dead\n stopSessionMonitor();\n stopProactiveRefresh();\n clearToken();\n clearRefreshToken();\n notifySessionInvalid('session_deleted_while_hidden');\n }\n };\n document.addEventListener('visibilitychange', visibilityHandler);\n }\n\n return sessionValidationTimer;\n}\n\nexport function stopSessionMonitor() {\n if (sessionValidationTimer) {\n clearInterval(sessionValidationTimer);\n sessionValidationTimer = null;\n console.log('⏹️ Session monitor stopped');\n }\n\n if (visibilityHandler && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', visibilityHandler);\n visibilityHandler = null;\n }\n}\n\n// ========== COMBINED SESSION SECURITY ==========\n// Start both proactive refresh and session monitoring\n\nexport function startSessionSecurity(onSessionInvalidCallback) {\n console.log('🔐 Starting session security (proactive refresh + session monitoring)');\n\n startProactiveRefresh();\n startSessionMonitor(onSessionInvalidCallback);\n\n return {\n stopAll: () => {\n stopProactiveRefresh();\n stopSessionMonitor();\n }\n };\n}\n\nexport function stopSessionSecurity() {\n stopProactiveRefresh();\n stopSessionMonitor();\n sessionInvalidCallbacks.clear();\n console.log('🔐 Session security stopped');\n}\n"],"mappings":";AACA,OAAO,WAAW;;;ACClB,SAAS,iBAAiB;AAE1B,IAAI,cAAc;AAClB,IAAM,YAAY,oBAAI,IAAI;AAE1B,IAAM,iBAAiB;AACvB,IAAM,iBAAiB,IAAI,KAAK,KAAK;AAErC,SAAS,kBAAkB;AAV3B;AAWE,MAAI;AACF,WAAO,OAAO,WAAW,iBAAe,YAAO,aAAP,mBAAiB,cAAa,WAClE,aACA;AAAA,EACN,SAAS,KAAK;AACZ,WAAO;AAAA,EACT;AACF;AAGA,SAAS,iBAAiB,OAAO;AAC/B,MAAI,CAAC,OAAO;AACV,QAAI;AACF,mBAAa,WAAW,WAAW;AAAA,IACrC,SAAS,KAAK;AACZ,cAAQ,KAAK,4CAA4C,GAAG;AAAA,IAC9D;AACA;AAAA,EACF;AAEA,MAAI;AACF,iBAAa,QAAQ,aAAa,KAAK;AAAA,EACzC,SAAS,KAAK;AACZ,YAAQ,KAAK,4CAA4C,GAAG;AAAA,EAC9D;AACF;AAEA,SAAS,kBAAkB;AACzB,MAAI;AACF,WAAO,aAAa,QAAQ,WAAW;AAAA,EACzC,SAAS,KAAK;AACZ,YAAQ,KAAK,2CAA2C,GAAG;AAC3D,WAAO;AAAA,EACT;AACF;AAmFO,SAAS,SAAS,OAAO;AAC9B,QAAM,gBAAgB;AACtB,gBAAc,SAAS;AACvB,mBAAiB,WAAW;AAE5B,MAAI,kBAAkB,aAAa;AACjC,cAAU,QAAQ,CAAC,aAAa;AAC9B,UAAI;AACF,iBAAS,aAAa,aAAa;AAAA,MACrC,SAAS,KAAK;AACZ,gBAAQ,KAAK,yBAAyB,GAAG;AAAA,MAC3C;AAAA,IACF,CAAC;AAAA,EACH;AACF;AAEO,SAAS,WAAW;AACzB,MAAI,YAAa,QAAO;AACxB,gBAAc,gBAAgB;AAC9B,SAAO;AACT;AAEO,SAAS,aAAa;AAC3B,MAAI,CAAC,aAAa;AAChB,qBAAiB,IAAI;AACrB,sBAAkB;AAClB;AAAA,EACF;AAEA,QAAM,gBAAgB;AACtB,gBAAc;AACd,mBAAiB,IAAI;AACrB,oBAAkB;AAElB,YAAU,QAAQ,CAAC,aAAa;AAC9B,QAAI;AACF,eAAS,MAAM,aAAa;AAAA,IAC9B,SAAS,KAAK;AACZ,cAAQ,KAAK,yBAAyB,GAAG;AAAA,IAC3C;AAAA,EACF,CAAC;AACH;AAQA,IAAM,oBAAoB;AAG1B,IAAI,uBAAuB;AAO3B,SAAS,wBAAwB;AA3LjC;AA6LE,MAAI,qBAAsB,QAAO;AAEjC,MAAI;AACF,WAAO,OAAO,WAAW,iBACvB,YAAO,aAAP,mBAAiB,cAAa;AAAA,EAClC,SAAS,KAAK;AACZ,WAAO;AAAA,EACT;AACF;AAEO,SAAS,gBAAgB,OAAO;AACrC,MAAI,CAAC,OAAO;AACV,sBAAkB;AAClB;AAAA,EACF;AAEA,MAAI,sBAAsB,GAAG;AAC3B,QAAI;AACF,mBAAa,QAAQ,mBAAmB,KAAK;AAC7C,cAAQ,IAAI,mDAA4C,uBAAuB,wBAAwB,eAAe,GAAG;AAAA,IAC3H,SAAS,KAAK;AACZ,cAAQ,KAAK,kCAAkC,GAAG;AAAA,IACpD;AAAA,EACF,OAAO;AAEL,YAAQ,IAAI,6EAAsE;AAAA,EACpF;AACF;AAEO,SAAS,kBAAkB;AAChC,MAAI,sBAAsB,GAAG;AAC3B,QAAI;AACF,YAAM,QAAQ,aAAa,QAAQ,iBAAiB;AACpD,aAAO;AAAA,IACT,SAAS,KAAK;AACZ,cAAQ,KAAK,iCAAiC,GAAG;AACjD,aAAO;AAAA,IACT;AAAA,EACF;AAIA,SAAO;AACT;AAEO,SAAS,oBAAoB;AAElC,MAAI;AACF,iBAAa,WAAW,iBAAiB;AAAA,EAC3C,SAAS,KAAK;AAAA,EAEd;AAGA,MAAI;AACF,aAAS,SAAS,GAAG,cAAc,6BAA6B,gBAAgB,CAAC;AAAA,EACnF,SAAS,KAAK;AACZ,YAAQ,KAAK,yCAAyC,GAAG;AAAA,EAC3D;AAGA,MAAI;AACF,mBAAe,WAAW,cAAc;AAAA,EAC1C,SAAS,KAAK;AAAA,EAEd;AACF;AAuBA,IAAI,OAAO,WAAW,eAAe,OAAO,kBAAkB;AAC5D,SAAO,iBAAiB,WAAW,CAAC,UAAU;AAC5C,QAAI,MAAM,QAAQ,YAAa;AAE/B,UAAM,gBAAgB;AACtB,kBAAc,MAAM,YAAY;AAChC,QAAI,kBAAkB,YAAa;AAEnC,cAAU,QAAQ,CAAC,aAAa;AAC9B,UAAI;AACF,iBAAS,aAAa,aAAa;AAAA,MACrC,SAAS,KAAK;AACZ,gBAAQ,KAAK,yBAAyB,GAAG;AAAA,MAC3C;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AACH;;;AC/RA,IAAI,SAAS;AAAA,EACX,WAAW;AAAA,EACX,aAAa;AAAA,EACb,aAAa;AAAA,EACb,cAAc;AAAA,EACd,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA,EAKV,oBAAoB;AAAA;AAAA;AAAA;AAAA,EAKpB,2BAA2B,KAAK,KAAK;AAAA;AAAA;AAAA,EAIrC,yBAAyB;AAAA;AAAA;AAAA,EAIzB,wBAAwB;AAAA;AAAA;AAAA,EAIxB,sBAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOtB,qBAAqB;AACvB;AAEA,IAAM,0BAA0B;AAAA,EAC9B,oBAAoB;AAAA,EACpB,2BAA2B,KAAK,KAAK;AAAA,EACrC,yBAAyB;AAAA,EACzB,wBAAwB;AAAA,EACxB,sBAAsB;AACxB;AAgEO,SAAS,YAAY;AAC1B,SAAO,EAAE,GAAG,OAAO;AACrB;AAGO,SAAS,eAAe;AAC7B,SAAO,OAAO;AAChB;;;ACzHA,IAAM,cAAc;AAGpB,IAAM,WAAW,MAAM;AAHvB;AAIE,OAAI,gBAAW,WAAX,mBAAmB,WAAY,QAAO,WAAW,OAAO,WAAW;AACvE,SAAO,QAAQ,KAAK,IAAI,CAAC,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,CAAC,CAAC;AAClE;AAEA,IAAM,cAAc,OAAO,UAAU;AARrC;AASE,MAAI,CAAC,SAAS,GAAC,gBAAW,WAAX,mBAAmB,QAAQ,QAAO;AACjD,QAAM,QAAQ,IAAI,YAAY,EAAE,OAAO,OAAO,KAAK,CAAC;AACpD,QAAM,SAAS,MAAM,WAAW,OAAO,OAAO,OAAO,WAAW,KAAK;AACrE,SAAO,CAAC,GAAG,IAAI,WAAW,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,EAC1C,IAAI,CAAC,SAAS,KAAK,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE;AAC9D;AAEO,SAAS,uBAAuB;AACrC,MAAI;AACF,UAAM,WAAW,KAAK,MAAM,eAAe,QAAQ,WAAW,KAAK,MAAM;AACzE,QAAI,qCAAU,cAAe,QAAO;AAAA,EACtC,QAAQ;AAAA,EAAC;AACT,QAAM,UAAU,EAAE,eAAe,SAAS,GAAG,WAAW,KAAK,IAAI,EAAE;AACnE,MAAI;AAAE,mBAAe,QAAQ,aAAa,KAAK,UAAU,OAAO,CAAC;AAAA,EAAG,QAAQ;AAAA,EAAC;AAC7E,SAAO;AACT;AAQO,SAAS,oBAAoB;AAClC,QAAM,EAAE,cAAc,IAAI,qBAAqB;AAC/C,SAAO,EAAE,oBAAoB,eAAe,gBAAgB,cAAc;AAC5E;AAiBA,eAAsB,mBAAmB,OAAO,SAAS,YAAY,UAAU,CAAC,GAAG;AACjF,QAAM,UAAU,qBAAqB;AACrC,QAAM,OAAO;AAAA,IACX,WAAW;AAAA,IACX;AAAA,IACA;AAAA,IACA;AAAA,IACA,eAAe,QAAQ;AAAA,IACvB,WAAW,KAAK,IAAI,IAAI,QAAQ;AAAA,IAChC,WAAW,QAAQ,aAAa;AAAA,IAChC,QAAQ,QAAQ,UAAU;AAAA,IAC1B,kBAAkB,MAAM,YAAY,QAAQ,KAAK;AAAA,IACjD,QAAQ,OAAO,cAAc,cAAc,OAAO,UAAU;AAAA,EAC9D;AACA,QAAM,SAAS,YAAY,YAAY,UAAU,YAAY,YAAY,SAAS;AAClF,UAAQ,MAAM,EAAE,iBAAiB,IAAI;AACrC,SAAO;AACT;;;ACqLA,IAAI,oBAAoB;AACxB,IAAI,iBAAiB;AAMrB,eAAe,wBAAwB,WAAW,oBAAoB,gBAAgB;AAjQtF;AAkQE,QAAM,WAAW,gBAAgB,SAAS;AAC1C,QAAM,MAAM,YAAY;AAItB,QAAI;AACF,YAAM,iBAAiB,aAAa,QAAQ,WAAW;AACvD,UAAI,sBAAsB,kBAAkB,mBAAmB,oBAAoB;AACjF,iBAAS,cAAc;AACvB,2BAAmB,kCAAkC,WAAW,sBAAsB;AAAA,UACpF;AAAA,QACF,CAAC;AACD,eAAO;AAAA,MACT;AAAA,IACF,QAAQ;AAAA,IAER;AAEA,WAAO,eAAe;AAAA,EACxB;AAEA,MAAI,OAAO,cAAc,iBAAe,eAAU,UAAV,mBAAiB,UAAS;AAChE,WAAO,UAAU,MAAM,QAAQ,UAAU,GAAG;AAAA,EAC9C;AAEA,SAAO,IAAI;AACb;AAEA,eAAsB,eAAe;AACnC,QAAM,EAAE,WAAW,YAAY,IAAI,UAAU;AAG7C,MAAI,qBAAqB,gBAAgB;AACvC,YAAQ,IAAI,yDAAkD;AAC9D,WAAO;AAAA,EACT;AAEA,sBAAoB;AACpB,oBAAkB,YAAY;AAC5B,UAAM,qBAAqB,SAAS;AACpC,UAAM,iBAAiB,YAAY;AA1SvC;AA2SM,UAAI;AAEF,cAAM,qBAAqB,gBAAgB;AAE3C,gBAAQ,IAAI,+BAAwB;AAAA,UAClC;AAAA,UACA,MAAM,aAAa,IAAI,WAAW;AAAA,UAClC,uBAAuB,CAAC,CAAC;AAAA,QAC3B,CAAC;AAGD,cAAM,iBAAiB;AAAA,UACrB,QAAQ;AAAA,UACR,aAAa;AAAA;AAAA,UACb,SAAS;AAAA,YACP,GAAG,kBAAkB;AAAA,YACrB,gBAAgB;AAAA,UAClB;AAAA,QACF;AAGA,YAAI,oBAAoB;AAEtB,yBAAe,OAAO,KAAK,UAAU,EAAE,cAAc,mBAAmB,CAAC;AACzE,kBAAQ,IAAI,sEAA+D;AAAA,QAC7E;AAEA,cAAM,WAAW,MAAM,MAAM,GAAG,WAAW,YAAY,SAAS,IAAI,cAAc;AAElF,YAAI,CAAC,SAAS,IAAI;AAChB,gBAAM,YAAY,MAAM,SAAS,KAAK;AACtC,cAAI,aAAa;AACjB,cAAI;AAAE,2BAAa,UAAK,MAAM,SAAS,MAApB,mBAAuB,YAAS,UAAK,MAAM,SAAS,MAApB,mBAAuB;AAAA,UAAM,QAAQ;AAAA,UAAC;AACzF,6BAAmB,0BAA0B,WAAW,cAAc,QAAQ,SAAS,MAAM,IAAI;AAAA,YAC/F;AAAA,YACA,QAAQ,SAAS;AAAA,UACnB,CAAC;AACD,gBAAM,eAAe,IAAI,MAAM,mBAAmB,SAAS,MAAM,EAAE;AACnE,uBAAa,OAAO,cAAc,QAAQ,SAAS,MAAM;AACzD,uBAAa,SAAS,SAAS;AAC/B,gBAAM;AAAA,QACR;AAEA,cAAM,OAAO,MAAM,SAAS,KAAK;AACjC,cAAM,EAAE,cAAc,eAAe,kBAAkB,IAAI;AAE3D,YAAI,CAAC,cAAc;AACjB,gBAAM,IAAI,MAAM,qCAAqC;AAAA,QACvD;AAGA,iBAAS,YAAY;AAGrB,YAAI,mBAAmB;AACrB,0BAAgB,iBAAiB;AACjC,kBAAQ,IAAI,kDAA2C;AAAA,QACzD;AAEA,gBAAQ,IAAI,qDAAgD;AAC5D,2BAAmB,2BAA2B,WAAW,QAAQ,EAAE,UAAU,CAAC;AAC9E,eAAO;AAAA,MACT,SAAS,KAAK;AACZ,gBAAQ,MAAM,+BAA0B,GAAG;AAI3C,cAAM,oBAAkB,SAAI,YAAJ,mBAAa,SAAS,aAC5C,SAAI,YAAJ,mBAAa,SAAS,aACtB,SAAI,YAAJ,mBAAa,SAAS,uBACtB,SAAI,YAAJ,mBAAa,SAAS;AACxB,YAAI,iBAAiB;AACnB,qBAAW;AACX,4BAAkB;AAAA,QACpB;AACA,cAAM;AAAA,MACR;AAAA,IACF;AAEA,WAAO,wBAAwB,WAAW,oBAAoB,cAAc;AAAA,EAC9E,GAAG,EAAE,QAAQ,MAAM;AACjB,wBAAoB;AACpB,qBAAiB;AAAA,EACnB,CAAC;AAED,SAAO;AACT;;;AJ1XA,IAAM,MAAM,MAAM,OAAO;AAAA,EACvB,iBAAiB;AACnB,CAAC;AAED,IAAI,aAAa,QAAQ,IAAI,CAACA,YAAW;AACvC,QAAM,gBAAgB,UAAU;AAEhC,MAAI,CAACA,QAAO,SAAS;AAEnB,IAAAA,QAAO,WAAU,+CAAe,gBAAe;AAAA,EACjD;AAEA,MAAI,CAACA,QAAO,SAAS;AACnB,IAAAA,QAAO,UAAU,CAAC;AAAA,EACpB;AAEA,OAAI,+CAAe,cAAa,CAACA,QAAO,QAAQ,cAAc,GAAG;AAC/D,IAAAA,QAAO,QAAQ,cAAc,IAAI,cAAc;AAAA,EACjD;AACA,SAAO,OAAOA,QAAO,SAAS,kBAAkB,CAAC;AAEjD,QAAM,QAAQ,SAAS;AACvB,MAAI,OAAO;AACT,IAAAA,QAAO,QAAQ,gBAAgB,UAAU,KAAK;AAAA,EAChD;AAEA,SAAOA;AACT,CAAC;AAED,IAAIC,kBAAiB;AAErB,IAAI,aAAa,SAAS;AAAA,EACxB,CAAC,aAAa;AAAA,EACd,OAAO,UAAU;AACf,UAAM,EAAE,UAAU,QAAAD,QAAO,IAAI,SAAS,CAAC;AAEvC,QAAI,CAAC,YAAY,CAACA,SAAQ;AACxB,aAAO,QAAQ,OAAO,KAAK;AAAA,IAC7B;AAEA,QAAI,SAAS,WAAW,OAAOA,QAAO,QAAQ;AAC5C,aAAO,QAAQ,OAAO,KAAK;AAAA,IAC7B;AAEA,IAAAA,QAAO,SAAS;AAChB,uBAAmB,2BAA2B,WAAW,YAAY;AAAA,MACnE,WAAW,UAAU,EAAE;AAAA,MACvB,QAAQ;AAAA,IACV,CAAC;AAED,QAAI,CAACC,iBAAgB;AACnB,MAAAA,kBAAiB,aAAe,EAC7B,KAAK,CAAC,aAAa;AAClB,QAAAA,kBAAiB;AACjB,YAAI,UAAU;AACZ,mBAAS,QAAQ;AAAA,QACnB;AACA,eAAO;AAAA,MACT,CAAC,EACA,MAAM,CAAC,iBAAiB;AACvB,QAAAA,kBAAiB;AAIjB,cAAM;AAAA,MACR,CAAC;AAAA,IACL;AAEA,QAAI;AACF,YAAM,iBAAiB,MAAMA;AAE7B,UAAI,gBAAgB;AAClB,QAAAD,QAAO,QAAQ,gBAAgB,UAAU,cAAc;AACvD,eAAO,IAAIA,OAAM;AAAA,MACnB;AAAA,IACF,SAAS,YAAY;AAGnB,aAAO,QAAQ,OAAO,KAAK;AAAA,IAC7B;AAEA,WAAO,QAAQ,OAAO,KAAK;AAAA,EAC7B;AACF;AAEA,IAAI,kBAAkB,YAAY;AA5FlC;AA6FE,MAAI;AACF,UAAM,WAAW,MAAM,IAAI,IAAI,2BAA2B;AAC1D,WAAO,SAAS,KAAK;AAAA,EACvB,SAAS,KAAK;AACZ,UAAI,SAAI,aAAJ,mBAAc,YAAW,KAAK;AAChC,aAAO;AAAA,IACT;AACA,UAAM;AAAA,EACR;AACF;AAEA,IAAO,cAAQ;","names":["config","refreshPromise"]}
1
+ {"version":3,"sources":["../api.js","../token.js","../config.js","../diagnostics.js","../core.js"],"sourcesContent":["// auth-client/api.js\nimport axios from 'axios';\nimport { getConfig } from './config.js';\nimport { getToken, setToken, clearToken } from './token.js';\nimport { refreshToken as performRefresh } from './core.js';\nimport { diagnosticHeaders, emitAuthDiagnostic } from './diagnostics.js';\n\nconst api = axios.create({\n withCredentials: true,\n});\n\napi.interceptors.request.use((config) => {\n const runtimeConfig = getConfig();\n\n if (!config.baseURL) {\n\n config.baseURL = runtimeConfig?.authBaseUrl || 'http://auth.local.test:4000/auth';\n }\n\n if (!config.headers) {\n config.headers = {};\n }\n\n if (runtimeConfig?.clientKey && !config.headers['X-Client-Key']) {\n config.headers['X-Client-Key'] = runtimeConfig.clientKey;\n }\n Object.assign(config.headers, diagnosticHeaders());\n\n const token = getToken();\n if (token) {\n config.headers.Authorization = `Bearer ${token}`;\n }\n\n return config;\n});\n\nlet refreshPromise = null;\n\napi.interceptors.response.use(\n (response) => response,\n async (error) => {\n const { response, config } = error || {};\n\n if (!response || !config) {\n return Promise.reject(error);\n }\n\n if (response.status !== 401 || config._retry) {\n return Promise.reject(error);\n }\n\n config._retry = true;\n emitAuthDiagnostic('API_401_REFRESH_STARTED', 'PENDING', 'HTTP_401', {\n clientKey: getConfig().clientKey,\n status: 401,\n });\n\n if (!refreshPromise) {\n refreshPromise = performRefresh()\n .then((newToken) => {\n refreshPromise = null;\n if (newToken) {\n setToken(newToken);\n }\n return newToken;\n })\n .catch((refreshError) => {\n refreshPromise = null;\n // ❌ REMOVED: clearToken() here caused cascading logouts.\n // The calling code (AuthContext) handles token clearing\n // based on the specific error context (401 vs network error).\n throw refreshError;\n });\n }\n\n try {\n const refreshedToken = await refreshPromise;\n\n if (refreshedToken) {\n config.headers.Authorization = `Bearer ${refreshedToken}`;\n return api(config);\n }\n } catch (refreshErr) {\n // Refresh failed — propagate the ORIGINAL 401 error so the caller\n // knows the request was unauthorized (not a refresh-specific error).\n return Promise.reject(error);\n }\n\n return Promise.reject(error);\n }\n);\n\napi.validateSession = async () => {\n try {\n const response = await api.get('/account/validate-session');\n return response.data.valid;\n } catch (err) {\n if (err.response?.status === 401) {\n return false;\n }\n throw err;\n }\n};\n\nexport default api;\n","// auth-client/token.js - MINIMAL WORKING VERSION\n\nimport { jwtDecode } from 'jwt-decode';\n\nlet accessToken = null;\nlet idToken = null;\nconst listeners = new Set();\n\nconst REFRESH_COOKIE = 'account_refresh_token';\nconst ID_TOKEN_KEY = 'auth_id_token';\nconst COOKIE_MAX_AGE = 7 * 24 * 60 * 60;\n\nfunction secureAttribute() {\n try {\n return typeof window !== 'undefined' && window.location?.protocol === 'https:'\n ? '; Secure'\n : '';\n } catch (err) {\n return '';\n }\n}\n\n// ========== ACCESS TOKEN ==========\nfunction writeAccessToken(token) {\n if (!token) {\n try {\n localStorage.removeItem('authToken');\n } catch (err) {\n console.warn('Could not clear token from localStorage:', err);\n }\n return;\n }\n\n try {\n localStorage.setItem('authToken', token);\n } catch (err) {\n console.warn('Could not persist token to localStorage:', err);\n }\n}\n\nfunction readAccessToken() {\n try {\n return localStorage.getItem('authToken');\n } catch (err) {\n console.warn('Could not read token from localStorage:', err);\n return null;\n }\n}\n\n// ========== REFRESH TOKEN (KEEP SIMPLE) ==========\n// export function setRefreshToken(token) {\n// if (!token) {\n// clearRefreshToken();\n// return;\n// }\n\n// const expires = new Date(Date.now() + COOKIE_MAX_AGE * 1000);\n\n// try {\n// document.cookie = `${REFRESH_COOKIE}=${encodeURIComponent(token)}; Path=/; SameSite=Lax${secureAttribute()}; Expires=${expires.toUTCString()}`;\n// } catch (err) {\n// console.warn('Could not set refresh token:', err);\n// }\n// }\n\n// export function getRefreshToken() {\n// try {\n// const match = document.cookie\n// ?.split('; ')\n// ?.find((row) => row.startsWith(`${REFRESH_COOKIE}=`));\n\n// if (match) {\n// return decodeURIComponent(match.split('=')[1]);\n// }\n// } catch (err) {\n// console.warn('Could not read refresh token:', err);\n// }\n\n// return null;\n// }\n\n// export function clearRefreshToken() {\n// try {\n// document.cookie = `${REFRESH_COOKIE}=; Path=/; SameSite=Lax${secureAttribute()}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;\n// } catch (err) {\n// console.warn('Could not clear refresh token:', err);\n// }\n// }\n\n// ========== ACCESS TOKEN FUNCTIONS ==========\nfunction decode(token) {\n try {\n return jwtDecode(token);\n } catch (err) {\n return null;\n }\n}\n\nfunction isExpired(token, bufferSeconds = 60) {\n if (!token) return true;\n const decoded = decode(token);\n if (!decoded?.exp) return true;\n const now = Date.now() / 1000;\n return decoded.exp < now + bufferSeconds;\n}\n\n// ========== TOKEN EXPIRY UTILITIES ==========\n// Get the exact expiry time of a token as a Date object\nexport function getTokenExpiryTime(token) {\n if (!token) return null;\n const decoded = decode(token);\n if (!decoded?.exp) return null;\n return new Date(decoded.exp * 1000);\n}\n\n// Get seconds until token expires (negative if already expired)\nexport function getTimeUntilExpiry(token) {\n if (!token) return -1;\n const decoded = decode(token);\n if (!decoded?.exp) return -1;\n const now = Date.now() / 1000;\n return Math.floor(decoded.exp - now);\n}\n\n// Check if token will expire within the next N seconds\nexport function willExpireSoon(token, withinSeconds = 60) {\n const timeLeft = getTimeUntilExpiry(token);\n return timeLeft >= 0 && timeLeft <= withinSeconds;\n}\n\nexport function setToken(token) {\n const previousToken = accessToken;\n accessToken = token || null;\n writeAccessToken(accessToken);\n\n if (previousToken !== accessToken) {\n listeners.forEach((listener) => {\n try {\n listener(accessToken, previousToken);\n } catch (err) {\n console.warn('Token listener error:', err);\n }\n });\n }\n}\n\nexport function getToken() {\n if (accessToken) return accessToken;\n accessToken = readAccessToken();\n return accessToken;\n}\n\nexport function clearToken() {\n if (!accessToken) {\n writeAccessToken(null);\n clearIdToken();\n clearRefreshToken();\n return;\n }\n\n const previousToken = accessToken;\n accessToken = null;\n writeAccessToken(null);\n clearIdToken();\n clearRefreshToken();\n\n listeners.forEach((listener) => {\n try {\n listener(null, previousToken);\n } catch (err) {\n console.warn('Token listener error:', err);\n }\n });\n}\n\n// ID tokens are retained only for the current browser tab. They are needed as\n// the OIDC logout hint, but must not be placed in localStorage or sent to\n// application APIs like access tokens are.\nexport function setIdToken(token) {\n idToken = token || null;\n try {\n if (idToken) sessionStorage.setItem(ID_TOKEN_KEY, idToken);\n else sessionStorage.removeItem(ID_TOKEN_KEY);\n } catch {\n // Storage can be unavailable in privacy-restricted browser contexts.\n }\n}\n\nexport function getIdToken() {\n if (idToken) return idToken;\n try {\n idToken = sessionStorage.getItem(ID_TOKEN_KEY) || null;\n } catch {\n idToken = null;\n }\n return idToken;\n}\n\nexport function clearIdToken() {\n idToken = null;\n try {\n sessionStorage.removeItem(ID_TOKEN_KEY);\n } catch {\n // Ignore storage cleanup failures.\n }\n}\n\n// ========== REFRESH TOKEN STORAGE ==========\n// By default:\n// HTTP → localStorage (cookies don't work cross-origin in dev)\n// HTTPS → httpOnly cookies (secure, managed by server)\n// When persistRefreshToken is enabled:\n// Always use localStorage (for local HTTPS with mkcert/self-signed certs)\nconst REFRESH_TOKEN_KEY = 'auth_refresh_token';\n\n// ✅ Persistence flag - controlled by config.persistRefreshToken\nlet _persistRefreshToken = false;\n\nexport function enableRefreshTokenPersistence(enabled) {\n _persistRefreshToken = !!enabled;\n console.log(`🔧 Refresh token persistence: ${_persistRefreshToken ? 'ENABLED' : 'DISABLED'}`);\n}\n\nfunction shouldUseLocalStorage() {\n // If persistence is forced, always use localStorage\n if (_persistRefreshToken) return true;\n // Otherwise, only use localStorage on HTTP (dev mode)\n try {\n return typeof window !== 'undefined' &&\n window.location?.protocol === 'http:';\n } catch (err) {\n return false;\n }\n}\n\nexport function setRefreshToken(token) {\n if (!token) {\n clearRefreshToken();\n return;\n }\n\n if (shouldUseLocalStorage()) {\n try {\n localStorage.setItem(REFRESH_TOKEN_KEY, token);\n console.log(`📦 Refresh token stored in localStorage (${_persistRefreshToken ? 'persistence enabled' : 'HTTP dev mode'})`);\n } catch (err) {\n console.warn('Could not store refresh token:', err);\n }\n } else {\n // HTTPS without persistence: refresh token is in httpOnly cookie only\n console.log('🔒 Refresh token managed by server httpOnly cookie (production mode)');\n }\n}\n\nexport function getRefreshToken() {\n if (shouldUseLocalStorage()) {\n try {\n const token = localStorage.getItem(REFRESH_TOKEN_KEY);\n return token;\n } catch (err) {\n console.warn('Could not read refresh token:', err);\n return null;\n }\n }\n\n // HTTPS without persistence: refresh token is in httpOnly cookie (not accessible via JS)\n // The refresh endpoint uses credentials: 'include' to send the cookie\n return null;\n}\n\nexport function clearRefreshToken() {\n // Clear localStorage (for HTTP dev)\n try {\n localStorage.removeItem(REFRESH_TOKEN_KEY);\n } catch (err) {\n // Ignore\n }\n\n // Clear cookie (for production)\n try {\n document.cookie = `${REFRESH_COOKIE}=; Path=/; SameSite=Strict${secureAttribute()}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;\n } catch (err) {\n console.warn('Could not clear refresh token cookie:', err);\n }\n\n // Clear sessionStorage\n try {\n sessionStorage.removeItem(REFRESH_COOKIE);\n } catch (err) {\n // Ignore\n }\n}\n\nexport function addTokenListener(listener) {\n if (typeof listener !== 'function') {\n throw new Error('Token listener must be a function');\n }\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n}\n\nexport function removeTokenListener(listener) {\n listeners.delete(listener);\n}\n\nexport function getListenerCount() {\n return listeners.size;\n}\n\n// localStorage changes do not fire in the tab that made the change, but they\n// do fire in sibling tabs on the same origin. Keep the in-memory token cache\n// and React consumers synchronized when another tab logs in or out.\nif (typeof window !== 'undefined' && window.addEventListener) {\n window.addEventListener('storage', (event) => {\n if (event.key !== 'authToken') return;\n\n const previousToken = accessToken;\n accessToken = event.newValue || null;\n if (previousToken === accessToken) return;\n\n listeners.forEach((listener) => {\n try {\n listener(accessToken, previousToken);\n } catch (err) {\n console.warn('Token listener error:', err);\n }\n });\n });\n}\n\nexport function isAuthenticated() {\n const token = getToken();\n return !!token && !isExpired(token, 10);\n}\n","// auth-client/config.js\nimport { enableRefreshTokenPersistence } from './token.js';\n\n// ========== SESSION SECURITY CONFIGURATION ==========\n// These settings control how the auth-client handles token refresh and session validation\n// to ensure deleted sessions in Keycloak are detected quickly.\n\nlet config = {\n clientKey: null,\n authBaseUrl: null,\n redirectUri: null,\n accountUiUrl: null,\n isRouter: false, // ✅ Add router flag\n\n // ========== SESSION SECURITY SETTINGS ==========\n // Buffer time (in seconds) before token expiry to trigger proactive refresh\n // With 5-minute access tokens, refreshing 60s before expiry ensures seamless UX\n tokenRefreshBuffer: 60,\n\n // Interval (in milliseconds) for periodic session validation\n // Validates that the session still exists in Keycloak (not deleted by admin)\n // Default: 15 minutes (900000ms) - Increased from 2m to avoid frequent checks\n sessionValidationInterval: 15 * 60 * 1000,\n\n // Enable/disable periodic session validation\n // When enabled, the client will ping the server to verify session is still active\n enableSessionValidation: true,\n\n // Enable/disable proactive token refresh\n // When enabled, tokens are refreshed before they expire (using tokenRefreshBuffer)\n enableProactiveRefresh: true,\n\n // Validate session when browser tab becomes visible again\n // Catches session deletions that happened while the tab was inactive\n validateOnVisibility: true,\n\n // ========== REFRESH TOKEN PERSISTENCE ==========\n // When true, stores refresh token in localStorage even on HTTPS\n // Required for local dev with mkcert/self-signed certs where httpOnly cookies\n // may not work reliably across origins\n // ⚠️ In true production, set to false and rely on httpOnly cookies\n persistRefreshToken: false,\n};\n\nconst RUNTIME_POLICY_DEFAULTS = {\n tokenRefreshBuffer: 60,\n sessionValidationInterval: 15 * 60 * 1000,\n enableSessionValidation: true,\n enableProactiveRefresh: true,\n validateOnVisibility: true,\n};\n\nfunction validateRuntimePolicy(policy = {}) {\n const numberInRange = (value, min, max) => Number.isInteger(value) && value >= min && value <= max;\n const result = { ...RUNTIME_POLICY_DEFAULTS };\n if (numberInRange(policy.tokenRefreshBuffer, 10, 300)) result.tokenRefreshBuffer = policy.tokenRefreshBuffer;\n if (numberInRange(policy.sessionValidationInterval, 60000, 86400000)) result.sessionValidationInterval = policy.sessionValidationInterval;\n for (const key of ['enableSessionValidation', 'enableProactiveRefresh', 'validateOnVisibility']) {\n if (typeof policy[key] === 'boolean') result[key] = policy[key];\n }\n return result;\n}\n\nexport function applyRuntimePolicy(policy = {}) {\n config = { ...config, ...validateRuntimePolicy(policy) };\n return getConfig();\n}\n\nexport async function loadRuntimePolicy() {\n const { authBaseUrl, clientKey } = config;\n if (!authBaseUrl || !clientKey || typeof fetch !== 'function') return getConfig();\n try {\n const response = await fetch(`${authBaseUrl.replace(/\\/+$/, '')}/clients/${encodeURIComponent(clientKey)}/config`, {\n method: 'GET',\n credentials: 'include',\n headers: { Accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`runtime policy HTTP ${response.status}`);\n const payload = await response.json();\n return applyRuntimePolicy(payload.authentication_policy || {});\n } catch (error) {\n // Runtime config is an enhancement. Safe bounded defaults keep login\n // available during a control-plane outage.\n console.warn('[auth-client] Runtime policy unavailable; using safe defaults', error.message);\n return applyRuntimePolicy({});\n }\n}\n\nexport function setConfig(customConfig = {}) {\n if (!customConfig.clientKey || !customConfig.authBaseUrl) {\n throw new Error('Missing required config: clientKey and authBaseUrl are required');\n }\n\n config = {\n ...config,\n ...customConfig,\n redirectUri: customConfig.redirectUri || window.location.origin + '/callback',\n // ✅ Auto-detect router mode\n isRouter: customConfig.isRouter || customConfig.clientKey === 'account-ui'\n };\n\n // Keep token storage synchronized when consumers reconfigure the singleton.\n enableRefreshTokenPersistence(config.persistRefreshToken);\n if (config.persistRefreshToken) {\n console.log('📦 Refresh token persistence ENABLED (localStorage on HTTPS)');\n }\n\n console.log(`🔧 Auth Client Mode: ${config.isRouter ? 'ROUTER' : 'CLIENT'}`, {\n clientKey: config.clientKey,\n isRouter: config.isRouter,\n persistRefreshToken: config.persistRefreshToken\n });\n}\n\nexport function getConfig() {\n return { ...config };\n}\n\n// ✅ Helper function\nexport function isRouterMode() {\n return config.isRouter;\n}\n","import { getConfig } from './config.js';\nimport { getToken } from './token.js';\n\nconst STORAGE_KEY = 'auth_diagnostic_context';\nconst LOGIN_LOCK_KEY = 'auth_login_lock';\n\nconst randomId = () => {\n if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();\n return `auth-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n};\n\nconst fingerprint = async (value) => {\n if (!value || !globalThis.crypto?.subtle) return null;\n const bytes = new TextEncoder().encode(String(value));\n const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);\n return [...new Uint8Array(digest)].slice(0, 6)\n .map((byte) => byte.toString(16).padStart(2, '0')).join('');\n};\n\nexport function getDiagnosticContext() {\n try {\n const existing = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null');\n if (existing?.correlationId) return existing;\n } catch {}\n const context = { correlationId: randomId(), startedAt: Date.now() };\n try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(context)); } catch {}\n return context;\n}\n\nexport function resetDiagnosticContext() {\n const context = { correlationId: randomId(), startedAt: Date.now() };\n try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(context)); } catch {}\n return context;\n}\n\nexport function diagnosticHeaders() {\n const { correlationId } = getDiagnosticContext();\n return { 'X-Correlation-ID': correlationId, 'X-Request-ID': correlationId };\n}\n\nexport function acquireLoginLock(clientKey, redirectUri, ttlMs = 5000) {\n const now = Date.now();\n const signature = `${clientKey}|${redirectUri}`;\n try {\n const current = JSON.parse(sessionStorage.getItem(LOGIN_LOCK_KEY) || 'null');\n if (current?.signature === signature && now - current.createdAt < ttlMs) return false;\n sessionStorage.setItem(LOGIN_LOCK_KEY, JSON.stringify({ signature, createdAt: now }));\n } catch {}\n return true;\n}\n\nexport function clearLoginLock() {\n try { sessionStorage.removeItem(LOGIN_LOCK_KEY); } catch {}\n}\n\n// Send only failure/warning diagnostics to the auth service. Successful\n// refreshes and callbacks are already recorded server-side; browser failures\n// are the missing half needed to explain a PMS-only redirect. This is\n// best-effort and never blocks an authentication flow.\nasync function reportClientDiagnostic(safe) {\n if (typeof fetch !== 'function' || typeof getToken !== 'function') return;\n if (safe.outcome === 'SUCCESS' && !safe.event.startsWith('PROFILE_') && !safe.event.startsWith('SESSION_')) return;\n\n const { authBaseUrl } = getConfig();\n const token = getToken();\n if (!authBaseUrl || !token) return;\n\n try {\n await fetch(`${authBaseUrl.replace(/\\/+$/, '')}/client-telemetry`, {\n method: 'POST',\n credentials: 'include',\n keepalive: true,\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${token}`,\n 'X-Correlation-ID': safe.correlationId,\n 'X-Request-ID': safe.correlationId,\n },\n body: JSON.stringify(safe),\n });\n } catch {\n // Diagnostic reporting must never affect login, refresh, or logout.\n }\n}\n\nexport async function emitAuthDiagnostic(event, outcome, reasonCode, details = {}) {\n const context = getDiagnosticContext();\n const safe = {\n eventType: 'auth_client_diagnostic',\n event,\n outcome,\n reasonCode,\n correlationId: context.correlationId,\n elapsedMs: Date.now() - context.startedAt,\n clientKey: details.clientKey || null,\n status: details.status || null,\n stateFingerprint: await fingerprint(details.state),\n online: typeof navigator === 'undefined' ? null : navigator.onLine,\n };\n const method = outcome === 'FAILURE' ? 'error' : outcome === 'WARNING' ? 'warn' : 'info';\n console[method]('[auth-client]', safe);\n void reportClientDiagnostic(safe);\n return safe;\n}\n","// auth-client/core.js - MINIMAL WORKING VERSION\n\nimport {\n setToken,\n clearToken,\n getIdToken,\n setIdToken,\n clearIdToken,\n getToken,\n getRefreshToken,\n setRefreshToken,\n clearRefreshToken,\n getTimeUntilExpiry,\n} from './token.js';\nimport { getConfig, isRouterMode } from './config.js';\nimport {\n acquireLoginLock,\n clearLoginLock,\n diagnosticHeaders,\n emitAuthDiagnostic,\n getDiagnosticContext,\n resetDiagnosticContext,\n} from './diagnostics.js';\n\nlet callbackProcessed = false;\n\nexport function login(clientKeyArg, redirectUriArg, options = {}) {\n // ✅ Reset callback state when starting new login\n resetCallbackState();\n\n const {\n clientKey: defaultClientKey,\n authBaseUrl,\n redirectUri: defaultRedirectUri,\n accountUiUrl\n } = getConfig();\n\n const clientKey = clientKeyArg || defaultClientKey;\n const redirectUri = redirectUriArg || defaultRedirectUri;\n\n if (!clientKey || !redirectUri) {\n emitAuthDiagnostic('LOGIN_REJECTED', 'FAILURE', 'CLIENT_CONFIG_MISSING', { clientKey });\n throw new Error('Missing clientKey or redirectUri');\n }\n\n if (!acquireLoginLock(clientKey, redirectUri)) {\n emitAuthDiagnostic('LOGIN_DUPLICATE_SUPPRESSED', 'WARNING', 'LOGIN_ALREADY_IN_PROGRESS', { clientKey });\n return false;\n }\n resetDiagnosticContext();\n emitAuthDiagnostic('LOGIN_INITIATED', 'PENDING', 'NONE', { clientKey });\n\n sessionStorage.setItem('originalApp', clientKey);\n sessionStorage.setItem('returnUrl', redirectUri);\n\n if (isRouterMode()) {\n // Router mode: Direct backend authentication\n return routerLogin(clientKey, redirectUri, options);\n } else {\n // Client mode: Redirect to centralized login\n return clientLogin(clientKey, redirectUri, options);\n }\n}\n\n// ✅ Router mode: Direct backend call\nfunction routerLogin(clientKey, redirectUri, options = {}) {\n const { authBaseUrl } = getConfig();\n\n const params = new URLSearchParams();\n if (redirectUri) {\n params.append('redirect_uri', redirectUri);\n }\n if (options.switchAccount || options.switch_account) params.append('switch_account', 'true');\n params.append('correlation_id', getDiagnosticContext().correlationId);\n const query = params.toString();\n const backendLoginUrl = `${authBaseUrl}/login/${clientKey}${query ? `?${query}` : ''}`;\n\n window.location.href = backendLoginUrl;\n}\n\n// ✅ Client mode: Centralized login\nfunction clientLogin(clientKey, redirectUri, options = {}) {\n const { accountUiUrl } = getConfig();\n\n const params = new URLSearchParams({\n client: clientKey\n });\n if (redirectUri) {\n params.append('redirect_uri', redirectUri);\n }\n if (options.switchAccount || options.switch_account) params.append('switch_account', 'true');\n const centralizedLoginUrl = `${accountUiUrl}/login?${params.toString()}`;\n\n window.location.href = centralizedLoginUrl;\n}\n\nexport async function logout(options = {}) {\n resetCallbackState();\n\n const { clientKey, authBaseUrl, accountUiUrl } = getConfig();\n const scope = options.scope === 'client' ? 'client' : 'sso';\n const token = getToken();\n const idToken = getIdToken();\n const refreshToken = getRefreshToken();\n\n console.log('🚪 Smart Logout initiated', {\n mode: isRouterMode() ? 'ROUTER' : 'CLIENT',\n clientKey,\n scope,\n });\n emitAuthDiagnostic('LOGOUT_INITIATED', 'PENDING', 'NONE', { clientKey });\n\n clearToken();\n clearIdToken();\n clearRefreshToken();\n sessionStorage.removeItem('originalApp');\n sessionStorage.removeItem('returnUrl');\n\n // Every client — router or not — must hit the backend so it can:\n // 1. revoke the refresh token record, and\n // 2. hand back a Keycloak end-session URL so the IdP's SSO cookie is\n // actually killed. Without step 2 the browser stays signed in at\n // Keycloak and silently re-authenticates on the next login attempt.\n try {\n const response = await fetch(`${authBaseUrl}/logout/${clientKey}`, {\n method: 'POST',\n credentials: 'include',\n headers: {\n ...diagnosticHeaders(),\n 'Authorization': token ? `Bearer ${token}` : '',\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({ refreshToken, idToken, scope })\n });\n\n if (!response.ok) {\n throw new Error(`Logout failed: ${response.status}`);\n }\n\n const data = await response.json();\n console.log('✅ Logout response:', data);\n emitAuthDiagnostic('LOGOUT_COMPLETED', 'SUCCESS', 'NONE', {\n clientKey,\n status: response.status,\n });\n\n if (data?.keycloakLogoutUrl) {\n window.location.replace(data.keycloakLogoutUrl);\n return;\n }\n\n if (data?.logoutRedirectUrl) {\n window.location.replace(data.logoutRedirectUrl);\n return;\n }\n } catch (error) {\n console.warn('⚠️ Logout backend call failed:', error);\n emitAuthDiagnostic('LOGOUT_BACKEND_FAILED', 'FAILURE', 'LOGOUT_REQUEST_FAILED', {\n clientKey,\n });\n }\n\n // Fallback only if the backend call failed or returned no logout URL —\n // the Keycloak SSO session may still be alive in this case.\n const fallbackUrl = isRouterMode()\n ? new URL('/login', window.location.origin)\n : new URL('/login', accountUiUrl);\n fallbackUrl.searchParams.set('logged_out', 'true');\n fallbackUrl.searchParams.set('client', clientKey);\n fallbackUrl.searchParams.set('scope', scope);\n window.location.replace(fallbackUrl.toString());\n}\n\nexport function handleCallback() {\n const params = new URLSearchParams(window.location.search);\n const accessToken = params.get('access_token');\n const idToken = params.get('id_token');\n const error = params.get('error');\n\n console.log('🔄 Callback handling:', {\n hasAccessToken: !!accessToken,\n hasIdToken: !!idToken,\n error\n });\n\n // ✅ Prevent duplicate callback processing\n if (callbackProcessed) {\n const existingToken = getToken();\n if (existingToken) {\n console.log('✅ Callback already processed, returning existing token');\n return existingToken;\n }\n // Reset if no token found (might be a retry)\n callbackProcessed = false;\n }\n\n callbackProcessed = true;\n clearLoginLock();\n sessionStorage.removeItem('originalApp');\n sessionStorage.removeItem('returnUrl');\n\n if (error) {\n const errorDescription = params.get('error_description') || error;\n const authError = new Error(`Authentication failed: ${errorDescription}`);\n authError.code = error;\n authError.correlationId = getDiagnosticContext().correlationId;\n emitAuthDiagnostic('CALLBACK_REJECTED', 'FAILURE', error.toUpperCase(), {\n clientKey: getConfig().clientKey,\n state: params.get('state'),\n });\n throw authError;\n }\n\n if (accessToken) {\n setToken(accessToken);\n if (idToken) setIdToken(idToken);\n\n // Refresh tokens must never be accepted from a callback URL. The auth\n // service transports them through the httpOnly client cookie; accepting a\n // URL value would put a credential in browser history, referrers, and\n // diagnostics. Keep deleting the legacy parameter below for clean URLs.\n const refreshTokenInUrl = params.get('refresh_token');\n if (refreshTokenInUrl) {\n emitAuthDiagnostic('CALLBACK_REFRESH_TOKEN_IGNORED', 'WARNING', 'REFRESH_TOKEN_IN_URL', {\n clientKey: getConfig().clientKey,\n });\n }\n\n const url = new URL(window.location);\n url.searchParams.delete('access_token');\n url.searchParams.delete('id_token');\n url.searchParams.delete('refresh_token');\n url.searchParams.delete('state');\n url.searchParams.delete('error');\n url.searchParams.delete('error_description');\n window.history.replaceState({}, '', url);\n\n console.log('✅ Callback processed successfully, token stored');\n emitAuthDiagnostic('CALLBACK_COMPLETED', 'SUCCESS', 'NONE', {\n clientKey: getConfig().clientKey,\n state: params.get('state'),\n });\n return accessToken;\n }\n\n emitAuthDiagnostic('CALLBACK_REJECTED', 'FAILURE', 'ACCESS_TOKEN_MISSING', {\n clientKey: getConfig().clientKey,\n state: params.get('state'),\n });\n const missingTokenError = new Error('No access token found in callback URL');\n missingTokenError.code = 'ACCESS_TOKEN_MISSING';\n throw missingTokenError;\n}\n\nexport function resetCallbackState() {\n callbackProcessed = false;\n}\n\n// ✅ Add refresh lock to prevent concurrent refresh calls\nlet refreshInProgress = false;\nlet refreshPromise = null;\n\n// Coordinate refreshes across tabs of the same application. The in-memory\n// promise above protects one tab; navigator.locks protects multiple tabs on\n// the same origin. Auth-service still remains the final authority and its\n// replay grace handles browsers that do not implement Web Locks.\nasync function withCrossTabRefreshLock(clientKey, tokenBeforeRefresh, refreshRequest) {\n const lockName = `auth-refresh-${clientKey}`;\n const run = async () => {\n // Another tab may have completed the rotation while this tab was waiting\n // for the lock. Reuse its access token instead of submitting the consumed\n // refresh cookie a second time.\n try {\n const persistedToken = localStorage.getItem('authToken');\n if (tokenBeforeRefresh && persistedToken && persistedToken !== tokenBeforeRefresh) {\n setToken(persistedToken);\n emitAuthDiagnostic('TOKEN_REFRESH_REUSED_CROSS_TAB', 'SUCCESS', 'CROSS_TAB_ROTATION', {\n clientKey,\n });\n return persistedToken;\n }\n } catch {\n // Storage can be unavailable in privacy-restricted browser contexts.\n }\n\n return refreshRequest();\n };\n\n if (typeof navigator !== 'undefined' && navigator.locks?.request) {\n return navigator.locks.request(lockName, run);\n }\n\n return run();\n}\n\nexport async function refreshToken() {\n const { clientKey, authBaseUrl } = getConfig();\n\n // ✅ Prevent concurrent refresh calls\n if (refreshInProgress && refreshPromise) {\n console.log('🔄 Token refresh already in progress, waiting...');\n return refreshPromise;\n }\n\n refreshInProgress = true;\n refreshPromise = (async () => {\n const tokenBeforeRefresh = getToken();\n const refreshRequest = async () => {\n try {\n // Get stored refresh token (for HTTP development)\n const storedRefreshToken = getRefreshToken();\n\n console.log('🔄 Refreshing token:', {\n clientKey,\n mode: isRouterMode() ? 'ROUTER' : 'CLIENT',\n hasStoredRefreshToken: !!storedRefreshToken\n });\n\n // Build request options - send refresh token in body and header for HTTP dev\n const requestOptions = {\n method: 'POST',\n credentials: 'include', // ✅ Include httpOnly cookies (for HTTPS)\n headers: {\n ...diagnosticHeaders(),\n 'Content-Type': 'application/json'\n }\n };\n\n // For HTTP development, send refresh token in body ONLY (header removed per user request)\n if (storedRefreshToken) {\n // requestOptions.headers['X-Refresh-Token'] = storedRefreshToken;\n requestOptions.body = JSON.stringify({ refreshToken: storedRefreshToken });\n console.log('📦 Sending refresh token in body only (Header skipped) v3.0.2');\n }\n\n const response = await fetch(`${authBaseUrl}/refresh/${clientKey}`, requestOptions);\n\n if (!response.ok) {\n const errorText = await response.text();\n let serverCode = null;\n try { serverCode = JSON.parse(errorText)?.error || JSON.parse(errorText)?.code; } catch {}\n emitAuthDiagnostic('TOKEN_REFRESH_REJECTED', 'FAILURE', serverCode || `HTTP_${response.status}`, {\n clientKey,\n status: response.status,\n });\n const refreshError = new Error(`Refresh failed: ${response.status}`);\n refreshError.code = serverCode || `HTTP_${response.status}`;\n refreshError.status = response.status;\n throw refreshError;\n }\n\n const data = await response.json();\n const { access_token, refresh_token: new_refresh_token } = data;\n\n if (!access_token) {\n throw new Error('No access token in refresh response');\n }\n\n // ✅ This will trigger token listeners\n setToken(access_token);\n\n // ✅ Store new refresh token if provided (token rotation)\n if (new_refresh_token) {\n setRefreshToken(new_refresh_token);\n console.log('🔄 New refresh token stored from rotation');\n }\n\n console.log('✅ Token refresh successful, listeners notified');\n emitAuthDiagnostic('TOKEN_REFRESH_COMPLETED', 'SUCCESS', 'NONE', { clientKey });\n return access_token;\n } catch (err) {\n console.error('❌ Token refresh error:', err);\n // Only clear tokens on definitive auth failure (server explicitly rejected).\n // Network errors / timeouts should NOT clear tokens — the session may still\n // be valid and the next attempt may succeed.\n const isAuthRejection = err.message?.includes('401') ||\n err.message?.includes('403') ||\n err.message?.includes('invalid_grant') ||\n err.message?.includes('Refresh failed: 4');\n if (isAuthRejection) {\n clearToken();\n clearRefreshToken();\n }\n throw err;\n }\n };\n\n return withCrossTabRefreshLock(clientKey, tokenBeforeRefresh, refreshRequest);\n })().finally(() => {\n refreshInProgress = false;\n refreshPromise = null;\n });\n\n return refreshPromise;\n}\n\nexport async function validateCurrentSession() {\n try {\n const { authBaseUrl } = getConfig();\n const token = getToken();\n\n if (!token || !authBaseUrl) {\n return false;\n }\n\n const response = await fetch(`${authBaseUrl}/account/validate-session`, {\n method: 'GET',\n headers: {\n ...diagnosticHeaders(),\n 'Authorization': `Bearer ${token}`,\n 'Content-Type': 'application/json'\n },\n credentials: 'include'\n });\n\n if (!response.ok) {\n if (response.status === 401) {\n return false;\n }\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const json = await response.json();\n // Support both wrapped { success, data: { valid } } and flat { valid } formats\n const payload = json.data || json;\n return payload.valid === true;\n } catch (error) {\n console.warn('Session validation failed:', error.message);\n if (error.message.includes('401')) {\n return false;\n }\n throw error;\n }\n}\n\n// ========== SESSION SECURITY: PROACTIVE REFRESH & VALIDATION ==========\n// These functions ensure that:\n// 1. Tokens are refreshed before they expire (proactive refresh)\n// 2. Sessions deleted in Keycloak Admin UI are detected quickly (periodic validation)\n\nlet proactiveRefreshTimer = null;\nlet sessionValidationTimer = null;\nlet visibilityHandler = null;\nlet sessionInvalidCallbacks = new Set();\n\n// Register a callback to be called when session is invalidated\nexport function onSessionInvalid(callback) {\n if (typeof callback === 'function') {\n sessionInvalidCallbacks.add(callback);\n }\n return () => sessionInvalidCallbacks.delete(callback);\n}\n\n// Notify all registered callbacks that session is invalid\nfunction notifySessionInvalid(reason = 'session_deleted') {\n console.log('🚨 Session invalidated:', reason);\n sessionInvalidCallbacks.forEach(callback => {\n try {\n callback(reason);\n } catch (err) {\n console.error('Session invalid callback error:', err);\n }\n });\n}\n\n// ========== PROACTIVE TOKEN REFRESH ==========\n// Schedules token refresh before expiry to ensure seamless UX\n\nexport function startProactiveRefresh() {\n const { enableProactiveRefresh, tokenRefreshBuffer } = getConfig();\n\n if (!enableProactiveRefresh) {\n console.log('⏸️ Proactive refresh disabled by config');\n return null;\n }\n\n // Clear any existing timer\n stopProactiveRefresh();\n\n const token = getToken();\n if (!token) {\n console.log('⏸️ No token, skipping proactive refresh setup');\n return null;\n }\n\n const timeUntilExpiry = getTimeUntilExpiry(token);\n\n if (timeUntilExpiry <= 0) {\n console.log('⚠️ Token already expired, attempting immediate refresh');\n refreshToken().catch(err => {\n console.error('❌ Immediate refresh failed:', err);\n notifySessionInvalid('token_expired');\n });\n return null;\n }\n\n // Schedule refresh for (expiry - buffer) seconds from now\n const refreshIn = Math.max(0, (timeUntilExpiry - tokenRefreshBuffer)) * 1000;\n\n console.log(`🔄 Scheduling proactive refresh in ${Math.round(refreshIn / 1000)}s (token expires in ${timeUntilExpiry}s)`);\n\n proactiveRefreshTimer = setTimeout(async () => {\n try {\n console.log('🔄 Proactive token refresh triggered');\n await refreshToken();\n console.log('✅ Proactive refresh successful, scheduling next refresh');\n // Schedule next refresh after successful refresh\n startProactiveRefresh();\n } catch (err) {\n console.error('❌ Proactive refresh failed:', err);\n\n // Check if this is a permanent failure (token revoked, invalid, etc.)\n const errorMessage = err.message?.toLowerCase() || '';\n const isPermanentFailure =\n errorMessage.includes('401') ||\n errorMessage.includes('revoked') ||\n errorMessage.includes('invalid') ||\n errorMessage.includes('expired') ||\n errorMessage.includes('unauthorized');\n\n if (isPermanentFailure) {\n console.log('🚨 Token permanently invalid, triggering session expiry');\n notifySessionInvalid('refresh_token_revoked');\n } else {\n // Temporary failure (network issue), try again in 30 seconds\n proactiveRefreshTimer = setTimeout(() => startProactiveRefresh(), 30000);\n }\n }\n }, refreshIn);\n\n return proactiveRefreshTimer;\n}\n\nexport function stopProactiveRefresh() {\n if (proactiveRefreshTimer) {\n clearTimeout(proactiveRefreshTimer);\n proactiveRefreshTimer = null;\n console.log('⏹️ Proactive refresh stopped');\n }\n}\n\n// ========== PERIODIC SESSION VALIDATION ==========\n// Validates with server that session still exists in Keycloak\n// Catches session deletions from Keycloak Admin UI\n\nexport function startSessionMonitor(onInvalid) {\n const { enableSessionValidation, sessionValidationInterval, validateOnVisibility } = getConfig();\n\n if (!enableSessionValidation) {\n console.log('⏸️ Session validation disabled by config');\n return null;\n }\n\n // Register callback if provided\n if (onInvalid && typeof onInvalid === 'function') {\n sessionInvalidCallbacks.add(onInvalid);\n }\n\n // Clear any existing timer\n stopSessionMonitor();\n\n const token = getToken();\n if (!token) {\n console.log('⏸️ No token, skipping session monitor setup');\n return null;\n }\n\n console.log(`👁️ Starting session monitor (interval: ${sessionValidationInterval / 1000}s)`);\n\n // Track when the tab was last hidden — used to decide if a full\n // server-side validation is warranted after the tab becomes visible.\n let hiddenAt = null;\n\n // ── Periodic validation (catches admin-deleted sessions) ──\n sessionValidationTimer = setInterval(async () => {\n try {\n const currentToken = getToken();\n if (!currentToken) {\n console.log('⏸️ No token, stopping session validation');\n stopSessionMonitor();\n return;\n }\n\n // If token is expired, refresh first so the validation call succeeds\n const ttl = getTimeUntilExpiry(currentToken);\n if (ttl <= 0) {\n console.log('Token expired before periodic check - refreshing');\n try {\n await refreshToken();\n } catch (refreshErr) {\n console.log('Periodic refresh failed - notifying consumer');\n notifySessionInvalid('session_expired');\n return;\n }\n }\n\n console.log('Validating session...');\n const isValid = await validateCurrentSession();\n\n if (!isValid) {\n console.log('❌ Session no longer valid on server');\n notifySessionInvalid('session_deleted');\n } else {\n console.log('✅ Session still valid');\n }\n } catch (error) {\n console.warn('⚠️ Session validation check failed:', error.message);\n // Don't invalidate on network errors - wait for next check\n }\n }, sessionValidationInterval);\n\n // ── Visibility-based validation (smart, enterprise-grade) ──\n if (validateOnVisibility && typeof document !== 'undefined') {\n visibilityHandler = async () => {\n // ── Tab hidden: record timestamp ──\n if (document.visibilityState === 'hidden') {\n hiddenAt = Date.now();\n return;\n }\n\n // ── Tab visible: decide what to do ──\n if (document.visibilityState === 'visible') {\n const currentToken = getToken();\n if (!currentToken) return;\n\n const ttl = getTimeUntilExpiry(currentToken);\n const hiddenDuration = hiddenAt ? Date.now() - hiddenAt : 0;\n hiddenAt = null;\n\n // ── Case 1: Token still valid & tab was hidden briefly ──\n // No network call needed — everything is fine.\n if (ttl > 0 && hiddenDuration < sessionValidationInterval) {\n console.log(`👁️ Tab visible — token valid (${Math.round(ttl)}s left), hidden for ${Math.round(hiddenDuration / 1000)}s — skipping validation`);\n return;\n }\n\n // ── Case 2: Token still valid BUT tab was hidden longer than validation interval ──\n // Validate with server to catch admin-deleted sessions.\n if (ttl > 0 && hiddenDuration >= sessionValidationInterval) {\n console.log(`👁️ Tab visible — token valid but hidden for ${Math.round(hiddenDuration / 1000)}s — server-validating`);\n try {\n const isValid = await validateCurrentSession();\n if (isValid) {\n console.log('✅ Session confirmed valid on server');\n return;\n }\n // Server says invalid despite valid token — admin deleted session\n console.log('❌ Session deleted by admin while tab was hidden');\n stopSessionMonitor();\n stopProactiveRefresh();\n clearToken();\n clearRefreshToken();\n notifySessionInvalid('session_deleted_while_hidden');\n return;\n } catch (error) {\n // Network error — give benefit of the doubt, token is valid\n console.warn('⚠️ Server validation failed (network), token still valid — continuing');\n return;\n }\n }\n\n // ── Case 3: Token expired (browser throttled the refresh timer) ──\n // Try silent refresh — this is the most common case.\n console.log('⚠️ Token expired while tab was hidden — attempting silent refresh');\n try {\n await refreshToken();\n console.log('✅ Token silently refreshed — session restored');\n\n // If hidden for a long time, also verify the session on server\n if (hiddenDuration >= sessionValidationInterval) {\n const isValid = await validateCurrentSession();\n if (!isValid) {\n console.log('❌ Token refreshed but session deleted on server');\n stopSessionMonitor();\n stopProactiveRefresh();\n clearToken();\n clearRefreshToken();\n notifySessionInvalid('session_deleted_while_hidden');\n }\n }\n return;\n } catch (refreshErr) {\n console.log('❌ Silent refresh failed — session genuinely expired:', refreshErr.message);\n }\n\n // Both refresh AND validation failed — session truly dead\n stopSessionMonitor();\n stopProactiveRefresh();\n clearToken();\n clearRefreshToken();\n notifySessionInvalid('session_deleted_while_hidden');\n }\n };\n document.addEventListener('visibilitychange', visibilityHandler);\n }\n\n return sessionValidationTimer;\n}\n\nexport function stopSessionMonitor() {\n if (sessionValidationTimer) {\n clearInterval(sessionValidationTimer);\n sessionValidationTimer = null;\n console.log('⏹️ Session monitor stopped');\n }\n\n if (visibilityHandler && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', visibilityHandler);\n visibilityHandler = null;\n }\n}\n\n// ========== COMBINED SESSION SECURITY ==========\n// Start both proactive refresh and session monitoring\n\nexport function startSessionSecurity(onSessionInvalidCallback) {\n console.log('🔐 Starting session security (proactive refresh + session monitoring)');\n\n startProactiveRefresh();\n startSessionMonitor(onSessionInvalidCallback);\n\n return {\n stopAll: () => {\n stopProactiveRefresh();\n stopSessionMonitor();\n }\n };\n}\n\nexport function stopSessionSecurity() {\n stopProactiveRefresh();\n stopSessionMonitor();\n sessionInvalidCallbacks.clear();\n console.log('🔐 Session security stopped');\n}\n"],"mappings":"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","names":["config","refreshPromise"]}
package/dist/index.cjs CHANGED
@@ -52,8 +52,10 @@ module.exports = __toCommonJS(index_exports);
52
52
  // token.js
53
53
  var import_jwt_decode = require("jwt-decode");
54
54
  var accessToken = null;
55
+ var idToken = null;
55
56
  var listeners = /* @__PURE__ */ new Set();
56
57
  var REFRESH_COOKIE = "account_refresh_token";
58
+ var ID_TOKEN_KEY = "auth_id_token";
57
59
  var COOKIE_MAX_AGE = 7 * 24 * 60 * 60;
58
60
  function secureAttribute() {
59
61
  var _a;
@@ -132,12 +134,14 @@ function getToken() {
132
134
  function clearToken() {
133
135
  if (!accessToken) {
134
136
  writeAccessToken(null);
137
+ clearIdToken();
135
138
  clearRefreshToken();
136
139
  return;
137
140
  }
138
141
  const previousToken = accessToken;
139
142
  accessToken = null;
140
143
  writeAccessToken(null);
144
+ clearIdToken();
141
145
  clearRefreshToken();
142
146
  listeners.forEach((listener) => {
143
147
  try {
@@ -147,6 +151,30 @@ function clearToken() {
147
151
  }
148
152
  });
149
153
  }
154
+ function setIdToken(token) {
155
+ idToken = token || null;
156
+ try {
157
+ if (idToken) sessionStorage.setItem(ID_TOKEN_KEY, idToken);
158
+ else sessionStorage.removeItem(ID_TOKEN_KEY);
159
+ } catch {
160
+ }
161
+ }
162
+ function getIdToken() {
163
+ if (idToken) return idToken;
164
+ try {
165
+ idToken = sessionStorage.getItem(ID_TOKEN_KEY) || null;
166
+ } catch {
167
+ idToken = null;
168
+ }
169
+ return idToken;
170
+ }
171
+ function clearIdToken() {
172
+ idToken = null;
173
+ try {
174
+ sessionStorage.removeItem(ID_TOKEN_KEY);
175
+ } catch {
176
+ }
177
+ }
150
178
  var REFRESH_TOKEN_KEY = "auth_refresh_token";
151
179
  var _persistRefreshToken = false;
152
180
  function enableRefreshTokenPersistence(enabled) {
@@ -391,6 +419,28 @@ function clearLoginLock() {
391
419
  } catch {
392
420
  }
393
421
  }
422
+ async function reportClientDiagnostic(safe) {
423
+ if (typeof fetch !== "function" || typeof getToken !== "function") return;
424
+ if (safe.outcome === "SUCCESS" && !safe.event.startsWith("PROFILE_") && !safe.event.startsWith("SESSION_")) return;
425
+ const { authBaseUrl } = getConfig();
426
+ const token = getToken();
427
+ if (!authBaseUrl || !token) return;
428
+ try {
429
+ await fetch(`${authBaseUrl.replace(/\/+$/, "")}/client-telemetry`, {
430
+ method: "POST",
431
+ credentials: "include",
432
+ keepalive: true,
433
+ headers: {
434
+ "Content-Type": "application/json",
435
+ Authorization: `Bearer ${token}`,
436
+ "X-Correlation-ID": safe.correlationId,
437
+ "X-Request-ID": safe.correlationId
438
+ },
439
+ body: JSON.stringify(safe)
440
+ });
441
+ } catch {
442
+ }
443
+ }
394
444
  async function emitAuthDiagnostic(event, outcome, reasonCode, details = {}) {
395
445
  const context = getDiagnosticContext();
396
446
  const safe = {
@@ -407,6 +457,7 @@ async function emitAuthDiagnostic(event, outcome, reasonCode, details = {}) {
407
457
  };
408
458
  const method = outcome === "FAILURE" ? "error" : outcome === "WARNING" ? "warn" : "info";
409
459
  console[method]("[auth-client]", safe);
460
+ void reportClientDiagnostic(safe);
410
461
  return safe;
411
462
  }
412
463
 
@@ -469,6 +520,7 @@ async function logout(options = {}) {
469
520
  const { clientKey, authBaseUrl, accountUiUrl } = getConfig();
470
521
  const scope = options.scope === "client" ? "client" : "sso";
471
522
  const token = getToken();
523
+ const idToken2 = getIdToken();
472
524
  const refreshToken2 = getRefreshToken();
473
525
  console.log("\u{1F6AA} Smart Logout initiated", {
474
526
  mode: isRouterMode() ? "ROUTER" : "CLIENT",
@@ -477,6 +529,7 @@ async function logout(options = {}) {
477
529
  });
478
530
  emitAuthDiagnostic("LOGOUT_INITIATED", "PENDING", "NONE", { clientKey });
479
531
  clearToken();
532
+ clearIdToken();
480
533
  clearRefreshToken();
481
534
  sessionStorage.removeItem("originalApp");
482
535
  sessionStorage.removeItem("returnUrl");
@@ -489,7 +542,7 @@ async function logout(options = {}) {
489
542
  "Authorization": token ? `Bearer ${token}` : "",
490
543
  "Content-Type": "application/json"
491
544
  },
492
- body: JSON.stringify({ refreshToken: refreshToken2, scope })
545
+ body: JSON.stringify({ refreshToken: refreshToken2, idToken: idToken2, scope })
493
546
  });
494
547
  if (!response.ok) {
495
548
  throw new Error(`Logout failed: ${response.status}`);
@@ -523,9 +576,11 @@ async function logout(options = {}) {
523
576
  function handleCallback() {
524
577
  const params = new URLSearchParams(window.location.search);
525
578
  const accessToken2 = params.get("access_token");
579
+ const idToken2 = params.get("id_token");
526
580
  const error = params.get("error");
527
581
  console.log("\u{1F504} Callback handling:", {
528
582
  hasAccessToken: !!accessToken2,
583
+ hasIdToken: !!idToken2,
529
584
  error
530
585
  });
531
586
  if (callbackProcessed) {
@@ -553,6 +608,7 @@ function handleCallback() {
553
608
  }
554
609
  if (accessToken2) {
555
610
  setToken(accessToken2);
611
+ if (idToken2) setIdToken(idToken2);
556
612
  const refreshTokenInUrl = params.get("refresh_token");
557
613
  if (refreshTokenInUrl) {
558
614
  emitAuthDiagnostic("CALLBACK_REFRESH_TOKEN_IGNORED", "WARNING", "REFRESH_TOKEN_IN_URL", {
@@ -561,6 +617,7 @@ function handleCallback() {
561
617
  }
562
618
  const url = new URL(window.location);
563
619
  url.searchParams.delete("access_token");
620
+ url.searchParams.delete("id_token");
564
621
  url.searchParams.delete("refresh_token");
565
622
  url.searchParams.delete("state");
566
623
  url.searchParams.delete("error");
@@ -1236,23 +1293,72 @@ var preferences = {
1236
1293
  // react/AuthProvider.jsx
1237
1294
  var import_react = __toESM(require("react"), 1);
1238
1295
  var AuthContext = (0, import_react.createContext)();
1239
- function AuthProvider({ children, onSessionExpired }) {
1296
+ function AuthProvider({ children, onSessionExpired, manageSessionSecurity = true }) {
1240
1297
  const [token, setTokenState] = (0, import_react.useState)(getToken());
1241
1298
  const [user, setUser] = (0, import_react.useState)(null);
1242
1299
  const [loading, setLoading] = (0, import_react.useState)(!!token);
1243
1300
  const [sessionValid, setSessionValid] = (0, import_react.useState)(true);
1244
1301
  const sessionSecurityRef = (0, import_react.useRef)(null);
1302
+ const onSessionExpiredRef = (0, import_react.useRef)(onSessionExpired);
1303
+ const recoveryInFlightRef = (0, import_react.useRef)(null);
1304
+ const profileRecoveryTokensRef = (0, import_react.useRef)(/* @__PURE__ */ new Set());
1305
+ (0, import_react.useEffect)(() => {
1306
+ onSessionExpiredRef.current = onSessionExpired;
1307
+ }, [onSessionExpired]);
1308
+ (0, import_react.useEffect)(() => {
1309
+ const unsubscribe = addTokenListener((nextToken, previousToken) => {
1310
+ if (nextToken === previousToken) return;
1311
+ setTokenState(nextToken);
1312
+ if (nextToken) {
1313
+ setSessionValid(true);
1314
+ setLoading(true);
1315
+ } else {
1316
+ setSessionValid(false);
1317
+ setUser(null);
1318
+ setLoading(false);
1319
+ }
1320
+ });
1321
+ return unsubscribe;
1322
+ }, []);
1323
+ const invalidateLocalSession = () => {
1324
+ clearToken();
1325
+ setTokenState(null);
1326
+ setUser(null);
1327
+ setSessionValid(false);
1328
+ setLoading(false);
1329
+ };
1245
1330
  const handleSessionInvalid = (reason) => {
1246
1331
  console.log("\u{1F6A8} AuthProvider: Session invalidated -", reason);
1247
- setSessionValid(false);
1248
- setUser(null);
1249
- setTokenState(null);
1250
- if (onSessionExpired && typeof onSessionExpired === "function") {
1251
- onSessionExpired(reason);
1252
- }
1332
+ if (recoveryInFlightRef.current) return recoveryInFlightRef.current;
1333
+ const recover = async () => {
1334
+ const callback = onSessionExpiredRef.current;
1335
+ if (typeof callback === "function") {
1336
+ try {
1337
+ await callback(reason);
1338
+ const recoveredToken = getToken();
1339
+ if (recoveredToken) {
1340
+ setTokenState(recoveredToken);
1341
+ setSessionValid(true);
1342
+ setLoading(true);
1343
+ return true;
1344
+ }
1345
+ } catch (error) {
1346
+ await emitAuthDiagnostic("SESSION_RECOVERY_FAILED", "FAILURE", reason, {
1347
+ status: (error == null ? void 0 : error.status) || null,
1348
+ errorCode: (error == null ? void 0 : error.code) || null
1349
+ });
1350
+ }
1351
+ }
1352
+ invalidateLocalSession();
1353
+ return false;
1354
+ };
1355
+ recoveryInFlightRef.current = recover().finally(() => {
1356
+ recoveryInFlightRef.current = null;
1357
+ });
1358
+ return recoveryInFlightRef.current;
1253
1359
  };
1254
1360
  (0, import_react.useEffect)(() => {
1255
- if (token && !sessionSecurityRef.current) {
1361
+ if (manageSessionSecurity && token && !sessionSecurityRef.current) {
1256
1362
  console.log("\u{1F510} AuthProvider: Starting session security");
1257
1363
  const unsubscribe = onSessionInvalid(handleSessionInvalid);
1258
1364
  sessionSecurityRef.current = startSessionSecurity(handleSessionInvalid);
@@ -1264,11 +1370,11 @@ function AuthProvider({ children, onSessionExpired }) {
1264
1370
  }
1265
1371
  };
1266
1372
  }
1267
- if (!token && sessionSecurityRef.current) {
1373
+ if ((!manageSessionSecurity || !token) && sessionSecurityRef.current) {
1268
1374
  sessionSecurityRef.current.stopAll();
1269
1375
  sessionSecurityRef.current = null;
1270
1376
  }
1271
- }, [token]);
1377
+ }, [manageSessionSecurity, token]);
1272
1378
  (0, import_react.useEffect)(() => {
1273
1379
  console.log("\u{1F50D} AuthProvider useEffect triggered:", {
1274
1380
  hasToken: !!token,
@@ -1289,26 +1395,79 @@ function AuthProvider({ children, onSessionExpired }) {
1289
1395
  authBaseUrl,
1290
1396
  tokenPreview: token.slice(0, 50) + "..."
1291
1397
  });
1292
- fetch(`${authBaseUrl}/account/profile`, {
1293
- headers: { Authorization: `Bearer ${token}` },
1294
- credentials: "include"
1295
- }).then((res) => {
1296
- console.log("\u{1F4E5} Profile response status:", res.status);
1297
- if (!res.ok) throw new Error("Failed to fetch user");
1298
- return res.json();
1299
- }).then((responseBody) => {
1300
- const userData = (responseBody == null ? void 0 : responseBody.data) ?? responseBody;
1301
- console.log("\u2705 Profile fetched successfully:", userData.email);
1302
- setUser(userData);
1303
- setSessionValid(true);
1304
- setLoading(false);
1305
- }).catch((err) => {
1306
- console.error("\u274C Fetch user error:", err);
1307
- clearToken();
1308
- setTokenState(null);
1309
- setUser(null);
1310
- setLoading(false);
1311
- });
1398
+ let cancelled = false;
1399
+ const fetchProfile = async (accessToken2) => {
1400
+ const response = await fetch(`${authBaseUrl}/account/profile`, {
1401
+ headers: { Authorization: `Bearer ${accessToken2}` },
1402
+ credentials: "include"
1403
+ });
1404
+ console.log("\u{1F4E5} Profile response status:", response.status);
1405
+ if (!response.ok) {
1406
+ const error = new Error(`Profile request failed: ${response.status}`);
1407
+ error.status = response.status;
1408
+ throw error;
1409
+ }
1410
+ return response.json();
1411
+ };
1412
+ const loadProfile = async () => {
1413
+ try {
1414
+ const responseBody = await fetchProfile(token);
1415
+ if (cancelled) return;
1416
+ const userData = (responseBody == null ? void 0 : responseBody.data) ?? responseBody;
1417
+ console.log("\u2705 Profile fetched successfully:", userData.email);
1418
+ setUser(userData);
1419
+ setSessionValid(true);
1420
+ setLoading(false);
1421
+ } catch (error) {
1422
+ if (cancelled) return;
1423
+ const status = Number((error == null ? void 0 : error.status) || 0);
1424
+ const isUnauthorized = status === 401;
1425
+ const callback = onSessionExpiredRef.current;
1426
+ await emitAuthDiagnostic(
1427
+ "PROFILE_REQUEST_FAILED",
1428
+ isUnauthorized ? "FAILURE" : "WARNING",
1429
+ isUnauthorized ? "PROFILE_UNAUTHORIZED" : status ? `PROFILE_HTTP_${status}` : "PROFILE_NETWORK_ERROR",
1430
+ { status, errorName: error == null ? void 0 : error.name, errorCode: error == null ? void 0 : error.code }
1431
+ );
1432
+ if (!isUnauthorized) {
1433
+ console.warn("\u26A0\uFE0F Profile unavailable; retaining the current auth session", error);
1434
+ setLoading(false);
1435
+ return;
1436
+ }
1437
+ if (typeof callback === "function" && !profileRecoveryTokensRef.current.has(token)) {
1438
+ profileRecoveryTokensRef.current.add(token);
1439
+ try {
1440
+ await callback("profile_unauthorized");
1441
+ const recoveredToken = getToken();
1442
+ if (recoveredToken && recoveredToken !== token) {
1443
+ setTokenState(recoveredToken);
1444
+ setSessionValid(true);
1445
+ setLoading(true);
1446
+ return;
1447
+ }
1448
+ if (recoveredToken) {
1449
+ const responseBody = await fetchProfile(recoveredToken);
1450
+ if (cancelled) return;
1451
+ const userData = (responseBody == null ? void 0 : responseBody.data) ?? responseBody;
1452
+ setUser(userData);
1453
+ setSessionValid(true);
1454
+ setLoading(false);
1455
+ return;
1456
+ }
1457
+ } catch (recoveryError) {
1458
+ await emitAuthDiagnostic("PROFILE_RECOVERY_FAILED", "FAILURE", "PROFILE_REFRESH_FAILED", {
1459
+ status: (recoveryError == null ? void 0 : recoveryError.status) || null,
1460
+ errorCode: (recoveryError == null ? void 0 : recoveryError.code) || null
1461
+ });
1462
+ }
1463
+ }
1464
+ invalidateLocalSession();
1465
+ }
1466
+ };
1467
+ void loadProfile();
1468
+ return () => {
1469
+ cancelled = true;
1470
+ };
1312
1471
  }, [token]);
1313
1472
  const login2 = (clientKey, redirectUri, state) => {
1314
1473
  login(clientKey, redirectUri, state);
@@ -1337,6 +1496,7 @@ function AuthProvider({ children, onSessionExpired }) {
1337
1496
  setToken(newToken);
1338
1497
  setTokenState(newToken);
1339
1498
  setSessionValid(true);
1499
+ if (newToken) setLoading(true);
1340
1500
  },
1341
1501
  clearToken: () => {
1342
1502
  stopSessionSecurity();
@@ -1521,6 +1681,9 @@ var auth = {
1521
1681
  getToken,
1522
1682
  setToken,
1523
1683
  clearToken,
1684
+ setIdToken,
1685
+ getIdToken,
1686
+ clearIdToken,
1524
1687
  setRefreshToken,
1525
1688
  // ✅ Refresh token for HTTP dev
1526
1689
  getRefreshToken,