import { NIANCODE_AUTH_CONFIG } from '../api/auth-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { logger } from '../utils/logger'; const TOKEN_REFRESH_SKEW_MS = 30_000; export type WorksSquareSessionInput = { accessToken: string; refreshToken?: string | null; tokenType?: string | null; expiresAt?: number | null; }; export type WorksSquareTokenPayload = { access_token?: unknown; refresh_token?: unknown; token_type?: unknown; expires_in?: unknown; }; export type WorksSquareSessionSnapshot = { accessToken: string; refreshToken: string | null; tokenType: string; expiresAt: number | null; }; export type WorksSquareSessionListener = (session: WorksSquareSessionSnapshot | null) => void; let currentSession: WorksSquareSessionSnapshot | null = null; let refreshPromise: Promise | null = null; const sessionListeners = new Set(); function notifySessionListeners(): void { const snapshot = currentSession ? { ...currentSession } : null; for (const listener of sessionListeners) { try { listener(snapshot); } catch { // Session observers must not affect authentication state changes. } } } function asString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } function expiresAtFromExpiresIn(expiresIn: unknown, nowMs = Date.now()): number | null { const seconds = typeof expiresIn === 'number' ? expiresIn : (typeof expiresIn === 'string' ? Number(expiresIn) : NaN); return Number.isFinite(seconds) && seconds > 0 ? nowMs + seconds * 1000 : null; } function createBasicAuthHeader(clientId: string, clientSecret: string): string { return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; } function parseTokenPayload( payload: WorksSquareTokenPayload, fallbackRefreshToken: string | null, nowMs = Date.now(), ): WorksSquareSessionSnapshot { const accessToken = asString(payload.access_token); if (!accessToken) { throw new Error('Auth refresh response did not include access_token'); } return { accessToken, refreshToken: asString(payload.refresh_token) ?? fallbackRefreshToken, tokenType: asString(payload.token_type) ?? 'Bearer', expiresAt: expiresAtFromExpiresIn(payload.expires_in, nowMs), }; } async function readResponsePayload(response: Response): Promise { const text = await response.text(); if (!text.trim()) return null; try { return JSON.parse(text) as unknown; } catch { return text; } } export function storeWorksSquareSession(input: WorksSquareSessionInput): void { const accessToken = input.accessToken.trim(); if (!accessToken) { clearWorksSquareSession(); return; } currentSession = { accessToken, refreshToken: input.refreshToken?.trim() || null, tokenType: input.tokenType?.trim() || 'Bearer', expiresAt: typeof input.expiresAt === 'number' && Number.isFinite(input.expiresAt) ? input.expiresAt : null, }; notifySessionListeners(); } export function storeWorksSquareSessionFromTokenPayload( payload: WorksSquareTokenPayload, fallbackRefreshToken: string | null = null, nowMs = Date.now(), ): WorksSquareSessionSnapshot { const session = parseTokenPayload(payload, fallbackRefreshToken, nowMs); currentSession = session; notifySessionListeners(); return session; } export function clearWorksSquareSession(): void { currentSession = null; refreshPromise = null; notifySessionListeners(); } export function subscribeWorksSquareSession(listener: WorksSquareSessionListener): () => void { sessionListeners.add(listener); return () => { sessionListeners.delete(listener); }; } export function getWorksSquareSessionSnapshot(): WorksSquareSessionSnapshot | null { return currentSession ? { ...currentSession } : null; } async function refreshWorksSquareSession( options: { fetchImpl?: typeof fetch; nowMs?: number } = {}, ): Promise { const session = currentSession; if (!session?.refreshToken) return null; const fetchImpl = options.fetchImpl ?? proxyAwareFetch; const nowMs = options.nowMs ?? Date.now(); const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: session.refreshToken, }); const response = await fetchImpl(`${NIANCODE_AUTH_CONFIG.gatewayAuthUrl.replace(/\/+$/, '')}/oauth2/token`, { method: 'POST', headers: { Authorization: createBasicAuthHeader( NIANCODE_AUTH_CONFIG.clientId, NIANCODE_AUTH_CONFIG.clientSecret, ), 'Content-Type': 'application/x-www-form-urlencoded', }, body, }); if (!response.ok) { logger.warn('[works-square-session] Refresh failed', { status: response.status }); clearWorksSquareSession(); return null; } const payload = await readResponsePayload(response); if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { clearWorksSquareSession(); return null; } const nextSession = storeWorksSquareSessionFromTokenPayload( payload as WorksSquareTokenPayload, session.refreshToken, nowMs, ); return nextSession.accessToken; } export async function getValidWorksSquareAccessToken( options: { fetchImpl?: typeof fetch; nowMs?: number; forceRefresh?: boolean } = {}, ): Promise { const session = currentSession; if (!session) return null; const nowMs = options.nowMs ?? Date.now(); const nearExpiry = Boolean( session.expiresAt && session.expiresAt <= nowMs + TOKEN_REFRESH_SKEW_MS, ); if (!nearExpiry && !options.forceRefresh) return session.accessToken; if (!refreshPromise) { refreshPromise = refreshWorksSquareSession(options).finally(() => { refreshPromise = null; }); } return await refreshPromise; }