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>
176 lines
7.4 KiB
TypeScript
176 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 };
|
|
}
|