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.
165 lines
7 KiB
TypeScript
165 lines
7 KiB
TypeScript
/**
|
|
* 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 } };
|
|
}
|