import { getAccessTokenAction, refreshAccessTokenAction } from '../actions.js'; import type { RefreshAccessTokenActionResult } from '../actions.js'; import { decodeJwt } from '../jwt.js'; function unwrapRefreshResult(result: RefreshAccessTokenActionResult): string | undefined { if (result.error) { throw new Error(result.error); } return result.accessToken; } interface TokenState { token: string | undefined; loading: boolean; error: Error | null; } const TOKEN_EXPIRY_BUFFER_SECONDS = 60; const MIN_REFRESH_DELAY_SECONDS = 15; const MAX_REFRESH_DELAY_SECONDS = 24 * 60 * 60; const RETRY_DELAY_SECONDS = 300; // 5 minutes for retry on error const jwtCookieName = 'workos-access-token'; export class TokenStore { private state: TokenState; private serverSnapshot: TokenState; constructor() { // Initialize state with token from cookie if available const initialToken = typeof window !== 'undefined' ? this.getInitialTokenFromCookie() : undefined; this.state = { token: initialToken, loading: false, error: null, }; // Server snapshot should match initial state for hydration this.serverSnapshot = { token: undefined, loading: false, error: null, }; if (initialToken) { // Mark as consumed if we found a token. Refresh scheduling is deferred // to the first subscriber: the store is constructed at module-evaluation // time, before hydration, and a refresh timer that fires before Next.js // initializes its router action queue makes the Server Action throw // inside React's startTransition without ever settling the action // promise — wedging refreshPromise for the rest of the page lifetime. this.fastCookieConsumed = true; } } private listeners = new Set<() => void>(); private refreshPromise: Promise | null = null; private refreshTimeout: ReturnType | undefined; private fastCookieConsumed = false; subscribe = (listener: () => void) => { this.listeners.add(listener); // Unsubscribing the last listener clears the refresh timer, so restore it // whenever the store goes from zero to one subscribers. Subscribers attach // from effects, which run after hydration, so a timer scheduled here can // never dispatch a Server Action before the router is ready. if (this.listeners.size === 1 && !this.refreshTimeout) { const tokenData = this.parseToken(this.state.token); if (tokenData) { this.scheduleRefresh(tokenData.timeUntilExpiry); } } return () => { this.listeners.delete(listener); if (this.listeners.size === 0 && this.refreshTimeout) { clearTimeout(this.refreshTimeout); this.refreshTimeout = undefined; } }; }; getSnapshot = () => this.state; getServerSnapshot = () => this.serverSnapshot; private notify() { this.listeners.forEach((listener) => listener()); } private setState(updates: Partial) { this.state = { ...this.state, ...updates }; this.notify(); } private scheduleRefresh(timeUntilExpiry?: number) { if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); this.refreshTimeout = undefined; } const delay = typeof timeUntilExpiry === 'undefined' ? RETRY_DELAY_SECONDS * 1000 : this.getRefreshDelay(timeUntilExpiry); this.refreshTimeout = setTimeout( /* istanbul ignore next */ () => { void this.getAccessTokenSilently().catch(/* istanbul ignore next */ () => {}); }, delay, ); } private getRefreshDelay(timeUntilExpiry: number) { if (timeUntilExpiry <= TOKEN_EXPIRY_BUFFER_SECONDS) { return 0; // Immediate refresh } const idealDelay = (timeUntilExpiry - TOKEN_EXPIRY_BUFFER_SECONDS) * 1000; return Math.min(Math.max(idealDelay, MIN_REFRESH_DELAY_SECONDS * 1000), MAX_REFRESH_DELAY_SECONDS * 1000); } private deleteCookie() { const isSecure = window.location.protocol === 'https:'; // Build deletion string to match EXACTLY what the server sets // Server sets: Path=/, SameSite=Lax, and Secure (if HTTPS) // NO Domain attribute is set by server, so we don't set it either const deletionString = isSecure ? `${jwtCookieName}=; SameSite=Lax; Max-Age=0; Secure` : `${jwtCookieName}=; SameSite=Lax; Max-Age=0`; document.cookie = deletionString; // The cookie might still appear in document.cookie even after deletion // due to browser caching, but it should be expired and not sent to server } private getInitialTokenFromCookie(): string | undefined { if (typeof document === 'undefined' || typeof document.cookie === 'undefined') { return; } // Parse cookies without regex const cookies = document.cookie.split(';').reduce( (acc, cookie) => { const [name, ...valueParts] = cookie.trim().split('='); if (name && valueParts.length > 0) { const value = valueParts.join('='); // Handle values that contain '=' acc[name.trim()] = decodeURIComponent(value); } return acc; }, {} as Record, ); const token = cookies[jwtCookieName]; if (!token) { return; } // Delete the cookie immediately after reading it this.deleteCookie(); return token; } private consumeFastCookie(): string | undefined { // Only try to consume once per page load if (this.fastCookieConsumed) { return; } if (typeof document === 'undefined' || typeof document.cookie === 'undefined') { return; } // Parse cookies without regex const cookies = document.cookie.split(';').reduce( (acc, cookie) => { const [name, ...valueParts] = cookie.trim().split('='); if (name && valueParts.length > 0) { const value = valueParts.join('='); // Handle values that contain '=' acc[name.trim()] = decodeURIComponent(value); } return acc; }, {} as Record, ); const newToken = cookies[jwtCookieName]; if (!newToken) { // Mark as consumed even if not found, to avoid repeated checks this.fastCookieConsumed = true; return; } // Mark as consumed BEFORE deleting to prevent race conditions this.fastCookieConsumed = true; // Delete the cookie using protocol-aware deletion this.deleteCookie(); if (newToken !== this.state.token) { return newToken; } } parseToken(token: string | undefined) { if (!token) return null; try { const { payload } = decodeJwt(token); const now = Math.floor(Date.now() / 1000); if (typeof payload.exp !== 'number') { return null; } const timeUntilExpiry = payload.exp - now; // For short-lived tokens (< 5 minutes), use a 30-second buffer // This prevents constant refreshing when tokens only last 60 seconds let bufferSeconds = TOKEN_EXPIRY_BUFFER_SECONDS; const totalTokenLifetime = payload.exp - (payload.iat || now); if (totalTokenLifetime <= 300) { // Token lifetime is 5 minutes or less - use 30 second buffer bufferSeconds = 30; } const isExpiring = payload.exp < now + bufferSeconds; return { payload, expiresAt: payload.exp, isExpiring, timeUntilExpiry, }; } catch { return null; } } isRefreshing(): boolean { return this.refreshPromise !== null; } clearToken() { this.setState({ token: undefined, error: null, loading: false }); if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); this.refreshTimeout = undefined; } } async getAccessToken(): Promise { const fastToken = this.consumeFastCookie(); if (fastToken) { this.setState({ token: fastToken, loading: false, error: null }); return fastToken; } const tokenData = this.parseToken(this.state.token); // If we have a valid JWT that's not expiring, return it if (tokenData && !tokenData.isExpiring) { return this.state.token; } // If we have an opaque token (can't parse as JWT), return it as-is if (this.state.token && !tokenData) { return this.state.token; } // Otherwise refresh (no token or expiring JWT) return this.refreshTokenSilently(); } async getAccessTokenSilently(): Promise { const fastToken = this.consumeFastCookie(); if (fastToken) { this.setState({ token: fastToken, loading: false, error: null }); // Schedule refresh based on token expiry const tokenData = this.parseToken(fastToken); if (tokenData) { this.scheduleRefresh(tokenData.timeUntilExpiry); } return fastToken; } const tokenData = this.parseToken(this.state.token); // If we have a valid JWT that's not expiring, return it if (tokenData && !tokenData.isExpiring) { // Valid non-expiring JWT - return cached token without server call return this.state.token; } // If we have an opaque token (can't parse as JWT), return it as-is if (this.state.token && !tokenData) { // Opaque token - return cached token without server call return this.state.token; } // Otherwise refresh (no token or expiring JWT) return this.refreshTokenSilently(); } async refreshToken(): Promise { return this._refreshToken(false); } private async refreshTokenSilently(): Promise { return this._refreshToken(true); } private async _refreshToken(silent: boolean): Promise { if (this.refreshPromise) { return this.refreshPromise; } const previousToken = this.state.token; // Only set loading for user-initiated refreshes, not background refreshes if (!silent) { this.setState({ loading: true, error: null }); } else { // Clear error for silent refreshes but don't set loading this.setState({ error: null }); } this.refreshPromise = (async () => { try { // For manual refresh, always call refreshAccessTokenAction // For silent refresh, try to get existing first, then refresh if needed let token: string | undefined; if (!silent) { // Manual refresh - always force refresh token = unwrapRefreshResult(await refreshAccessTokenAction()); } else { // Silent refresh - only fetch from server if we don't have a local token if (!previousToken) { // No local token, need to check server token = await getAccessTokenAction(); const tokenData = this.parseToken(token); // Set the token even if it's expiring, to preserve it in case refresh fails if (token && token !== previousToken) { this.setState({ token, loading: false, error: null, }); } // If the token from server is expiring, refresh it if (!token || (tokenData && tokenData.isExpiring)) { const refreshedToken = unwrapRefreshResult(await refreshAccessTokenAction()); if (refreshedToken) { token = refreshedToken; } } } else { // We have a local token that needs refreshing (already checked by getAccessTokenSilently) token = unwrapRefreshResult(await refreshAccessTokenAction()); } } // Only update state if token actually changed or if loading was true if (token !== previousToken || !silent) { this.setState({ token, loading: false, error: null, }); } const tokenData = this.parseToken(token); if (tokenData) { this.scheduleRefresh(tokenData.timeUntilExpiry); } // If token is opaque (not a JWT), we don't schedule automatic refreshes return token; } catch (error) { // Don't clear the token immediately - keep the stale one while retrying this.setState({ loading: false, error: error instanceof Error ? error : new Error(String(error)), }); // Schedule a retry after delay this.scheduleRefresh(); throw error; } finally { this.refreshPromise = null; } })(); return this.refreshPromise; } reset() { this.state = { token: undefined, loading: false, error: null }; this.refreshPromise = null; this.fastCookieConsumed = false; if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); this.refreshTimeout = undefined; } this.listeners.clear(); } } export const tokenStore = new TokenStore();