177 lines
7.4 KiB
TypeScript
177 lines
7.4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 };
|
||
|
|
}
|