All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m22s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
- ticketService.requestDocverToken fetches withBasePath("/api/auth/token")
instead of /api/documents/token — same handler, shorter URL
- update referencing comments in authService/apiClient/config
- ticket-auth-flow test now asserts the actual URL hit, guarding a silent
revert to the old path
- versioning endpoints (/api/documents/versions*) unchanged — no short-path
equivalents exist
Co-Authored-By: Claude Code <noreply@anthropic.com>
197 lines
12 KiB
TypeScript
197 lines
12 KiB
TypeScript
/**
|
|
* 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<SignInResult> {
|
|
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<boolean> {
|
|
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<string | null> {
|
|
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;
|
|
}
|