Files
2026-07-13 13:04:05 +08:00

194 lines
6.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as jose from "jose";
vi.mock("jose", async () => {
const actual = await vi.importActual<typeof jose>("jose");
return {
...actual,
createRemoteJWKSet: vi.fn(
() => "fake-jwks" as unknown as ReturnType<typeof jose.createRemoteJWKSet>
),
jwtVerify: vi.fn(),
};
});
const TENANT_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const ENTRA_ISSUER = `https://login.microsoftonline.com/${TENANT_ID}/v2.0`;
const AUDIENCE = "6ff6a635-03d9-472d-a7f1-dc98a4e5fde2";
async function loadModule() {
vi.resetModules();
return import("../src/lib/jwt.js");
}
function makeFetchResponse(init: Partial<Response> & { jsonData?: unknown }): Response {
const { jsonData, ...rest } = init;
return {
ok: true,
status: 200,
json: async () => jsonData,
...rest,
} as Response;
}
function makeEntraToken(payload: jose.JWTPayload): string {
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${body}.signature`;
}
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("isJWT", () => {
test("returns true for 3-part dotted strings", async () => {
const { isJWT } = await loadModule();
expect(isJWT("a.b.c")).toBe(true);
});
test("returns false for non-JWT strings", async () => {
const { isJWT } = await loadModule();
expect(isJWT("not-a-jwt")).toBe(false);
expect(isJWT("only.two")).toBe(false);
expect(isJWT("a.b.c.d")).toBe(false);
});
});
describe("validateJWT - Entra path", () => {
test("validates token after fetching tenant config", async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce(
makeFetchResponse({
jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: "mcp.access" },
})
);
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: { oid: "user-oid", scp: "mcp.access" },
protectedHeader: { alg: "RS256" },
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
const { validateJWT } = await loadModule();
const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE }));
expect(result.valid).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain(`/v2/entra/config/${AUDIENCE}`);
});
test("returns 'Unknown audience' when config endpoint returns 404", async () => {
vi.mocked(fetch).mockResolvedValue(makeFetchResponse({ ok: false, status: 404 }));
const { validateJWT } = await loadModule();
const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER, aud: "unknown-aud" }));
expect(result.valid).toBe(false);
expect(result.error).toBe("Unknown audience");
expect(jose.jwtVerify).not.toHaveBeenCalled();
});
test("returns 'Missing required scope' when scp claim lacks the configured scope", async () => {
vi.mocked(fetch).mockResolvedValue(
makeFetchResponse({
jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: "mcp.access" },
})
);
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: { scp: "other.scope" },
protectedHeader: { alg: "RS256" },
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
const { validateJWT } = await loadModule();
const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE }));
expect(result.valid).toBe(false);
expect(result.error).toBe("Missing required scope");
});
test("returns 'Missing audience' for Entra issuer with no aud claim", async () => {
const { validateJWT } = await loadModule();
const result = await validateJWT(makeEntraToken({ iss: ENTRA_ISSUER }));
expect(result.valid).toBe(false);
expect(result.error).toBe("Missing audience");
expect(fetch).not.toHaveBeenCalled();
});
test("caches config across repeated audiences", async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValue(
makeFetchResponse({
jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: null },
})
);
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: { oid: "u" },
protectedHeader: { alg: "RS256" },
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
const { validateJWT } = await loadModule();
const token = makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE });
await validateJWT(token);
await validateJWT(token);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("does not cache transient 500 errors — next request retries", async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce(makeFetchResponse({ ok: false, status: 500 }));
fetchMock.mockResolvedValueOnce(
makeFetchResponse({
jsonData: { teamspaceId: "team-1", tenantId: TENANT_ID, requiredScope: null },
})
);
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: { oid: "u" },
protectedHeader: { alg: "RS256" },
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
const { validateJWT } = await loadModule();
const token = makeEntraToken({ iss: ENTRA_ISSUER, aud: AUDIENCE });
const first = await validateJWT(token);
expect(first.valid).toBe(false);
expect(first.error).toBe("Unknown audience");
const second = await validateJWT(token);
expect(second.valid).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe("validateJWT - Clerk path", () => {
test("verifies against Clerk JWKS for non-Entra issuers", async () => {
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: {},
protectedHeader: { alg: "RS256" },
} as unknown as Awaited<ReturnType<typeof jose.jwtVerify>>);
const { validateJWT } = await loadModule();
const result = await validateJWT(makeEntraToken({ iss: "https://clerk.context7.com" }));
expect(result.valid).toBe(true);
expect(fetch).not.toHaveBeenCalled();
});
test("returns 'Token expired' when jwtVerify throws JWTExpired", async () => {
vi.mocked(jose.jwtVerify).mockRejectedValue(
new jose.errors.JWTExpired("expired", { payload: {}, protectedHeader: { alg: "RS256" } })
);
const { validateJWT } = await loadModule();
const result = await validateJWT(makeEntraToken({ iss: "https://clerk.context7.com" }));
expect(result.valid).toBe(false);
expect(result.error).toBe("Token expired");
});
});