102 lines
7.4 KiB
TypeScript
102 lines
7.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
oidcLoginAttempt: { deleteMany: vi.fn(), create: vi.fn(), findUnique: vi.fn(), delete: vi.fn() },
|
|
externalIdentity: { findUnique: vi.fn(), update: vi.fn(), upsert: vi.fn(), deleteMany: vi.fn() },
|
|
user: { update: vi.fn(), findUniqueOrThrow: vi.fn() },
|
|
transaction: vi.fn(async (input: unknown) => Array.isArray(input) ? Promise.all(input) : undefined),
|
|
}));
|
|
|
|
vi.mock("../src/db/prisma.js", () => ({ prisma: { oidcLoginAttempt: mocks.oidcLoginAttempt, externalIdentity: mocks.externalIdentity, user: mocks.user, $transaction: mocks.transaction } }));
|
|
const { oidcLoginAttempt, externalIdentity, user } = mocks;
|
|
|
|
import { loadEnv } from "../src/config/env.js";
|
|
import { consumeEntraAttempt, createEntraAuthorization, exchangeEntraCode, getEntraLinkStatus, isEntraConfigured, linkEntraIdentity, resolveEntraUser, unlinkEntraIdentity } from "../src/security/entraOidc.js";
|
|
|
|
const tenantId = "11111111-1111-4111-8111-111111111111";
|
|
const clientId = "22222222-2222-4222-8222-222222222222";
|
|
const baseInput = {
|
|
NODE_ENV: "test", DATABASE_URL: "postgresql://example.invalid/projectbrain", PROJECTBRAIN_DATA_SOURCE: "database",
|
|
PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: "correct-horse-battery-staple",
|
|
PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_PUBLIC_URL: "https://projectbrain.example.com",
|
|
PROJECTBRAIN_ENTRA_TENANT_ID: tenantId, PROJECTBRAIN_ENTRA_CLIENT_ID: clientId, PROJECTBRAIN_ENTRA_CLIENT_SECRET: "unit-test-client-secret",
|
|
};
|
|
const env = () => loadEnv(baseInput);
|
|
const profile = { tenantId, subject: "33333333-3333-4333-8333-333333333333", displayName: "Entra Owner", preferredUsername: "owner@example.com" };
|
|
const storedUser = { id: "owner-id", username: "owner", role: "owner" as const, credentialVersion: 1, disabledAt: null };
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
oidcLoginAttempt.deleteMany.mockResolvedValue({ count: 0 });
|
|
oidcLoginAttempt.create.mockResolvedValue({ id: "attempt-id" });
|
|
oidcLoginAttempt.delete.mockResolvedValue({ id: "attempt-id" });
|
|
externalIdentity.update.mockResolvedValue({ id: "identity-id" });
|
|
externalIdentity.upsert.mockResolvedValue({ id: "identity-id" });
|
|
externalIdentity.deleteMany.mockResolvedValue({ count: 1 });
|
|
user.update.mockResolvedValue(storedUser);
|
|
user.findUniqueOrThrow.mockResolvedValue(storedUser);
|
|
});
|
|
|
|
describe("Microsoft Entra OIDC service", () => {
|
|
it("creates a tenant-bound authorization URL with PKCE and stores only hashed state", async () => {
|
|
expect(isEntraConfigured(env())).toBe(true);
|
|
expect(isEntraConfigured(loadEnv({ ...baseInput, PROJECTBRAIN_ENTRA_TENANT_ID: undefined, PROJECTBRAIN_ENTRA_CLIENT_ID: undefined, PROJECTBRAIN_ENTRA_CLIENT_SECRET: undefined }))).toBe(false);
|
|
const authorization = new URL(await createEntraAuthorization(env(), { purpose: "login", returnTo: "//evil.example/path" }));
|
|
expect(authorization.hostname).toBe("login.microsoftonline.com");
|
|
expect(authorization.pathname).toContain(tenantId);
|
|
expect(authorization.searchParams.get("code_challenge_method")).toBe("S256");
|
|
const rawState = authorization.searchParams.get("state")!;
|
|
const stored = oidcLoginAttempt.create.mock.calls[0][0].data;
|
|
expect(stored.stateHash).not.toBe(rawState);
|
|
expect(stored.stateHash).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(stored.returnTo).toBe("/");
|
|
await expect(createEntraAuthorization(env(), { purpose: "link" })).rejects.toThrow("ENTRA_LINK_USER_REQUIRED");
|
|
});
|
|
|
|
it("consumes valid state exactly once and rejects missing or expired attempts", async () => {
|
|
oidcLoginAttempt.findUnique.mockResolvedValueOnce(null);
|
|
expect(await consumeEntraAttempt(env(), "missing-state")).toBeUndefined();
|
|
oidcLoginAttempt.findUnique.mockResolvedValueOnce({ id: "expired", provider: "microsoft-entra", purpose: "login", userId: null, codeVerifier: "verifier", nonce: "nonce", returnTo: "/settings", expiresAt: new Date(Date.now() - 1_000) });
|
|
expect(await consumeEntraAttempt(env(), "expired-state")).toBeUndefined();
|
|
oidcLoginAttempt.findUnique.mockResolvedValueOnce({ id: "valid", provider: "microsoft-entra", purpose: "link", userId: "owner-id", codeVerifier: "verifier", nonce: "nonce", returnTo: "/settings", expiresAt: new Date(Date.now() + 60_000) });
|
|
await expect(consumeEntraAttempt(env(), "valid-state")).resolves.toEqual({ purpose: "link", userId: "owner-id", codeVerifier: "verifier", nonce: "nonce", returnTo: "/settings" });
|
|
expect(oidcLoginAttempt.delete).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("validates Microsoft signature, issuer, audience, tenant, nonce and oid", async () => {
|
|
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
|
const publicJwk = { ...await exportJWK(publicKey), kid: "unit-key", alg: "RS256", use: "sig" };
|
|
const sign = (nonce: string, oid = profile.subject) => new SignJWT({ tid: tenantId, oid, nonce, name: profile.displayName, preferred_username: profile.preferredUsername })
|
|
.setProtectedHeader({ alg: "RS256", kid: "unit-key" }).setIssuer(`https://login.microsoftonline.com/${tenantId}/v2.0`).setAudience(clientId).setIssuedAt().setExpirationTime("5m").sign(privateKey);
|
|
const idToken = await sign("expected-nonce");
|
|
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
|
|
const url = String(input instanceof Request ? input.url : input);
|
|
return url.endsWith("/token")
|
|
? new Response(JSON.stringify({ id_token: idToken }), { status: 200, headers: { "content-type": "application/json" } })
|
|
: new Response(JSON.stringify({ keys: [publicJwk] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
}));
|
|
try {
|
|
await expect(exchangeEntraCode(env(), "code", { purpose: "login", codeVerifier: "verifier", nonce: "expected-nonce", returnTo: "/" })).resolves.toEqual(profile);
|
|
await expect(exchangeEntraCode(env(), "code", { purpose: "login", codeVerifier: "verifier", nonce: "wrong-nonce", returnTo: "/" })).rejects.toThrow("ENTRA_NONCE_INVALID");
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
}, 15_000);
|
|
|
|
it("resolves, links, reports and unlinks only durable mapped identities", async () => {
|
|
externalIdentity.findUnique.mockResolvedValueOnce(null);
|
|
expect(await resolveEntraUser(profile)).toBeUndefined();
|
|
externalIdentity.findUnique.mockResolvedValueOnce({ id: "identity-id", userId: storedUser.id, user: storedUser });
|
|
await expect(resolveEntraUser(profile)).resolves.toEqual({ id: storedUser.id, username: "owner", role: "owner", credentialVersion: 1 });
|
|
externalIdentity.findUnique.mockResolvedValueOnce({ id: "foreign", userId: "other-user" });
|
|
await expect(linkEntraIdentity(storedUser.id, profile)).rejects.toThrow("ENTRA_IDENTITY_ALREADY_LINKED");
|
|
externalIdentity.findUnique.mockResolvedValueOnce(null);
|
|
await expect(linkEntraIdentity(storedUser.id, profile)).resolves.toMatchObject({ id: storedUser.id });
|
|
externalIdentity.findUnique.mockResolvedValueOnce({ displayName: profile.displayName, preferredUsername: profile.preferredUsername });
|
|
await expect(getEntraLinkStatus(storedUser.id)).resolves.toEqual({ linked: true, displayName: profile.displayName, preferredUsername: profile.preferredUsername });
|
|
await unlinkEntraIdentity(storedUser.id);
|
|
expect(externalIdentity.deleteMany).toHaveBeenCalledWith({ where: { provider: "microsoft-entra", userId: storedUser.id } });
|
|
});
|
|
});
|