/** * SDK Token 管理模块 —— 自动刷新 + 401 兜底重试 * * 背景:SDK Token 默认只有 2 小时(后端 `jwt.sdk-expiration`),第三方页面长时间挂着必然失效。 * 本模块负责三件事: * 1. 临期主动刷新 —— token 剩余寿命不足阈值时,通过宿主提供的 `getToken` 回调换新,用户无感; * 2. 401 兜底重试 —— 主动刷新没覆盖到的场景(宿主未给 expiresIn、后台标签页定时器被节流等), * 由 api.ts 在收到 401 时调 `refreshNow()` 换新并重试一次原请求; * 3. 手动入口 —— 宿主自行换好 Token 后调 `setHostToken()` 推送,或调 `forceRefresh()` 让 SDK 去拉。 * * Token 只存内存,**不落 localStorage / sessionStorage**(避免 XSS 或同域页面读取)。 * * 本模块禁止 import api.ts(会形成循环依赖),也不触碰 DOM(保证 vitest 的 node 环境可直接测)。 */ import { TokenProvider } from './types'; import { logger } from './logger'; import { t } from './i18n'; /** 临期阈值上限:token 剩余寿命不足该值时触发主动刷新 */ const REFRESH_SKEW_MAX_MS = 5 * 60 * 1000; /** Token 有效期上限(秒),对齐后端 `SdkJwtTokenProvider.MAX_EXPIRATION` */ const MAX_TTL_SECONDS = 86400; /** 定时器延迟下限,防止 delay ≤ 0 排成 0ms 定时器形成自激循环 */ const MIN_TIMER_DELAY_MS = 1000; /** 两次自主刷新之间的最小间隔(自激的第二道闸) */ const MIN_PROACTIVE_INTERVAL_MS = 5000; /** 自主刷新失败后的退避间隔 */ const PROACTIVE_BACKOFF_MS = 60000; /** 自主刷新连续失败次数上限,超过后彻底停掉定时器链(改由业务请求 / 401 驱动) */ const MAX_PROACTIVE_FAILURES = 3; /** provider 调用超时:宿主回调可能永不 settle,必须自带超时兜底 */ const PROVIDER_TIMEOUT_MS = 10000; /** 同一错误码的上报冷却,避免并发 401 引发 onError 风暴 */ const REPORT_COOLDOWN_MS = 30000; /** provider 反复返回同一个 token 时的静默窗口,避免每个请求都白跑两趟 */ const STALE_PROVIDER_COOLDOWN_MS = 30000; // ==================== 运行时状态 ==================== /** 当前 Token */ let token: string | null = null; /** 绝对过期时间(毫秒时间戳),无过期信息时为 null */ let expireAt: number | null = null; /** 当前 Token 的总寿命(毫秒),用于按比例计算临期阈值 */ let ttlMs: number | null = null; /** 宿主提供的换 Token 回调 */ let provider: TokenProvider | null = null; /** * 取 Token 的策略: * - `'expiry'`(默认)SDK 自己推算过期时间,只在临期 / 401 时才换; * - `'always'` 每个受保护请求发出前都向宿主取一次,SDK 完全不推算过期时间。 */ let strategy: 'expiry' | 'always' = 'expiry'; /** 是否已初始化(configureTokenManager 后为 true,resetTokenManager 后为 false) */ let configured = false; /** 在途的刷新 Promise(single-flight) */ let refreshPromise: Promise | null = null; /** 自主刷新定时器 */ let timer: ReturnType | null = null; /** * 代际计数:宿主 setToken / destroy 时自增,用于让在途刷新的结果作废。 * 没有它,「在途刷新返回旧 token」会覆盖宿主刚推来的新值并重排定时器。 */ let generation = 0; /** 自主刷新连续失败次数 */ let proactiveFailures = 0; /** 上次自主刷新的时间戳,用于最小间隔节流 */ let lastProactiveAt = 0; /** 自主刷新失败后的退避截止时间戳 */ let backoffUntil = 0; /** provider 返回同一 token 后的静默截止时间戳 */ let staleProviderUntil = 0; /** 上次错误上报的 code / 时间戳,用于同码冷却 */ let lastReportCode = ''; let lastReportAt = 0; // ==================== 路径判定 ==================== /** * 判断 URL 是否属于后端 `SdkAuthFilter` 保护的路径(需要携带 SDK Token)。 * * **必须与 `SdkAuthFilter.shouldNotFilter()` 逐字对齐**:受保护 = `/ai/**`(除显式放行的 * `/ai/system-config/**`)+ `/feedback` + `/attachment/upload`。改 api.ts 的鉴权相关逻辑时, * 请同步核对后端过滤器,否则会出现「该带 Token 的没带」或「公开端点泄露 Token」。 * * 用 URL.pathname 而非整串 includes:requestDomain 可能自带路径前缀(如 `https://gw.example.com/ai/`), * 那种情况下 `includes('/ai/')` 对所有 URL 都成立,判据会全真。 */ export function isProtectedPath(url: string): boolean { let pathname: string; try { pathname = new URL(url, 'http://localhost').pathname; } catch { return false; } // 后端显式放行的公开端点(SecurityConfig 中 permitAll 且 SdkAuthFilter 不拦) if (pathname.startsWith('/ai/system-config/')) return false; return pathname.startsWith('/ai/') || pathname === '/feedback' || pathname === '/attachment/upload'; } // ==================== 内部工具 ==================== /** * 把 `tokenUrl` 包装成一个 provider,复用与 `getToken` 完全相同的刷新链路。 * * - `credentials: 'same-origin'`:同源(宿主页面调自己后端,最常见)自动带 Cookie; * 跨域则不带,避免把宿主站点的 Cookie 泄露给别的域。需要跨域带 Cookie 的场景请改用 `getToken`。 * - 自带超时中断:`callProvider` 的 `Promise.race` 只保证不阻塞调用方,不会取消 fetch 本身, * 宿主端点挂死时得靠这里把连接掐掉。 * - 不做 URL 白名单校验:`tokenUrl` 是接入方自己写在页面里的,信任级别与 `getToken`(任意 JS)等同。 */ function createUrlProvider(url: string): TokenProvider { return async () => { const controller = new AbortController(); const timerId = setTimeout(() => controller.abort(), PROVIDER_TIMEOUT_MS); try { const res = await fetch(url, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'same-origin', mode: 'cors', signal: controller.signal, }); if (!res.ok) throw new Error(`取 Token 失败:HTTP ${res.status}`); let data: unknown; try { data = await res.json(); } catch { throw new Error('取 Token 响应不是合法 JSON'); } // 兼容两种响应:{token, expiresIn} 与 {success, token, expiresIn, roles} const record = (data ?? {}) as { token?: unknown; expiresIn?: unknown; message?: unknown }; if (typeof record.token !== 'string' || !record.token.trim()) { throw new Error(typeof record.message === 'string' ? record.message : '取 Token 响应缺少 token 字段'); } return { token: record.token.trim(), expiresIn: typeof record.expiresIn === 'number' ? record.expiresIn : undefined, }; } finally { clearTimeout(timerId); } }; } /** * 临期阈值:取「token 总寿命的 10%」与 5 分钟的较小值。 * * 必须自适应,不能固定 5 分钟:后端 `clampExpirationMillis` 允许的最短 ttl 恰好是 300s * (`SdkJwtTokenProvider.MIN_EXPIRATION`),固定 5 分钟会让 300s 的 token 永远处于临期态 —— * 每个请求都触发刷新、定时器延迟算成 0,退化成秒级无限刷新循环。 */ function skewMsOf(ttl: number): number { return Math.min(REFRESH_SKEW_MAX_MS, Math.floor(ttl * 0.1)); } /** * 把宿主的 expiresIn(秒)归一为毫秒。 * * **仅做上界钳制,绝不做下界**:钳下界会把「宿主手里只剩 20s 的 token」当成 300s, * 导致 SDK 以为它还很新而带着已过期 Token 发请求 —— 正是本模块要消灭的场景。 * 上界同时防两件事:setTimeout 超过 2^31-1 会被浏览器按 1ms 处理(又是一次刷新风暴), * 以及单位误传(把 7200000 毫秒当秒)。 */ function normalizeExpiresIn(expiresIn: unknown): { ttlMs: number } | null { if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) return null; if (expiresIn > MAX_TTL_SECONDS) { logger.error( `expiresIn=${expiresIn} 超出上限(疑似误传毫秒),已按 ${MAX_TTL_SECONDS} 秒处理`, undefined, 'config_invalid' ); return { ttlMs: MAX_TTL_SECONDS * 1000 }; } return { ttlMs: Math.round(expiresIn * 1000) }; } /** 写入 Token 并重排定时器(失败计数与退避状态一并复位) */ function applyToken(nextToken: string, expiresIn?: number): void { token = nextToken; const normalized = normalizeExpiresIn(expiresIn); if (normalized) { ttlMs = normalized.ttlMs; expireAt = Date.now() + normalized.ttlMs; } else { // 无过期信息:不排定时器,只能靠 401 驱动重取 ttlMs = null; expireAt = null; } proactiveFailures = 0; backoffUntil = 0; staleProviderUntil = 0; scheduleProactive(); } /** 按「过期时间 - 临期阈值」排定下一次自主刷新 */ function scheduleProactive(): void { if (timer !== null) { clearTimeout(timer); timer = null; } if (!configured || !provider || expireAt === null) return; // 'always' 模式每次请求前都会取 Token,定时器纯属多余(且该模式通常没有 expiresIn,到期时间本就未知) if (strategy === 'always') return; // 连续失败达上限:彻底停掉定时器链,避免无意义的空转与打点 if (proactiveFailures >= MAX_PROACTIVE_FAILURES) return; const baseDelay = expireAt - skewMsOf(ttlMs ?? 0) - Date.now(); // 延迟必须有正下限(防 delay ≤ 0 排成 0ms 定时器),退避期内也不允许提前打扰 provider const delay = Math.max(baseDelay, MIN_TIMER_DELAY_MS, backoffUntil - Date.now()); timer = setTimeout(() => { timer = null; void refreshNow('timer'); }, delay); } /** 调用宿主 provider,自带超时兜底 */ async function callProvider(p: TokenProvider): Promise { let timerId: ReturnType | null = null; try { return await Promise.race([ // 用 Promise.resolve 包住:宿主回调同步 throw 时也能进入 catch,而不是逃逸出去 Promise.resolve(p()), new Promise((_, reject) => { timerId = setTimeout(() => reject(new Error('provider timeout')), PROVIDER_TIMEOUT_MS); }), ]); } finally { if (timerId !== null) clearTimeout(timerId); } } /** * 校验 provider 的返回值,只做校验不做兜底猜测。 * 返回 null 表示本次刷新失败,调用方保持原 Token 不变。 */ function normalizeProviderResult(raw: unknown): { token: string; expiresIn?: number } | null { // 裸字符串:合法但无过期信息,后续只能靠 401 驱动重取(文档中把对象形式标为推荐写法) if (typeof raw === 'string') { return raw.trim() ? { token: raw.trim() } : null; } if (raw && typeof raw === 'object') { const record = raw as { token?: unknown; expiresIn?: unknown }; if (typeof record.token !== 'string' || !record.token.trim()) return null; return { token: record.token.trim(), // expiresIn 类型不对时按「未知」处理,不猜单位 expiresIn: typeof record.expiresIn === 'number' ? record.expiresIn : undefined, }; } return null; } /** 同码冷却上报,避免 provider 故障 / 并发 401 时给宿主发一连串重复错误 */ function reportThrottled(code: string, message: string, detail?: unknown): void { const now = Date.now(); if (code === lastReportCode && now - lastReportAt < REPORT_COOLDOWN_MS) return; lastReportCode = code; lastReportAt = now; logger.error(message, detail, code); } // ==================== 对外接口 ==================== /** 注入配置(init 时调用,必须在 setErrorCallback 之后) */ export function configureTokenManager(opts: { token?: string; expiresIn?: number; getToken?: TokenProvider; tokenStrategy?: 'expiry' | 'always'; tokenUrl?: string; }): void { resetTokenManager(); configured = true; strategy = opts.tokenStrategy === 'always' ? 'always' : 'expiry'; // getToken 优先:两者都配时忽略 tokenUrl(config.ts 会就此告警,不让宿主蒙在鼓里) if (typeof opts.getToken === 'function') { provider = opts.getToken; } else if (typeof opts.tokenUrl === 'string' && opts.tokenUrl.trim()) { provider = createUrlProvider(opts.tokenUrl.trim()); } else { provider = null; } if (typeof opts.token === 'string' && opts.token.trim()) { applyToken(opts.token.trim(), opts.expiresIn); } // token 为空时不排定时器:'always' 模式下首个受保护请求会取回 Token; // 'expiry' 模式下同样交给 ensureFresh 的自举分支处理 } /** * 运行时更新 Token(公开 API `ChatbotSDK.setToken()` 的实现)。 * 宿主自行换好 Token 后可随时调用,无需 destroy + init 重建整个 DOM。 */ export function setHostToken(nextToken: string, expiresIn?: number): void { if (!configured) return; // 未 init / 已 destroy:静默忽略,不产生任何副作用 if (typeof nextToken !== 'string' || !nextToken.trim()) { logger.error('setToken 传入的 token 无效(需为非空字符串),已忽略', undefined, 'config_invalid'); return; } // 自增代际,使在途刷新的结果作废,避免旧 token 覆盖宿主刚推来的新值。 // 注意:**不能**在这里清 refreshPromise —— 那会破坏 single-flight //(在途刷新期间 ensureFresh 会再发起一次重复刷新),它只能由 refreshNow 的 finally 清空。 generation++; applyToken(nextToken.trim(), expiresIn); } /** 读取当前 Token(api.ts 注入 Authorization 头时使用) */ export function getToken(): string | null { return token; } /** 是否具备刷新能力(已初始化且宿主提供了 getToken) */ export function hasProvider(): boolean { return configured && provider !== null; } /** * 请求前检查:确保这次请求带的是有效 Token。永不 throw、永不挂起(provider 有超时兜底), * 可安全地在 hot path 上 await。两种策略: * - `'always'`:无条件向宿主取一次(SDK 不推算过期时间,`expiresIn` 不参与判断); * - `'expiry'`:仅在 Token 缺失或进入临期窗口时才取,其余情况直接放行。 * * 刷新失败时的退避/熔断判断在 `refreshNow` 里统一处理(单一定义点)。 */ export async function ensureFresh(): Promise { if (!configured) return; // 没有刷新手段:什么也做不了,到期后由 401 触发 auth_expired 上报,宿主据此重新 init 或 setToken if (!provider) return; if (strategy === 'always') { await refreshNow('request'); return; } // 自举:只配了 getToken 没配 token 时,首个受保护请求前先把 Token 换回来。 // 放在这里而不是 init() 时,是为了不给未使用受保护接口的页面增加一次额外请求。 if (token === null) { await refreshNow('request'); return; } // 有 Token 但无过期信息(宿主只传了裸字符串):只能靠 401 驱动重取 if (expireAt === null || ttlMs === null) return; if (Date.now() < expireAt - skewMsOf(ttlMs)) return; await refreshNow('request'); } /** * 立即刷新一次(single-flight:并发调用共享同一次刷新)。 * * @param reason 'request' = 业务请求驱动(临期检查 / 401 兜底),不节流; * 'timer' = 定时器自主驱动,受最小间隔与连续失败上限约束。 * @returns 新 Token;失败或未发生变化时返回 null(调用方据此决定是否重试) */ export function refreshNow(reason: 'request' | 'timer' = 'request'): Promise { if (!configured || !provider) return Promise.resolve(null); if (refreshPromise) return refreshPromise; // single-flight const now = Date.now(); // 熔断:上一次刷新失败后的退避窗口内,所有自动触发(临期检查 / 定时器 / 401 兜底)都不再尝试。 // 否则 provider 挂死时,每个请求都要多等一个 PROVIDER_TIMEOUT,把 SDK 整体拖慢。 // 宿主显式调用 forceRefresh() 会清掉该窗口。 if (now < backoffUntil) return Promise.resolve(null); // provider 刚返回过同一个 token:静默窗口内不再打扰它,避免每个请求都白跑两趟。 // 仅在 'expiry' 模式下生效 —— 'always' 模式下宿主返回同一个(仍有效的)缓存 Token 是常态, // 不该被当成 provider 故障,否则该模式会退化成 401 驱动。 if (strategy === 'expiry' && now < staleProviderUntil) return Promise.resolve(null); if (reason === 'timer') { if (proactiveFailures >= MAX_PROACTIVE_FAILURES) return Promise.resolve(null); // lastProactiveAt === 0 表示从未自主刷新过,不受最小间隔限制 if (lastProactiveAt !== 0 && now - lastProactiveAt < MIN_PROACTIVE_INTERVAL_MS) { return Promise.resolve(token); } lastProactiveAt = now; } const gen = generation; const activeProvider = provider; refreshPromise = (async (): Promise => { try { const raw = await callProvider(activeProvider); const parsed = normalizeProviderResult(raw); if (!parsed) { // provider 返回空串 / 非字符串 / 缺 token:绝不能发出「Bearer 」空值, // 后端会回 401「认证令牌为空」,白跑一次往返 if (gen === generation) { proactiveFailures++; backoffUntil = Date.now() + PROACTIVE_BACKOFF_MS; reportThrottled('auth_refresh_failed', t('error_token_refresh_failed')); } return null; } // 期间宿主 setToken / destroy 过:丢弃本次结果,绝不回写 if (gen !== generation) return token; const before = token; applyToken(parsed.token, parsed.expiresIn); if (parsed.token === before) { // provider 反复返回同一个 token:本次刷新无效,静默一段时间 staleProviderUntil = Date.now() + STALE_PROVIDER_COOLDOWN_MS; } return token; } catch (err) { if (gen === generation) { proactiveFailures++; // 无论何种触发方式,失败后都进入退避窗口:让 ensureFresh 与定时器都先安静下来 backoffUntil = Date.now() + PROACTIVE_BACKOFF_MS; reportThrottled('auth_refresh_failed', t('error_token_refresh_failed'), err); } return null; } finally { // 无条件清空:否则 provider 永不 settle 时会把挂起传染给后续所有受保护请求 refreshPromise = null; if (gen === generation) scheduleProactive(); } })(); return refreshPromise; } /** * 主动触发一次刷新(公开 API `ChatbotSDK.refreshToken()` 的实现)。 * 宿主可在收到 `onError(code='auth_expired')` 后调用,拿到新 Token 再重试。 * @returns 是否真的换到了与原来不同的 Token */ export async function forceRefresh(): Promise { if (!configured || !provider) return false; const before = token; // 宿主显式要求刷新:绕过两道自动触发才受的闸 ——「provider 刚返回过同一 token」的静默窗口, // 以及刷新失败后的退避/熔断窗口 staleProviderUntil = 0; backoffUntil = 0; const next = await refreshNow('request'); return next !== null && next !== before; } /** 上报「Token 已失效且无法自动恢复」,供 api.ts 在重试后仍 401 时调用 */ export function reportAuthExpired(): void { reportThrottled('auth_expired', t('error_auth')); } /** 清空全部状态(destroy 时调用) */ export function resetTokenManager(): void { // 先置 false 并自增代际:在途刷新的结算会被代际判定拦住,不会「复活」状态并重排定时器 configured = false; generation++; if (timer !== null) { clearTimeout(timer); timer = null; } token = null; expireAt = null; ttlMs = null; provider = null; strategy = 'expiry'; refreshPromise = null; proactiveFailures = 0; lastProactiveAt = 0; backoffUntil = 0; staleProviderUntil = 0; lastReportCode = ''; lastReportAt = 0; }