eCourt-note/wordaddin/services/ticketService.ts

166 lines
7 KiB
TypeScript
Raw Normal View History

/**
* Ticket ของ add-in — ของมือถือ (primitives) ให้ authService ประกอบเป็น flow
* (PLAN-ADDIN-AUTH.md §5):
* - 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 และจริง) ·
* คืน discriminated result แยก network/http status/malformed ให้ caller
* ตัดสินตาม contract 400/401/403 (2026-11-04)
* - (ไม่มีการตรวจ HMAC ฝั่ง client อีกต่อไป — secret เป็นของ server อย่างเดียว
* ตาม PLAN-SSO.md D13, ถอด verifyTicketHmacWeb ออก 2026-11-04)
*/
import { withBasePath } from "@/utils/base-path";
/** 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;
}
// ---------------------------------------------------------------------------
// ขอ 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;
}
}
/** ผลของ requestDocverToken — แยกสาเหตุให้ caller ตัดสินตาม contract ไม่โยน exception */
export type RequestDocverTokenResult =
| { ok: true; entry: SessionTokenEntry }
/** fetch reject — ไม่ถึง server (offline/dev server ตาย) */
| { ok: false; kind: "network" }
/** ได้ response แต่ไม่ใช่ 200 — 400 ฟอร์แม็ตพัง / 401 ลายเซ็นไม่ถูก / 403 ลายเซ็นถูกแต่ ticket หมดอายุ */
| { ok: false; kind: "http"; status: number }
/** ตอบ 200 แต่ body ใช้ไม่ได้ (ไม่ใช่ JSON / ไม่มี access_token) */
| { ok: false; kind: "malformed" };
/**
* ขอ access_token ด้วย ticket — คืน discriminated result ไม่โยน Error:
* caller (authService) แยกทางตาม contract ของทางเข้า ticket ของ
* /api/documents/token (2026-11-04: 200 ผ่าน / 400 ฟอร์แม็ต / 401 ลายเซ็น /
* 403 ลายเซ็นถูกแต่ ticket หมดอายุ — มี JWT เดิมของ fid ใน session ใช้ต่อได้)
*/
export async function requestDocverToken(
ticketData: string,
ticketHmac: string,
): Promise<RequestDocverTokenResult> {
let response: Response;
try {
response = await fetch(withBasePath("/api/documents/token"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }),
});
} catch {
return { ok: false, kind: "network" };
}
if (!response.ok) {
return { ok: false, kind: "http", status: response.status };
}
let data: TokenResponse;
try {
data = (await response.json()) as TokenResponse;
} catch {
return { ok: false, kind: "malformed" };
}
if (typeof data.access_token !== "string" || data.access_token === "") {
return { ok: false, kind: "malformed" };
}
const exp =
jwtExp(data.access_token) ??
(typeof data.expires_in === "number" ? Math.floor(Date.now() / 1000) + data.expires_in : 0);
return { ok: true, entry: { token: data.access_token, exp } };
}