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 } }); }); });