// 批准页 step-up 再认证的会话级暂存。step_up 开启时,批准方在批准新设备前必须做 // 一次新鲜的 prompt=login 再登录(provider 若开了 2FA 即在此处过),把这次拿到的 // 授权码交给后端就地核验。这段往返跨一次整页跳转(→ provider → /oauth/callback → // 批准页),故用 sessionStorage 串起三个状态: // // 1. 跳转去 provider 前:记下「待办」标记 + 这次的 code_verifier + 要回到的批准页 // URL(PENDING)。 // 2. /oauth/callback 落地(检测到 PENDING):不调 /api/auth/exchange(那会在服务端 // 消费掉 code 并轮换会话),转而把本次回调的 {code, verifier} 暂存进 STASH, // 清掉 PENDING,整页跳回批准页 URL。 // 3. 批准页回到后:取出 STASH 的 {code, verifier} → 调 qrApprove 就地核验 → 用完即清。 // // sessionStorage(非 localStorage):仅本次再认证往返需要,关标签即清、不跨设备; // code 是一次性的且与本批准会话强绑定,落 localStorage 反而徒增泄漏面。 const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo } const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier } // 登录会话登出的 step-up:跨整页跳转记住「要登出的是哪条会话」。回到设置页后取出, // 重试那条 DELETE。批准页的 step-up 不用它(要批准的 request 已编在 returnTo 里)。 const REVOKE_KEY = "cdrop.qr.stepup_revoke"; // sessionId(string) // 仅允许两个站内回流目标,杜绝 open-redirect(外部 URL / 协议相对地址 / 任意路径): // - /link[?…] ——批准页(扫码登录的 step-up); // - /settings ——设置页(登录会话登出的 step-up)。 // step-up 往返回来后的整页跳转目标只能是这两处之一;其余一律拒。校验 path 必须以 // "/link" 或 "/settings" 起头且后随串只能是空或 "?…",挡住 "/linkmalicious" / // "//evil.com"(协议相对)/ "/settings/../x" 之类绕过。 function isSafeReturnPath(path: string): boolean { for (const base of [ "/link", "/settings" ]) { if (path === base) { return true; } if (path.startsWith(base + "?")) { return true; } } return false; } export interface StepUpPending { verifier: string; returnTo: string; } export interface StepUpStash { code: string; verifier: string; } // stashStepUpPending 在跳转去 provider 前记下「待办」。returnTo 非法(防开放重定向) // 即不写——调用方据返回值判定是否安全发起跳转。 export function stashStepUpPending(pending: StepUpPending): boolean { if (typeof window === "undefined") { return false; } if (!pending.verifier || !isSafeReturnPath(pending.returnTo)) { return false; } window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending)); return true; } // peekStepUpPending 只读地探测是否在 step-up 往返中(/oauth/callback 据此分叉, // 决定不走 /api/auth/exchange)。不消费。 export function peekStepUpPending(): StepUpPending | null { if (typeof window === "undefined") { return null; } const raw = window.sessionStorage.getItem(PENDING_KEY); if (!raw) { return null; } const p = safeParse(raw); if (!p?.verifier || !isSafeReturnPath(p.returnTo)) { return null; } return p; } export function clearStepUpPending(): void { if (typeof window === "undefined") { return; } window.sessionStorage.removeItem(PENDING_KEY); } // stashStepUpCode 把回调拿到的一次性 code + 配套 verifier 暂存,留给批准页交给 // 后端 approve 核验。前端绝不自己 POST /exchange 消费它。 export function stashStepUpCode(stash: StepUpStash): void { if (typeof window === "undefined") { return; } if (!stash.code || !stash.verifier) { return; } window.sessionStorage.setItem(STASH_KEY, JSON.stringify(stash)); } // consumeStepUpCode 取出并清除暂存的 {code, verifier}(一次性,用完即清,避免泄漏 // 与复用)。无 / 非法返回 null。 export function consumeStepUpCode(): StepUpStash | null { if (typeof window === "undefined") { return null; } const raw = window.sessionStorage.getItem(STASH_KEY); window.sessionStorage.removeItem(STASH_KEY); if (!raw) { return null; } const s = safeParse(raw); if (!s?.code || !s.verifier) { return null; } return s; } export function clearStepUpStash(): void { if (typeof window === "undefined") { return; } window.sessionStorage.removeItem(STASH_KEY); } // stashPendingRevoke 在发起会话登出的 step-up 再认证前记下待登出的 sessionId, // 跨整页跳转留到回到设置页后取出重试。 export function stashPendingRevoke(sessionId: string): void { if (typeof window === "undefined") { return; } if (!sessionId) { return; } window.sessionStorage.setItem(REVOKE_KEY, sessionId); } // consumePendingRevoke 取出并清除待登出的 sessionId(一次性,用完即清)。 export function consumePendingRevoke(): string | null { if (typeof window === "undefined") { return null; } const id = window.sessionStorage.getItem(REVOKE_KEY); window.sessionStorage.removeItem(REVOKE_KEY); return id || null; } function safeParse(raw: string): T | null { try { return JSON.parse(raw) as T; } catch { return null; } }