Refactor authentication flow and ticket handling in add-in
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m23s
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 0s

- Removed client-side HMAC verification; server now handles all HMAC checks.
- Updated ticket processing logic to improve UX and security.
- Adjusted API request handling to return discriminated results based on HTTP status.
- Enhanced error messages for better clarity on authentication failures.
- Added unit tests for the new token endpoint to ensure compliance with updated contract.
- Cleaned up unused code and comments related to previous HMAC handling.
This commit is contained in:
Suphonchai Phoonsawat 2026-11-04 21:18:19 +07:00
parent 2e951f99f4
commit c0833c00f7
24 changed files with 858 additions and 460 deletions

View file

@ -1,21 +1,21 @@
/**
* Auth ของ add-in — e-Court ticket ทางเข้าเดียว (PLAN-ADDIN-AUTH.md §5 ตาม
* docs/requirement/addin-workflow.md)
* docs/requirement/addin-workflow.md — ปรับ flow 2026-11-04)
*
* ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data`
* + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ
* HMAC → session ใน localStorage ตาม uid/fid → ขอ access_token จาก
* /api/documents/token เก็บตาม fid ("คีย์คือ fid") · exp ของ ticket ไม่ใช่
* เกณฑ์ตัดสิทธิ์ฝั่ง client — server ออก token ให้แม้ ticket หมดอายุเมื่อ
* เอกสารนี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7)
* ฟอร์แม็ต/exp เป็น UX gate ฝั่ง client (ไม่มี secret ใน bundle อีกต่อไปตาม
* PLAN-SSO.md D13 — การตรวจ HMAC เป็นของ server อย่างเดียว ยิง /api/documents/token
* ทุกครั้งที่เปิดเอกสาร) → แยกทางตาม contract: 200 = ได้ JWT ใหม่ / 400 ฟอร์แม็ตพัง /
* 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ (มี JWT ของ fid ใน session
* ที่ยังไม่หมด = ใช้ต่อ ไม่มี = ขอไฟล์ใหม่จาก e-Court)
*
* token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่
* ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew
* ตอน token หมดอายุกลาง session
* token เก็บใน localStorage ตาม fid ("คีย์คือ fid") — อายุ 7 วัน แทนระบบ refresh
* ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew ตอนโดน 401 กลาง session
*/
import { config } from "../config";
import { isTicketExpired, parseDocverTicket } from "@/utils/docver-ticket";
import { isTicketExpired, parseDocverTicket, type DocverTicket } from "@/utils/docver-ticket";
import * as ticketService from "./ticketService";
/** สาเหตุที่เข้าไม่ได้ — UI แยกข้อความตาม reason (TaskpaneApp) */
@ -46,13 +46,16 @@ export function isDemoMode(): boolean {
}
/**
* e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7):
* 1. ตรวจมี ticket_data + HmacSha256 ครบ
* 2. parse + ตรวจ HMAC ฝั่ง client (UX gate)
* 3. session ใน localStorage ตาม uid/fid — token ยังใช้ได้ = ผ่านเลย · ต้องขอ
* token ใหม่ = ยิงขอเสมอแล้วให้ **server เป็นคนตัดสิน** (ticket หมดอายุแต่
* เอกสารนี้เคยมี version โดย uid เดียวกัน = ออกให้ต่อ — requirement ขั้น 7
* กลับมาทำเอกสารเดิมได้แม้ ticket หมดอายุ แม้ session หายไปจากเครื่อง)
* 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/documents/token **ทุกครั้ง** ให้ server ตรวจลายเซ็น (HMAC)
* แล้วแยกทางตาม contract: 200 ได้ JWT ใหม่ (user ต่าง = เคลียร์ session ทั้งก้อน
* สร้างใหม่) · 403 ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT ของ fid ที่ยังไม่หมด
* ใน session ก็ใช้ต่อ · 400/401 ฟอร์แม็ต/ลายเซ็นพัง = เตะ (ไฟล์ถูกแก้)
*/
export async function signInWithTicket(input: {
ticketData?: string;
@ -74,14 +77,7 @@ export async function signInWithTicket(input: {
return {
ok: false,
reason: "tampered",
message: "ตรวจสอบ ticket ไม่ผ่าน (รูปแบบไม่ถูกต้อง) — ไฟล์ถูกแก้ไขหรือเสียหาย",
};
}
if (!(await ticketService.verifyTicketHmacWeb(ticketData, ticketHmac, config.ticketSecret))) {
return {
ok: false,
reason: "tampered",
message: "ตรวจสอบลายเซ็น (HMAC) ของ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไข",
message: "รูปแบบ ticket ในไฟล์ไม่ถูกต้อง — ไฟล์ถูกแก้ไขหรือเสียหาย โปรดขอไฟล์ใหม่จาก e-Court",
};
}
const ticket = parsed.ticket;
@ -91,56 +87,68 @@ export async function signInWithTicket(input: {
message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง",
};
// snapshot session ก่อนยิง — "มีใน session" = ยูสเซอร์ + ไฟล์ (uid+fid) ตรงกับ ticket
const session = ticketService.loadSession();
if (session === null || session.userId !== ticket.uid) {
// ไม่มี session / user ใหม่ — ขอ token ใหม่เสมอ (แม้ ticket หมดอายุ: server
// ออกให้ต่อเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน ซึ่งช่วยเคส localStorage หาย
// ของคนที่กลับมาทำเอกสารเดิมด้วย) แล้วเริ่ม session ใหม่ทั้งก้อน
let entry: ticketService.SessionTokenEntry;
try {
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
} catch {
return isTicketExpired(ticket.exp)
? expiredMessage
: {
ok: false,
reason: "token-request-failed",
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
};
}
ticketService.clearSession();
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 เสมอแล้วให้ server ตัดสิน (ticket ยังไม่หมด =
// ออกให้เสมอ · หมดแล้ว = ออกเฉพาะเอกสารนี้เคยทำงานโดย uid เดียวกัน)
try {
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
session.tokens[ticket.fid] = entry;
} catch {
// ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access
}
}
if (entry === null) {
// เอกสารใหม่ของคนเดิม — ขอ token เก็บเพิ่ม (server ตัดสินเรื่อง ticket หมดอายุ)
try {
session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac);
} catch {
return isTicketExpired(ticket.exp)
? expiredMessage
: {
ok: false,
reason: "token-request-failed",
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
};
}
}
ticketService.saveSession(session);
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;
@ -151,26 +159,22 @@ export async function signInWithTicket(input: {
/**
* ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน — เรียกจาก apiClient ตอนโดน 401
* ก่อน throw AuthRequiredError 1 รอบ · ไม่ตัดสิน exp ของ ticket ฝั่งนี้ (server
* ออกให้ต่อได้แม้ ticket หมดอายุเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน —
* requirement ขั้น 7) 401 กลับมา = false ตามเดิม
* เป็นคนตอบ: 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;
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;
}
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) */