284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
|
|
import { createHash } from "crypto";
|
|||
|
|
import * as authService from "./authService";
|
|||
|
|
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
|
|||
|
|
|
|||
|
|
const TOKEN_ENDPOINT = "http://localhost:8080/realms/word-versioning/protocol/openid-connect/token";
|
|||
|
|
|
|||
|
|
function base64Url(buffer: Buffer): string {
|
|||
|
|
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface OfficeDialogMock {
|
|||
|
|
displayDialogAsync: jest.Mock;
|
|||
|
|
dialog: { close: jest.Mock };
|
|||
|
|
capturedUrl: () => string;
|
|||
|
|
sendMessage: (payload: unknown) => void;
|
|||
|
|
sendDialogEvent: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** mock Office.context.ui.displayDialogAsync + dialog event handlers (FR-8 flow ผ่าน Dialog API) */
|
|||
|
|
function installOfficeDialogMock(): OfficeDialogMock {
|
|||
|
|
const globalAny = globalThis as unknown as Record<string, unknown>;
|
|||
|
|
const messageHandlers: Array<(args: unknown) => void> = [];
|
|||
|
|
const eventHandlers: Array<(args: unknown) => void> = [];
|
|||
|
|
let capturedAuthUrl = "";
|
|||
|
|
|
|||
|
|
const dialog = {
|
|||
|
|
close: jest.fn(),
|
|||
|
|
addEventHandler: (eventType: string, handler: (args: unknown) => void) => {
|
|||
|
|
(eventType === "dialogMessageReceived" ? messageHandlers : eventHandlers).push(handler);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const displayDialogAsync = jest.fn(
|
|||
|
|
(startAddress: string, _options: unknown, callback: (result: unknown) => void) => {
|
|||
|
|
capturedAuthUrl = startAddress;
|
|||
|
|
callback({ status: "succeeded", value: dialog });
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
globalAny.Office = {
|
|||
|
|
EventType: {
|
|||
|
|
DialogMessageReceived: "dialogMessageReceived",
|
|||
|
|
DialogEventReceived: "dialogEventReceived",
|
|||
|
|
},
|
|||
|
|
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
|
|||
|
|
context: { ui: { displayDialogAsync } },
|
|||
|
|
} as unknown as typeof Office;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
displayDialogAsync,
|
|||
|
|
dialog: dialog as OfficeDialogMock["dialog"],
|
|||
|
|
capturedUrl: () => capturedAuthUrl,
|
|||
|
|
sendMessage: (payload: unknown) => {
|
|||
|
|
messageHandlers.forEach((handler) => handler({ message: JSON.stringify(payload) }));
|
|||
|
|
},
|
|||
|
|
sendDialogEvent: () => {
|
|||
|
|
eventHandlers.forEach((handler) => handler({ error: 12006 }));
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** รอจนกว่า login จะเรียก displayDialogAsync จริง (ต้องผ่าน crypto.subtle.digest ก่อน — ในเครื่องโหลดอาจช้ากว่า setTimeout(0)) */
|
|||
|
|
async function waitForDialog(office: OfficeDialogMock): Promise<void> {
|
|||
|
|
for (
|
|||
|
|
let attempt = 0;
|
|||
|
|
attempt < 200 && office.displayDialogAsync.mock.calls.length === 0;
|
|||
|
|
attempt++
|
|||
|
|
) {
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|||
|
|
}
|
|||
|
|
expect(office.displayDialogAsync).toHaveBeenCalled();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
beforeEach(() => {
|
|||
|
|
authService.signOut();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
afterEach(() => {
|
|||
|
|
authService.signOut(); // เคลียร์ auto-refresh timer กัน handle ค้าง
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("PKCE", () => {
|
|||
|
|
it("code_verifier ยาว 43–128 chars และอยู่ในชุดตัวอักษร base64url ที่ RFC 7636 อนุญาต", () => {
|
|||
|
|
for (let i = 0; i < 20; i++) {
|
|||
|
|
const verifier = authService.createCodeVerifier();
|
|||
|
|
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
|||
|
|
expect(verifier.length).toBeLessThanOrEqual(128);
|
|||
|
|
expect(verifier).toMatch(/^[A-Za-z0-9\-_]+$/);
|
|||
|
|
expect(verifier).not.toContain("=");
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("code_challenge = BASE64URL(SHA256(verifier)) — ตรวจกับ test vector ของ RFC 7636 appendix B", async () => {
|
|||
|
|
// vector จาก RFC 7636 เอง
|
|||
|
|
const challenge = await authService.createS256Challenge(
|
|||
|
|
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|||
|
|
);
|
|||
|
|
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("code_challenge ของ verifier สุ่มตรงกับที่คำนวณเองด้วย node crypto", async () => {
|
|||
|
|
const verifier = authService.createCodeVerifier();
|
|||
|
|
const expected = base64Url(createHash("sha256").update(verifier).digest());
|
|||
|
|
await expect(authService.createS256Challenge(verifier)).resolves.toBe(expected);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("buildAuthorizationUrl", () => {
|
|||
|
|
it("ประกอบ authorization URL ครบทุกพารามิเตอร์ (client_id/redirect/response_type/scope/state/PKCE)", () => {
|
|||
|
|
const url = new URL(
|
|||
|
|
authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" })
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
expect(url.origin).toBe("http://localhost:8080");
|
|||
|
|
expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth");
|
|||
|
|
expect(url.searchParams.get("client_id")).toBe("word-addin-officejs");
|
|||
|
|
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html");
|
|||
|
|
expect(url.searchParams.get("response_type")).toBe("code");
|
|||
|
|
expect(url.searchParams.get("scope")).toBe("openid");
|
|||
|
|
expect(url.searchParams.get("state")).toBe("the-state");
|
|||
|
|
expect(url.searchParams.get("code_challenge")).toBe("the-challenge");
|
|||
|
|
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("decodeJwtExpiry", () => {
|
|||
|
|
it("อ่าน exp จาก JWT payload เป็น ms epoch", () => {
|
|||
|
|
const expiresAt = Date.now() + 300_000;
|
|||
|
|
expect(authService.decodeJwtExpiry(makeFakeJwt(expiresAt))).toBe(
|
|||
|
|
Math.floor(expiresAt / 1000) * 1000
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("token ไม่ใช่รูป JWT → null", () => {
|
|||
|
|
expect(authService.decodeJwtExpiry("not-a-jwt")).toBeNull();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("login (Office Dialog + token exchange)", () => {
|
|||
|
|
it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => {
|
|||
|
|
const office = installOfficeDialogMock();
|
|||
|
|
let exchangedBody: URLSearchParams | null = null;
|
|||
|
|
const fetchMock = installFetchMock();
|
|||
|
|
fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => {
|
|||
|
|
expect(String(input)).toBe(TOKEN_ENDPOINT);
|
|||
|
|
exchangedBody = new URLSearchParams(String(init?.body));
|
|||
|
|
return mockResponse({
|
|||
|
|
status: 200,
|
|||
|
|
body: {
|
|||
|
|
access_token: makeFakeJwt(Date.now() + 300_000),
|
|||
|
|
refresh_token: "refresh-1",
|
|||
|
|
expires_in: 300,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const loginPromise = authService.login();
|
|||
|
|
await waitForDialog(office);
|
|||
|
|
|
|||
|
|
// authorization URL ที่ส่งเข้า dialog
|
|||
|
|
const authUrl = new URL(office.capturedUrl());
|
|||
|
|
const challenge = authUrl.searchParams.get("code_challenge") ?? "";
|
|||
|
|
const state = authUrl.searchParams.get("state") ?? "";
|
|||
|
|
expect(challenge).not.toBe("");
|
|||
|
|
expect(state).not.toBe("");
|
|||
|
|
|
|||
|
|
office.sendMessage({ code: "the-auth-code", state });
|
|||
|
|
await expect(loginPromise).resolves.toEqual({ ok: true });
|
|||
|
|
expect(authService.isTokenValid()).toBe(true);
|
|||
|
|
expect(office.dialog.close).toHaveBeenCalled();
|
|||
|
|
|
|||
|
|
// verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง)
|
|||
|
|
const verifier = exchangedBody?.get("code_verifier") ?? "";
|
|||
|
|
expect(exchangedBody?.get("grant_type")).toBe("authorization_code");
|
|||
|
|
expect(exchangedBody?.get("code")).toBe("the-auth-code");
|
|||
|
|
expect(exchangedBody?.get("client_id")).toBe("word-addin-officejs");
|
|||
|
|
expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("state ไม่ตรง (CSRF) → login fail และไม่ยิง token endpoint", async () => {
|
|||
|
|
const office = installOfficeDialogMock();
|
|||
|
|
const fetchMock = installFetchMock();
|
|||
|
|
|
|||
|
|
const loginPromise = authService.login();
|
|||
|
|
await waitForDialog(office);
|
|||
|
|
office.sendMessage({ code: "code", state: "tampered-state" });
|
|||
|
|
|
|||
|
|
const result = await loginPromise;
|
|||
|
|
expect(result).toEqual({ ok: false, error: expect.stringContaining("state") });
|
|||
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|||
|
|
expect(authService.isTokenValid()).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("ผู้ใช้ปิด dialog เอง (DialogEventReceived) → คืน error แบบ graceful", async () => {
|
|||
|
|
const office = installOfficeDialogMock();
|
|||
|
|
installFetchMock();
|
|||
|
|
|
|||
|
|
const loginPromise = authService.login();
|
|||
|
|
await waitForDialog(office);
|
|||
|
|
office.sendDialogEvent();
|
|||
|
|
|
|||
|
|
const result = await loginPromise;
|
|||
|
|
expect(result.ok).toBe(false);
|
|||
|
|
expect(authService.isTokenValid()).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("Keycloak ปฏิเสธ (error ใน callback) → ok:false พร้อมชื่อ error", async () => {
|
|||
|
|
const office = installOfficeDialogMock();
|
|||
|
|
installFetchMock();
|
|||
|
|
|
|||
|
|
const loginPromise = authService.login();
|
|||
|
|
await waitForDialog(office);
|
|||
|
|
office.sendMessage({ error: "access_denied", errorDescription: "user cancelled" });
|
|||
|
|
|
|||
|
|
const result = await loginPromise;
|
|||
|
|
expect(result).toEqual({ ok: false, error: expect.stringContaining("access_denied") });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("เรียก login ซ้อนพร้อมกัน → เปิด dialog ครั้งเดียว ได้ผลเดียวกันทั้งสอง promise", async () => {
|
|||
|
|
const office = installOfficeDialogMock();
|
|||
|
|
installFetchMock(async () =>
|
|||
|
|
mockResponse({
|
|||
|
|
status: 200,
|
|||
|
|
body: { access_token: makeFakeJwt(Date.now() + 300_000), expires_in: 300 },
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const first = authService.login();
|
|||
|
|
const second = authService.login();
|
|||
|
|
await waitForDialog(office);
|
|||
|
|
|
|||
|
|
const state = new URL(office.capturedUrl()).searchParams.get("state") ?? "";
|
|||
|
|
office.sendMessage({ code: "code", state });
|
|||
|
|
|
|||
|
|
await expect(first).resolves.toEqual({ ok: true });
|
|||
|
|
await expect(second).resolves.toEqual({ ok: true });
|
|||
|
|
expect(office.displayDialogAsync).toHaveBeenCalledTimes(1);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("silent refresh (AC-9)", () => {
|
|||
|
|
it("access token หมดอายุ + refresh token ใช้ได้ → getAccessToken refresh เงียบๆ แล้วคืน token ใหม่", async () => {
|
|||
|
|
authService.setSessionForTest("expired-access-token", "refresh-old", Date.now() - 60_000);
|
|||
|
|
const newToken = makeFakeJwt(Date.now() + 600_000);
|
|||
|
|
const fetchMock = installFetchMock(async () =>
|
|||
|
|
mockResponse({
|
|||
|
|
status: 200,
|
|||
|
|
body: { access_token: newToken, refresh_token: "refresh-new", expires_in: 600 },
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const token = await authService.getAccessToken();
|
|||
|
|
|
|||
|
|
expect(token).toBe(newToken);
|
|||
|
|
expect(authService.isTokenValid()).toBe(true);
|
|||
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|||
|
|
const body = new URLSearchParams(String((fetchMock.mock.calls[0][1] as { body: string }).body));
|
|||
|
|
expect(body.get("grant_type")).toBe("refresh_token");
|
|||
|
|
expect(body.get("refresh_token")).toBe("refresh-old");
|
|||
|
|
expect(body.get("client_id")).toBe("word-addin-officejs");
|
|||
|
|
// Keycloak หมุน refresh token — เก็บอันใหม่ไว้ใช้รอบหน้า
|
|||
|
|
expect(authService.hasRefreshToken()).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("refresh token ตาย/ถูก revoke → getAccessToken คืน null ไม่ throw และล้าง session", async () => {
|
|||
|
|
authService.setSessionForTest("expired-access-token", "refresh-dead", Date.now() - 60_000);
|
|||
|
|
const fetchMock = installFetchMock(async () =>
|
|||
|
|
mockResponse({ status: 400, text: "invalid_grant" })
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await expect(authService.getAccessToken()).resolves.toBeNull();
|
|||
|
|
expect(authService.isTokenValid()).toBe(false);
|
|||
|
|
expect(authService.hasRefreshToken()).toBe(false);
|
|||
|
|
|
|||
|
|
// เรียกซ้ำหลัง session ถูกล้าง → null ทันทีโดยไม่ยิง fetch เพิ่ม
|
|||
|
|
await expect(authService.getAccessToken()).resolves.toBeNull();
|
|||
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("ไม่เคย login เลย → isTokenValid false, getAccessToken null", async () => {
|
|||
|
|
expect(authService.isTokenValid()).toBe(false);
|
|||
|
|
await expect(authService.getAccessToken()).resolves.toBeNull();
|
|||
|
|
});
|
|||
|
|
});
|