feat(addin): implement e-Court ticket auth (ticket_data + HmacSha256)
Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):
- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
{ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
wordaddin-auth keyed by fid, same-origin token request), authService
rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
metadataService reads ticket_data/HmacSha256, no-access messages per
cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
request bodies for /token), CLAUDE.md, .env.example
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
8ebe5f8644
commit
cffc571da5
21 changed files with 1725 additions and 124 deletions
|
|
@ -2,12 +2,19 @@
|
|||
* Config ของ wordaddin — ค่าจาก NEXT_PUBLIC_* env (Next inline ตอน build)
|
||||
* (ต้นฉบับ D:\word-addin-poc\addin inject ผ่าน webpack DefinePlugin — พอร์ตมาเป็น env ของ Next)
|
||||
*
|
||||
* ช่วง mock: authService/versioningService เป็น mock ไม่ใช้ apiBaseUrl —
|
||||
* ช่วง mock: versioningService เป็น mock ไม่ใช้ apiBaseUrl —
|
||||
* ตั้งค่านี้เมื่อปลด mock ต่อ backend จริง (ต้องเป็น HTTPS เสมอ — webview ของ Word บล็อก http)
|
||||
* ส่วนการขอ token (ticket auth) ยิง same-origin /api/documents/token เสมอ ไม่ผูกกับ apiBaseUrl
|
||||
*/
|
||||
export const config = {
|
||||
/** base URL ของ backend API (ไม่มี slash ท้าย) — ว่าง = ยังไม่ต่อ backend จริง */
|
||||
apiBaseUrl: process.env.NEXT_PUBLIC_ADDIN_API_BASE_URL ?? "",
|
||||
/** ชื่อผู้ใช้ของ mock auth — แสดงหน้า header + เป็น createdByUserId ของ version ที่ mock สร้าง */
|
||||
mockUsername: process.env.NEXT_PUBLIC_ADDIN_MOCK_USERNAME ?? "tester01",
|
||||
/**
|
||||
* secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate — ตัดเคสไฟล์ถูกแก้ก่อนยิง network;
|
||||
* ความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น เพราะค่าใน bundle สกัดได้) — ต้องตรงกับ
|
||||
* ตัว stamp ของ e-Court และ env ADDIN_TICKET_SECRET/SECRET ฝั่ง server (PLAN-ADDIN-AUTH.md §7)
|
||||
*/
|
||||
ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@ import { config } from "../config";
|
|||
import * as authService from "./authService";
|
||||
|
||||
/**
|
||||
* fetch wrapper ของ add-in — แนบ Bearer ทุก request (token จาก custom properties ของไฟล์)
|
||||
* โดน 401 → AuthRequiredError ทันที: token จากไฟล์ต่ออายุไม่ได้ (ไม่มี refresh token
|
||||
* ตั้งแต่ยกเลิก Keycloak) — UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
|
||||
* fetch wrapper ของ add-in — แนบ Bearer ทุก request (access_token ที่ authService
|
||||
* ขอจาก /api/documents/token ด้วย ticket ของไฟล์ — PLAN-ADDIN-AUTH.md)
|
||||
* โดน 401 → ลองขอ token ใหม่จาก ticket แล้ว retry 1 ครั้ง ยังไม่ผ่าน → AuthRequiredError:
|
||||
* UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
|
||||
* พอร์ตจาก D:\word-addin-poc\addin\src\services\apiClient.ts
|
||||
* (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError
|
||||
* ถูก TaskpaneApp ใช้อยู่แล้ว)
|
||||
*/
|
||||
|
||||
/** บอกว่า token จากไฟล์ใช้ไม่ได้ (ไม่มี / หมดอายุ / ไม่ถูกต้อง) — ต้องได้ไฟล์ใหม่จาก e-Court */
|
||||
/** บอกว่า ticket ใช้ไม่ได้แล้ว (หมดอายุ / token ขอไม่ได้) — ต้องได้ไฟล์ใหม่จาก e-Court */
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor(message = "token ของ e-Court หมดอายุหรือไม่ถูกต้อง") {
|
||||
constructor(message = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง") {
|
||||
super(message);
|
||||
this.name = "AuthRequiredError";
|
||||
}
|
||||
|
|
@ -54,8 +55,10 @@ async function doFetch(
|
|||
}
|
||||
|
||||
/**
|
||||
* request พร้อม Bearer — 401 = token จากไฟล์หมดอายุ/ไม่ถูกต้อง → AuthRequiredError
|
||||
* Response ที่ได้คืนไปให้ caller เช็ค ok เอง
|
||||
* request พร้อม Bearer — โดน 401 → ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน
|
||||
* (renewTokenFromTicket — ticket ยังไม่หมดอายุเท่านั้น) แล้ว retry 1 ครั้ง
|
||||
* ยัง 401 อีก / renew ไม่ได้ = AuthRequiredError (UI พาไป no-access ให้ขอไฟล์
|
||||
* ใหม่จาก e-Court) · Response ที่ได้คืนไปให้ caller เช็ค ok เอง
|
||||
*/
|
||||
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
|
||||
const accessToken = await authService.getAccessToken();
|
||||
|
|
@ -63,10 +66,19 @@ export async function apiFetch(path: string, options: ApiRequestOptions = {}): P
|
|||
throw new AuthRequiredError();
|
||||
}
|
||||
const response = await doFetch(path, options, accessToken);
|
||||
if (response.status === 401) {
|
||||
throw new AuthRequiredError();
|
||||
if (response.status !== 401) {
|
||||
return response;
|
||||
}
|
||||
return response;
|
||||
if (await authService.renewTokenFromTicket()) {
|
||||
const retryToken = await authService.getAccessToken();
|
||||
if (retryToken) {
|
||||
const retry = await doFetch(path, options, retryToken);
|
||||
if (retry.status !== 401) {
|
||||
return retry;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
|
||||
/** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */
|
||||
|
|
|
|||
|
|
@ -1,60 +1,176 @@
|
|||
/**
|
||||
* Auth ของ add-in — e-Court pass-through ทางเดียว (ยกเลิก Keycloak 2026-10-27 ตามคำขอผู้ใช้)
|
||||
* ไม่มีขั้นตอน login ใน add-in อีกต่อไป: ไฟล์ที่ e-Court stamp custom properties
|
||||
* `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = ใช้งานได้ทันที ไม่ต้องกดปุ่มใด ๆ
|
||||
* ไฟล์ที่ไม่มีทั้งคู่ = phase no-access ของ TaskpaneApp (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต)
|
||||
* Auth ของ add-in — e-Court ticket ทางเข้าเดียว (PLAN-ADDIN-AUTH.md §5 ตาม
|
||||
* docs/requirement/addin-workflow.md)
|
||||
*
|
||||
* token เก็บใน memory เท่านั้น — getAccessToken คืน docToken ให้ apiClient แนบ Bearer
|
||||
* (ช่วงไม่ต่อ backend จริง versioningService เป็น mock ไม่เรียก apiClient อยู่แล้ว)
|
||||
* ไม่มี 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";
|
||||
|
||||
/** state เดียว — null = ยังไม่เคย pass-through สำเร็จ (จากไฟล์ หรือ ?addindemo) */
|
||||
/** สาเหตุที่เข้าไม่ได้ — 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;
|
||||
/** token จาก custom properties ของไฟล์ (e-Court pass-through) — เก็บใน memory เท่านั้น */
|
||||
let docToken: string | null = null;
|
||||
/** fid ของเอกสารปัจจุบัน — token ที่ใช้ผูกกับ fid นี้เสมอ */
|
||||
let currentFid: string | null = null;
|
||||
/** ticket ดิบของเอกสารปัจจุบัน — ไว้ขอ token ใหม่ตอน 401/token หมดอายุ */
|
||||
let currentTicket: { data: string; hmac: string } | null = null;
|
||||
|
||||
/** dev-only — โหมด ?addindemo ของ WordAddinGate ใช้ตั้ง session ทันทีไม่อ่านไฟล์จริง */
|
||||
export function signInForDemo(): void {
|
||||
username = config.mockUsername;
|
||||
}
|
||||
|
||||
/** ถอด field จาก JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ดู uid ประกอบชื่อผู้ใช้เท่านั้น */
|
||||
function decodeJwtField(token: string, field: string): string | undefined {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return undefined;
|
||||
try {
|
||||
// atob คืน binary string (1 char = 1 byte) — ต้องถอด UTF-8 ด้วย TextDecoder ก่อน JSON.parse
|
||||
// ไม่งั้น uid ที่ไม่ใช่ ASCII (เช่น ชื่อไทย) จะกลายเป็น mojibake
|
||||
const bytes = Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0));
|
||||
const json: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
||||
const value = typeof json === "object" && json !== null ? (json as Record<string, unknown>)[field] : undefined;
|
||||
return typeof value === "string" && value !== "" ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
currentFid = null;
|
||||
currentTicket = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* e-Court pass-through — จุดเข้าเดียวของ auth: ไฟล์ที่ stamp `token`/`owner` ไว้
|
||||
* username ได้จาก owner → uid ใน JWT → mockUsername (ตามลำดับ)
|
||||
* คืน true = ผ่านเข้าได้ (มี token หรือ owner อย่างน้อยหนึ่งตัว)
|
||||
* 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 function signInFromDocument(input: { token?: string; owner?: string }): boolean {
|
||||
const token = input.token?.trim() || undefined;
|
||||
const owner = input.owner?.trim() || (token ? decodeJwtField(token, "uid") : undefined);
|
||||
if (!token && !owner) {
|
||||
return false;
|
||||
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 เท่านั้น",
|
||||
};
|
||||
}
|
||||
username = owner ?? config.mockUsername;
|
||||
docToken = token ?? null;
|
||||
return true;
|
||||
|
||||
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;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Bearer ของ apiClient — docToken จากไฟล์ (ส่วน demo/mock คืน token ปลอม) */
|
||||
/**
|
||||
* ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน (ticket ต้องยังไม่หมดอายุ) —
|
||||
* เรียกจาก apiClient ตอนโดน 401 ก่อน throw AuthRequiredError 1 รอบ
|
||||
* คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่)
|
||||
*/
|
||||
export async function renewTokenFromTicket(): Promise<boolean> {
|
||||
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<string | null> {
|
||||
if (docToken) return docToken;
|
||||
if (currentFid !== null) {
|
||||
return ticketService.loadSession()?.tokens[currentFid]?.token ?? null;
|
||||
}
|
||||
// demo mode (?addindemo) — ไม่มี ticket ใช้ token ปลอมเหมือนเดิม
|
||||
return username ? `mock-token-${username}` : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,10 @@ export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
|
|||
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
|
||||
export const TEMPLATE_ID_PROPERTY = "TemplateId";
|
||||
|
||||
/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลย (auth ทางเข้าเดียว) */
|
||||
export const ECOURT_TOKEN_PROPERTY = "token";
|
||||
export const ECOURT_OWNER_PROPERTY = "owner";
|
||||
/** e-Court ticket — ไฟล์ที่ e-Court stamp คู่นี้มาใน custom properties คือ auth ทางเข้าเดียวของ add-in
|
||||
* (PLAN-ADDIN-AUTH.md §2: HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase 64 ตัว) */
|
||||
export const TICKET_DATA_PROPERTY = "ticket_data";
|
||||
export const TICKET_HMAC_PROPERTY = "HmacSha256";
|
||||
|
||||
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
|
||||
export interface TrackedDocumentProperties {
|
||||
|
|
@ -35,9 +36,9 @@ export interface TrackedDocumentProperties {
|
|||
lastVersionTimestamp?: string;
|
||||
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
|
||||
templateId?: string;
|
||||
/** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ใช้งานได้ทันที (username จาก owner หรือ uid ใน token) */
|
||||
ecourtToken?: string;
|
||||
ecourtOwner?: string;
|
||||
/** e-Court ticket — มีครบทั้งคู่ = ตรวจ HMAC แล้วเข้าใช้งานได้ (auth ทางเข้าเดียว) */
|
||||
ticketData?: string;
|
||||
ticketHmac?: string;
|
||||
}
|
||||
|
||||
/** item ใน Custom XML Part `<VersionHistory>` */
|
||||
|
|
@ -49,7 +50,7 @@ export interface XmlVersionEntry {
|
|||
}
|
||||
|
||||
/** อ่าน custom properties ทั้ง 7 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16
|
||||
* + token/owner ที่ e-Court stamp มากับไฟล์) */
|
||||
* + ticket_data/HmacSha256 ที่ e-Court stamp มากับไฟล์) */
|
||||
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
|
||||
return Word.run(async (context) => {
|
||||
const properties = context.document.properties.customProperties;
|
||||
|
|
@ -58,16 +59,16 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
|
|||
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
|
||||
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
|
||||
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
|
||||
const ecourtToken = properties.getItemOrNullObject(ECOURT_TOKEN_PROPERTY);
|
||||
const ecourtOwner = properties.getItemOrNullObject(ECOURT_OWNER_PROPERTY);
|
||||
const ticketData = properties.getItemOrNullObject(TICKET_DATA_PROPERTY);
|
||||
const ticketHmac = properties.getItemOrNullObject(TICKET_HMAC_PROPERTY);
|
||||
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
|
||||
savedByAddIn.load("value");
|
||||
documentTrackingId.load("value");
|
||||
lastVersionId.load("value");
|
||||
lastVersionTimestamp.load("value");
|
||||
templateId.load("value");
|
||||
ecourtToken.load("value");
|
||||
ecourtOwner.load("value");
|
||||
ticketData.load("value");
|
||||
ticketHmac.load("value");
|
||||
await context.sync();
|
||||
|
||||
const read = (property: Word.CustomProperty): string | undefined =>
|
||||
|
|
@ -81,8 +82,8 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
|
|||
lastVersionId: read(lastVersionId),
|
||||
lastVersionTimestamp: read(lastVersionTimestamp),
|
||||
templateId: read(templateId),
|
||||
ecourtToken: read(ecourtToken),
|
||||
ecourtOwner: read(ecourtOwner),
|
||||
ticketData: read(ticketData),
|
||||
ticketHmac: read(ticketHmac),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
176
wordaddin/services/ticketService.ts
Normal file
176
wordaddin/services/ticketService.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Ticket ของ add-in — ของมือถือ (primitives) ให้ authService ประกอบเป็น flow
|
||||
* (PLAN-ADDIN-AUTH.md §5):
|
||||
* - verifyTicketHmacWeb: ตรวจ HMAC ฝั่ง client ด้วย Web Crypto (UX gate — ตัดเคส
|
||||
* ไฟล์ถูกแก้ก่อนยิง network ส่วนความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น)
|
||||
* - 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 และจริง)
|
||||
*/
|
||||
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
import { normalizeHexDigest } from "@/utils/docver-ticket";
|
||||
|
||||
/** 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<string, SessionTokenEntry>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HMAC ฝั่ง client — Web Crypto (crypto.subtle ให้ promise เสมอ)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** คำนวณ HMAC-SHA256 คืน hex lowercase — รูปแบบเดียวกับ generateHmacSha256 ฝั่ง server */
|
||||
export async function hmacSha256HexWeb(message: string, secret: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
|
||||
return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจ HMAC ของ ticket ฝั่ง client — message คือ string ดิบของ ticket_data ตามที่อ่านจาก
|
||||
* property (ห้าม trim — ตัว stamp เซ็นบนค่าดิบ) · เทียบหลัง normalize lowercase
|
||||
*/
|
||||
export async function verifyTicketHmacWeb(ticketData: string, expectedHex: string, secret: string): Promise<boolean> {
|
||||
const expected = normalizeHexDigest(expectedHex);
|
||||
if (expected === null) return false;
|
||||
try {
|
||||
return (await hmacSha256HexWeb(ticketData, secret)) === expected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ขอ 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<string, unknown>).exp : undefined;
|
||||
return typeof exp === "number" && Number.isFinite(exp) ? exp : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ขอ access_token ด้วย ticket — โยน Error เมื่อ status ไม่สำเร็จ
|
||||
* (401 = ticket ไม่ผ่านการตรวจฝั่ง server — ให้ caller ไป no-access ตาม flow)
|
||||
*/
|
||||
export async function requestDocverToken(ticketData: string, ticketHmac: string): Promise<SessionTokenEntry> {
|
||||
const response = await fetch(withBasePath("/api/documents/token"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`ขอ token ไม่สำเร็จ (HTTP ${response.status})`);
|
||||
}
|
||||
let data: TokenResponse;
|
||||
try {
|
||||
data = (await response.json()) as TokenResponse;
|
||||
} catch {
|
||||
throw new Error("ขอ token ไม่สำเร็จ (response ไม่ใช่ JSON)");
|
||||
}
|
||||
if (typeof data.access_token !== "string" || data.access_token === "") {
|
||||
throw new Error("ขอ token ไม่สำเร็จ (ไม่มี access_token)");
|
||||
}
|
||||
const exp =
|
||||
jwtExp(data.access_token) ??
|
||||
(typeof data.expires_in === "number" ? Math.floor(Date.now() / 1000) + data.expires_in : 0);
|
||||
return { token: data.access_token, exp };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue