/** * Auth ของ add-in — e-Court ticket ทางเข้าเดียว (PLAN-ADDIN-AUTH.md §5 ตาม * docs/requirement/addin-workflow.md) * * ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data` * + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ * HMAC → exp → session ใน localStorage ตาม uid/fid แล้วขอ access_token จาก * /api/documents/token เก็บตาม fid ("คีย์คือ fid") * * token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่ * ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew * ตอน token หมดอายุกลาง session */ import { config } from "../config"; import { isTicketExpired, parseDocverTicket } from "@/utils/docver-ticket"; import * as ticketService from "./ticketService"; /** สาเหตุที่เข้าไม่ได้ — UI แยกข้อความตาม reason (TaskpaneApp) */ export type SignInFailureReason = "missing" | "tampered" | "expired" | "token-request-failed"; export type SignInResult = { ok: true } | { ok: false; reason: SignInFailureReason; message: string }; /** state ของ taskpane instance นี้ (ต่อเอกสารที่เปิด) — null = ยังไม่ได้ sign in */ let username: string | null = null; /** fid ของเอกสารปัจจุบัน — token ที่ใช้ผูกกับ fid นี้เสมอ */ let currentFid: string | null = null; /** ticket ดิบของเอกสารปัจจุบัน — ไว้ขอ token ใหม่ตอน 401/token หมดอายุ */ let currentTicket: { data: string; hmac: string } | null = null; /** โหมด ?addindemo (dev-only) — เปิดโดย signInForDemo เท่านั้น */ let demoMode = false; /** dev-only — โหมด ?addindemo ของ WordAddinGate ใช้ตั้ง session ทันทีไม่อ่านไฟล์จริง */ export function signInForDemo(): void { username = config.mockUsername; currentFid = null; currentTicket = null; demoMode = true; } /** โหมด ?addindemo หรือเปล่า — versioningService ใช้ mock แทน backend จริงเฉพาะโหมดนี้ */ export function isDemoMode(): boolean { return demoMode; } /** * e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7): * 1. ตรวจมี ticket_data + HmacSha256 ครบ * 2. parse + ตรวจ HMAC ฝั่ง client (UX gate) * 3. session ใน localStorage: ยังไม่มี/user ใหม่ → ตรวจ exp → เคลียร์ session เก่า * แล้วขอ token ใหม่ · user เดิม + fid เดิม → ผ่านเลย (renew เงียบ ๆ ถ้า token * หมดอายุแต่ ticket ยังไม่หมด) · user เดิม + fid ใหม่ → ตรวจ exp → ขอ token เก็บเพิ่ม */ export async function signInWithTicket(input: { ticketData?: string; ticketHmac?: string; }): Promise { const ticketData = input.ticketData ?? ""; const ticketHmac = input.ticketHmac ?? ""; // Word custom property จำกัด value ~255 ตัวอักษร — เกิน = ไฟล์เสียหาย (ถูกตัด) if (ticketData === "" || ticketHmac === "" || ticketData.length > 255 || ticketHmac.length > 255) { return { ok: false, reason: "missing", message: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น", }; } const parsed = parseDocverTicket(ticketData); if (!parsed.ok) { return { ok: false, reason: "tampered", message: "ตรวจสอบ ticket ไม่ผ่าน (รูปแบบไม่ถูกต้อง) — ไฟล์ถูกแก้ไขหรือเสียหาย", }; } if (!(await ticketService.verifyTicketHmacWeb(ticketData, ticketHmac, config.ticketSecret))) { return { ok: false, reason: "tampered", message: "ตรวจสอบลายเซ็น (HMAC) ของ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไข", }; } const ticket = parsed.ticket; const ticketAlive = !isTicketExpired(ticket.exp); const session = ticketService.loadSession(); if (session === null || session.userId !== ticket.uid) { // ไม่มี session / user ใหม่ — ตรวจ exp ก่อนแล้วเริ่ม session ใหม่ทั้งก้อน if (!ticketAlive) { return { ok: false, reason: "expired", message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", }; } ticketService.clearSession(); let entry: ticketService.SessionTokenEntry; try { entry = await ticketService.requestDocverToken(ticketData, ticketHmac); } catch { return { ok: false, reason: "token-request-failed", message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", }; } ticketService.saveSession({ userId: ticket.uid, userName: ticket.uname, tokens: { [ticket.fid]: entry } }); } else { // คนเดิม — userName อัปเดตตามไฟล์ล่าสุด (ชื่อเปลี่ยนได้) session.userName = ticket.uname; let entry = session.tokens[ticket.fid] ?? null; if (entry !== null && ticketService.isTokenExpired(entry)) { // token หมดอายุ — renew ได้เฉพาะตอน ticket ยังไม่หมด; ticket หมดด้วยก็เก็บของ // เก่าไว้ (requirement ขั้น 7: ผ่านเลยไม่ตรวจ exp) ให้ server ตัดสินตอนใช้งาน if (ticketAlive) { try { entry = await ticketService.requestDocverToken(ticketData, ticketHmac); session.tokens[ticket.fid] = entry; } catch { // ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access } } } if (entry === null) { // เอกสารใหม่ของคนเดิม — ตรวจ exp แล้วขอ token เก็บเพิ่ม if (!ticketAlive) { return { ok: false, reason: "expired", message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", }; } try { session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac); } catch { return { ok: false, reason: "token-request-failed", message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", }; } } ticketService.saveSession(session); } currentFid = ticket.fid; currentTicket = { data: ticketData, hmac: ticketHmac }; username = ticket.uname; demoMode = false; // sign-in จริงเคลียร์โหมด demo (ถ้าเคยเปิดค้างใน page เดียวกัน) return { ok: true }; } /** * ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน (ticket ต้องยังไม่หมดอายุ) — * เรียกจาก apiClient ตอนโดน 401 ก่อน throw AuthRequiredError 1 รอบ * คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่) */ export async function renewTokenFromTicket(): Promise { if (currentTicket === null || currentFid === null) return false; const parsed = parseDocverTicket(currentTicket.data); if (!parsed.ok || isTicketExpired(parsed.ticket.exp)) return false; try { const entry = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac); const session = ticketService.loadSession() ?? { userId: parsed.ticket.uid, userName: parsed.ticket.uname, tokens: {} }; session.userName = parsed.ticket.uname; session.tokens[parsed.ticket.fid] = entry; ticketService.saveSession(session); return true; } catch { return false; } } /** Bearer ของ apiClient — token ของ fid ปัจจุบัน (stale ได้ — apiClient เป็นคนตัดสิน 401) */ export async function getAccessToken(): Promise { if (currentFid !== null) { return ticketService.loadSession()?.tokens[currentFid]?.token ?? null; } // demo mode (?addindemo) — ไม่มี ticket ใช้ token ปลอมเหมือนเดิม return username ? `mock-token-${username}` : null; } export function getUsername(): string | undefined { return username ?? undefined; }