/** * Ticket ของ add-in — ของมือถือ (primitives) ให้ authService ประกอบเป็น flow * (PLAN-ADDIN-AUTH.md §5): * - 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 และจริง) · * คืน discriminated result แยก network/http status/malformed ให้ caller * ตัดสินตาม contract 400/401/403 (2026-11-04) * - (ไม่มีการตรวจ HMAC ฝั่ง client อีกต่อไป — secret เป็นของ server อย่างเดียว * ตาม PLAN-SSO.md D13, ถอด verifyTicketHmacWeb ออก 2026-11-04) */ import { withBasePath } from "@/utils/base-path"; /** 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; } // --------------------------------------------------------------------------- // ขอ 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; } } /** ผลของ requestDocverToken — แยกสาเหตุให้ caller ตัดสินตาม contract ไม่โยน exception */ export type RequestDocverTokenResult = | { ok: true; entry: SessionTokenEntry } /** fetch reject — ไม่ถึง server (offline/dev server ตาย) */ | { ok: false; kind: "network" } /** ได้ response แต่ไม่ใช่ 200 — 400 ฟอร์แม็ตพัง / 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ */ | { ok: false; kind: "http"; status: number } /** ตอบ 200 แต่ body ใช้ไม่ได้ (ไม่ใช่ JSON / ไม่มี access_token) */ | { ok: false; kind: "malformed" }; /** * ขอ access_token ด้วย ticket — คืน discriminated result ไม่โยน Error: * caller (authService) แยกทางตาม contract ของทางเข้า ticket ของ * /api/documents/token (2026-11-04: 200 ผ่าน / 400 ฟอร์แม็ต / 401 ลายเซ็น / * 403 ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT เดิมของ fid ใน session ใช้ต่อได้) */ export async function requestDocverToken( ticketData: string, ticketHmac: string, ): Promise { let response: Response; try { response = await fetch(withBasePath("/api/documents/token"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }), }); } catch { return { ok: false, kind: "network" }; } if (!response.ok) { return { ok: false, kind: "http", status: response.status }; } let data: TokenResponse; try { data = (await response.json()) as TokenResponse; } catch { return { ok: false, kind: "malformed" }; } if (typeof data.access_token !== "string" || data.access_token === "") { return { ok: false, kind: "malformed" }; } const exp = jwtExp(data.access_token) ?? (typeof data.expires_in === "number" ? Math.floor(Date.now() / 1000) + data.expires_in : 0); return { ok: true, entry: { token: data.access_token, exp } }; }