/** * Ticket ของ add-in — ของมือถือ (primitives) ให้ authService ประกอบเป็น flow * (PLAN-ADDIN-AUTH.md §5): * - verifyTicketHmacWeb: ตรวจ HMAC ฝั่ง client ด้วย Web Crypto (UX gate — ตัดเคส * ไฟล์ถูกแก้ก่อนยิง network ส่วนความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น) * - session store: localStorage key เดียว `wordaddin-auth` — userId/userName + * map token ต่อ fid · webview block localStorage = fallback memory ต่อ session * (pattern เดียวกับ versioningService) * - requestDocverToken: POST same-origin /api/documents/token แบบ body ticket — * ไม่ผูกกับ config.apiBaseUrl (endpoint อยู่แอปนี้ ใช้ได้ทั้งช่วง mock และจริง) */ import { withBasePath } from "@/utils/base-path"; import { normalizeHexDigest } from "@/utils/docver-ticket"; /** key เดียวของ session — v ไว้ migrate รูปในอนาคต */ const SESSION_STORAGE_KEY = "wordaddin-auth"; export interface SessionTokenEntry { token: string; /** exp ของ JWT (unix วินาที) — decode จาก payload ตอนรับ */ exp: number; } export interface AddinSession { userId: string; userName: string; /** token ต่อ fid — "คีย์คือ fid" ตาม requirement */ tokens: Record; } /** memory fallback — ใช้เมื่อ localStorage โดน block (webview บางตัว/private mode) */ let memorySession: AddinSession | null = null; function parseSession(raw: string | null): AddinSession | null { if (!raw) return null; try { const parsed = JSON.parse(raw) as AddinSession; if ( typeof parsed.userId !== "string" || typeof parsed.userName !== "string" || parsed.userId === "" || typeof parsed.tokens !== "object" || parsed.tokens === null || Array.isArray(parsed.tokens) ) { return null; } for (const entry of Object.values(parsed.tokens)) { if (typeof entry.token !== "string" || typeof entry.exp !== "number") return null; } return parsed; } catch { return null; } } export function loadSession(): AddinSession | null { if (typeof window === "undefined") return memorySession; // SSR ของ client component try { return parseSession(window.localStorage.getItem(SESSION_STORAGE_KEY)) ?? memorySession; } catch { return memorySession; } } /** เขียน session — localStorage พัง = เก็บ memory ต่อ (ไม่ throw) */ export function saveSession(session: AddinSession): void { memorySession = session; if (typeof window === "undefined") return; try { window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); } catch { // ignore — quota/private mode: มี memory ของ session นี้อยู่ } } /** ล้างทั้งก้อน — ใช้ตอนเจอ user ใหม่ (requirement ขั้น 6: เคลียร์ Session เดิม) */ export function clearSession(): void { memorySession = null; if (typeof window === "undefined") return; try { window.localStorage.removeItem(SESSION_STORAGE_KEY); } catch { // ignore } } /** token หมดอายุหรือยัง (มี margin 30 วิ กันขอบเขตตกหล่น) */ export function isTokenExpired(entry: SessionTokenEntry): boolean { return entry.exp * 1000 <= Date.now() - 30_000; } // --------------------------------------------------------------------------- // HMAC ฝั่ง client — Web Crypto (crypto.subtle ให้ promise เสมอ) // --------------------------------------------------------------------------- /** คำนวณ HMAC-SHA256 คืน hex lowercase — รูปแบบเดียวกับ generateHmacSha256 ฝั่ง server */ export async function hmacSha256HexWeb(message: string, secret: string): Promise { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message)); return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join(""); } /** * ตรวจ HMAC ของ ticket ฝั่ง client — message คือ string ดิบของ ticket_data ตามที่อ่านจาก * property (ห้าม trim — ตัว stamp เซ็นบนค่าดิบ) · เทียบหลัง normalize lowercase */ export async function verifyTicketHmacWeb(ticketData: string, expectedHex: string, secret: string): Promise { const expected = normalizeHexDigest(expectedHex); if (expected === null) return false; try { return (await hmacSha256HexWeb(ticketData, secret)) === expected; } catch { return false; } } // --------------------------------------------------------------------------- // ขอ token จาก /api/documents/token (ทางเข้า ticket — PLAN-ADDIN-AUTH.md §3) // --------------------------------------------------------------------------- interface TokenResponse { access_token: string; token_type: string; expires_in: number; } /** ถอด exp จาก JWT payload โดยไม่ตรวจ signature (แม่นกว่า expires_in — ใช้นาฬิกา server) */ function jwtExp(token: string): number | undefined { const payload = token.split(".")[1]; if (!payload) return undefined; try { const bytes = Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0)); const json: unknown = JSON.parse(new TextDecoder().decode(bytes)); const exp = typeof json === "object" && json !== null ? (json as Record).exp : undefined; return typeof exp === "number" && Number.isFinite(exp) ? exp : undefined; } catch { return undefined; } } /** * ขอ access_token ด้วย ticket — โยน Error เมื่อ status ไม่สำเร็จ * (401 = ticket ไม่ผ่านการตรวจฝั่ง server — ให้ caller ไป no-access ตาม flow) */ export async function requestDocverToken(ticketData: string, ticketHmac: string): Promise { const response = await fetch(withBasePath("/api/documents/token"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }), }); if (!response.ok) { throw new Error(`ขอ token ไม่สำเร็จ (HTTP ${response.status})`); } let data: TokenResponse; try { data = (await response.json()) as TokenResponse; } catch { throw new Error("ขอ token ไม่สำเร็จ (response ไม่ใช่ JSON)"); } if (typeof data.access_token !== "string" || data.access_token === "") { throw new Error("ขอ token ไม่สำเร็จ (ไม่มี access_token)"); } const exp = jwtExp(data.access_token) ?? (typeof data.expires_in === "number" ? Math.floor(Date.now() / 1000) + data.expires_in : 0); return { token: data.access_token, exp }; }