/** * Auth ของ add-in — e-Court ticket ทางเข้าเดียว (PLAN-ADDIN-AUTH.md §5 ตาม * docs/requirement/addin-workflow.md — ปรับ flow 2026-11-04) * * ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data` * + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ * ฟอร์แม็ต/exp เป็น UX gate ฝั่ง client (ไม่มี secret ใน bundle อีกต่อไปตาม * PLAN-SSO.md D13 — การตรวจ HMAC เป็นของ server อย่างเดียว ยิง /api/auth/token * ทุกครั้งที่เปิดเอกสาร) → แยกทางตาม contract: 200 = ได้ JWT ใหม่ / 400 ฟอร์แม็ตพัง / * 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ (มี JWT ของ fid ใน session * ที่ยังไม่หมด = ใช้ต่อ ไม่มี = ขอไฟล์ใหม่จาก e-Court) * * token เก็บใน localStorage ตาม fid ("คีย์คือ fid") — อายุ 7 วัน แทนระบบ refresh * ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew ตอนโดน 401 กลาง session */ import { config } from "../config"; import { isTicketExpired, parseDocverTicket, type DocverTicket } 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 ตอนเปิดเอกสาร (flow 2026-11-04): * 1. ตรวจมี ticket_data + HmacSha256 ครบ (client) — ไม่ครบ = เตะเลย * 2. parse ฟอร์แม็ต (client UX gate) — พัง = เตะเลย * 3. pre-check: ticket หมดอายุ และ ไม่มี JWT ของ fid นี้ใน session (uid ตรงกัน) * = เตะเลย ไม่ยิง API (มีใน session = มีโอกาสเป็นไฟล์เก่าที่เคยแก้ → ไปให้ * server ตัดสินที่ขั้นถัดไป) * 4. ที่เหลือยิง /api/auth/token **ทุกครั้ง** ให้ server ตรวจลายเซ็น (HMAC) * แล้วแยกทางตาม contract: 200 ได้ JWT ใหม่ (user ต่าง = เคลียร์ session ทั้งก้อน * สร้างใหม่) · 403 ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT ของ fid ที่ยังไม่หมด * ใน session ก็ใช้ต่อ · 400/401 ฟอร์แม็ต/ลายเซ็นพัง = เตะ (ไฟล์ถูกแก้) */ 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 ในไฟล์ไม่ถูกต้อง — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court", }; } const ticket = parsed.ticket; const expiredMessage = { ok: false as const, reason: "expired" as const, message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", }; // snapshot session ก่อนยิง — "มีใน session" = ยูสเซอร์ + ไฟล์ (uid+fid) ตรงกับ ticket const session = ticketService.loadSession(); const inSession = session !== null && session.userId === ticket.uid && session.tokens[ticket.fid] !== undefined; // pre-check: หมดอายุ + ไม่มีใน session = เตะโดยไม่ยิง API (ลดการเรียกใช้ network) if (isTicketExpired(ticket.exp) && !inSession) { return expiredMessage; } // ยิงทุกครั้งที่เปิดเอกสาร — signature ถูกตรวจที่ server เท่านั้น (ไม่มี secret ฝั่ง client) const result = await ticketService.requestDocverToken(ticketData, ticketHmac); if (!result.ok) { if (result.kind === "http" && result.status === 400) { return { ok: false, reason: "tampered", message: "รูปแบบ ticket ในไฟล์ไม่ถูกต้อง — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court", }; } if (result.kind === "http" && result.status === 401) { return { ok: false, reason: "tampered", message: "ตรวจสอบลายเซ็น (HMAC) ของ ticket กับระบบไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court", }; } if (result.kind === "http" && result.status === 403) { // ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT ของ fid (uid ตรง) ที่ยังไม่หมด = ใช้ต่อ if (session !== null && session.userId === ticket.uid) { const entry = session.tokens[ticket.fid]; if (entry !== undefined && !ticketService.isTokenExpired(entry)) { session.userName = ticket.uname; // refresh ชื่อตามไฟล์ล่าสุด ticketService.saveSession(session); return finishSignIn(ticket, ticketData, ticketHmac); } } return expiredMessage; } // network/malformed/5xx และอื่น ๆ — ไม่ใช่ความผิดของไฟล์ ลองใหม่ได้เมื่อเน็ตกลับมา return { ok: false, reason: "token-request-failed", message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", }; } // 200 — user ใหม่ = เคลียร์ session เก่าทั้งก้อน (requirement ขั้น 6) สร้างใหม่ · // คนเดิม = update userName (ชื่อเปลี่ยนได้) + เก็บ token ใหม่ตาม fid if (session === null || session.userId !== ticket.uid) { ticketService.clearSession(); ticketService.saveSession({ userId: ticket.uid, userName: ticket.uname, tokens: { [ticket.fid]: result.entry } }); } else { session.userName = ticket.uname; session.tokens[ticket.fid] = result.entry; ticketService.saveSession(session); } return finishSignIn(ticket, ticketData, ticketHmac); } /** ท้ายร่วมของทุกทางที่ผ่าน — ตั้ง module state ของ taskpane instance นี้ */ function finishSignIn(ticket: DocverTicket, ticketData: string, ticketHmac: string): SignInResult { currentFid = ticket.fid; currentTicket = { data: ticketData, hmac: ticketHmac }; username = ticket.uname; demoMode = false; // sign-in จริงเคลียร์โหมด demo (ถ้าเคยเปิดค้างใน page เดียวกัน) return { ok: true }; } /** * ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน — เรียกจาก apiClient ตอนโดน 401 * ก่อน throw AuthRequiredError 1 รอบ · ไม่ตัดสิน exp ของ ticket ฝั่งนี้ (server * เป็นคนตอบ: 200 = ออกให้ใหม่ · 403 = ticket หมดอายุ → false) * คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่) */ export async function renewTokenFromTicket(): Promise { if (currentTicket === null || currentFid === null) return false; const parsed = parseDocverTicket(currentTicket.data); if (!parsed.ok) return false; const result = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac); if (!result.ok) return false; const session = ticketService.loadSession() ?? { userId: parsed.ticket.uid, userName: parsed.ticket.uname, tokens: {} }; session.userName = parsed.ticket.uname; session.tokens[parsed.ticket.fid] = result.entry; ticketService.saveSession(session); return true; } /** 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; } /** fid ของเอกสารปัจจุบัน (จาก ticket ที่ sign-in ผ่าน) — null = ยังไม่ได้ sign-in หรือโหมด demo * ใช้เป็น tracking id ตอน Start Versioning (versionFlow.resolveTrackingId, 2026-11-05) */ export function getCurrentFid(): string | null { return currentFid; }