import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, describe, expect, it, test } from "@effect/vitest"; import { createEmulator } from "@executor-js/emulate"; import { mintInviteCode } from "../testing/mint-invite"; // Real Better Auth path with an SSO provider configured — and a REAL IdP on the // wire: an @executor-js/emulate Okta instance serving OIDC discovery, // authorize, and token, so the round-trip tests below exercise the same // discovery -> redirect -> consent -> callback flow a production Google/Okta // deployment does. Emulator + registered client + env must all exist before // the app import, so `loadConfig` sees a fully-configured instance at boot. const BASE = "http://localhost:4788"; const CALLBACK = `${BASE}/api/auth/oauth2/callback/okta`; const idp = await createEmulator({ service: "okta", port: 4790 }); afterAll(() => idp.close()); const registration = await fetch(`${idp.url}/oauth2/v1/clients`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_name: "executor-selfhost-test", redirect_uris: [CALLBACK], grant_types: ["authorization_code"], response_types: ["code"], token_endpoint_auth_method: "client_secret_post", }), }).then((r) => r.json() as Promise<{ client_id: string; client_secret: string }>); const createIdpUser = (email: string, firstName: string) => fetch(`${idp.url}/api/v1/users`, { method: "POST", headers: { "content-type": "application/json", authorization: "SSWS test" }, body: JSON.stringify({ profile: { login: email, email, firstName, lastName: "User" } }), }); await createIdpUser("alice@example.com", "Alice"); await createIdpUser("mallory@evil.test", "Mallory"); process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-sso-")); process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; process.env.EXECUTOR_SSO_PROVIDER_ID = "okta"; process.env.EXECUTOR_SSO_DISCOVERY_URL = `${idp.url}/.well-known/openid-configuration`; process.env.EXECUTOR_SSO_CLIENT_ID = registration.client_id; process.env.EXECUTOR_SSO_CLIENT_SECRET = registration.client_secret; process.env.EXECUTOR_SSO_ALLOWED_DOMAINS = "Example.com, @second.example ,"; const { loadConfig } = await import("../config"); const { emailDomain } = await import("./sso"); const { makeSelfHostApiHandler } = await import("../app"); const { handler, dispose } = await makeSelfHostApiHandler(); afterAll(() => dispose()); const SSO_ENV_KEYS = [ "EXECUTOR_SSO_PROVIDER_ID", "EXECUTOR_SSO_PROVIDER_NAME", "EXECUTOR_SSO_DISCOVERY_URL", "EXECUTOR_SSO_CLIENT_ID", "EXECUTOR_SSO_CLIENT_SECRET", "EXECUTOR_SSO_ALLOWED_DOMAINS", ] as const; // Run a block with the SSO env vars swapped out, restoring them afterwards so // the booted instance's request-time config reads stay consistent. const withSsoEnv = (overrides: Record, run: () => T): T => { const saved = Object.fromEntries(SSO_ENV_KEYS.map((k) => [k, process.env[k]])); for (const key of SSO_ENV_KEYS) { const value = overrides[key]; if (value === undefined) delete process.env[key]; else process.env[key] = value; } // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: env save/restore around config reads must restore on assertion failure try { return run(); } finally { for (const key of SSO_ENV_KEYS) { const value = saved[key]; if (value === undefined) delete process.env[key]; else process.env[key] = value; } } }; describe("sso config resolution", () => { it("normalizes the domain allowlist (trim, lowercase, strip @, drop empties)", () => { const sso = loadConfig().sso; expect(sso).toBeDefined(); expect(sso!.allowedDomains).toEqual(["example.com", "second.example"]); }); it("derives the display name from the provider id when not set", () => { expect(loadConfig().sso!.providerName).toBe("Okta"); }); it("is undefined when no SSO env is set", () => { withSsoEnv({}, () => { expect(loadConfig().sso).toBeUndefined(); }); }); it("refuses half-configured credentials", () => { withSsoEnv({ EXECUTOR_SSO_CLIENT_ID: "id-only" }, () => { expect(() => loadConfig()).toThrow(/must be set together/); }); }); it("refuses credentials without a provider id", () => { withSsoEnv({ EXECUTOR_SSO_CLIENT_ID: "id", EXECUTOR_SSO_CLIENT_SECRET: "secret" }, () => { expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_PROVIDER_ID/); }); }); it("presets Google's discovery document so only credentials + domains are needed", () => { withSsoEnv( { EXECUTOR_SSO_PROVIDER_ID: "google", EXECUTOR_SSO_CLIENT_ID: "id.apps.googleusercontent.com", EXECUTOR_SSO_CLIENT_SECRET: "secret", EXECUTOR_SSO_ALLOWED_DOMAINS: "example.com", }, () => { const sso = loadConfig().sso; expect(sso!.discoveryUrl).toBe( "https://accounts.google.com/.well-known/openid-configuration", ); expect(sso!.providerName).toBe("Google"); }, ); }); it("refuses a provider without a known or explicit discovery URL", () => { withSsoEnv( { EXECUTOR_SSO_PROVIDER_ID: "okta", EXECUTOR_SSO_CLIENT_ID: "id", EXECUTOR_SSO_CLIENT_SECRET: "secret", EXECUTOR_SSO_ALLOWED_DOMAINS: "example.com", }, () => { expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_DISCOVERY_URL/); }, ); }); it("refuses a configured provider without a domain allowlist", () => { withSsoEnv( { EXECUTOR_SSO_PROVIDER_ID: "okta", EXECUTOR_SSO_DISCOVERY_URL: "https://idp.example/.well-known/openid-configuration", EXECUTOR_SSO_CLIENT_ID: "id", EXECUTOR_SSO_CLIENT_SECRET: "secret", }, () => { expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_ALLOWED_DOMAINS/); }, ); }); }); describe("emailDomain", () => { it("extracts the lowercased domain of a well-formed address", () => { expect(emailDomain("User@Example.COM")).toBe("example.com"); }); it("returns null for malformed addresses instead of a matchable domain", () => { expect(emailDomain("@example.com")).toBeNull(); expect(emailDomain("user@")).toBeNull(); expect(emailDomain("no-at-sign")).toBeNull(); }); }); test("auth-config advertises the configured provider (id + name only)", async () => { const res = await handler(new Request(`${BASE}/api/auth-config`)); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ssoProviders: [{ id: "okta", name: "Okta" }] }); }); // --- The full OIDC round-trip against the emulated IdP ----------------------- const decodeHtml = (value: string): string => value .replaceAll("&", "&") .replaceAll(""", '"') .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">"); const formFields = (form: string): Record => { const fields: Record = {}; for (const input of form.matchAll(/]*>/gi)) { const tag = input[0]; const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1]; const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? ""; if (name) fields[decodeHtml(name)] = decodeHtml(value); } return fields; }; // Drive the whole flow as the given IdP account: sign-in redirect -> IdP // consent page (pick the account's form) -> callback back into the app. // Returns the callback response plus the cookies it set. const signInThroughIdp = async (email: string) => { const start = await handler( new Request(`${BASE}/api/auth/sign-in/oauth2`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ providerId: "okta", callbackURL: "/" }), }), ); expect(start.status).toBe(200); const { url } = (await start.json()) as { url: string }; expect(url.startsWith(idp.url)).toBe(true); const stateCookies = start.headers .getSetCookie() .map((cookie) => cookie.split(";")[0]!) .join("; "); const consentHtml = await fetch(url).then((r) => r.text()); const form = [...consentHtml.matchAll(//gi)] .map((m) => m[0]) .find((f) => f.includes(email)); expect(form).toBeDefined(); const action = decodeHtml(form!.match(/\baction=["']([^"']+)["']/i)![1]!); const consent = await fetch(action, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(formFields(form!)), redirect: "manual", }); expect(consent.status).toBe(302); const callbackUrl = consent.headers.get("location")!; expect(callbackUrl.startsWith(CALLBACK)).toBe(true); const callback = await handler(new Request(callbackUrl, { headers: { cookie: stateCookies } })); const cookies = callback.headers .getSetCookie() .map((cookie) => cookie.split(";")[0]!) .join("; "); return { callback, cookies }; }; test("an allowlisted-domain IdP account signs in and joins the org as a member", async () => { const { callback, cookies } = await signInThroughIdp("alice@example.com"); expect(callback.status).toBe(302); expect(callback.headers.get("location")).toBe("/"); const session = await handler( new Request(`${BASE}/api/auth/get-session`, { headers: { cookie: cookies } }), ); expect(session.status).toBe(200); const sessionBody = (await session.json()) as { user?: { email?: string } }; expect(sessionBody.user?.email).toBe("alice@example.com"); const orgs = await handler( new Request(`${BASE}/api/auth/organization/list`, { headers: { cookie: cookies } }), ); expect(orgs.status).toBe(200); expect(((await orgs.json()) as unknown[]).length).toBe(1); }); test("an IdP account outside the allowlist is refused and gets no session", async () => { const { callback, cookies } = await signInThroughIdp("mallory@evil.test"); // The rejection surfaces as better-auth's error redirect, not a success. expect(callback.status).toBe(302); expect(callback.headers.get("location")).not.toBe("/"); expect(callback.headers.get("location")).toContain("error"); const session = await handler( new Request(`${BASE}/api/auth/get-session`, { headers: { cookie: cookies } }), ); const sessionBody = (await session.json()) as { user?: unknown } | null; expect(sessionBody?.user ?? null).toBeNull(); }); // --- Invite-code regressions with SSO configured ------------------------------ test("invite-gated email signup still works with an SSO provider configured", async () => { const inviteCode = await mintInviteCode(handler); const signUp = await handler( new Request(`${BASE}/api/auth/sign-up/email`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "member@test.local", password: "member-password-123", name: "Member", inviteCode, }), }), ); expect(signUp.status).toBe(200); }); test("email signup without an invite is still refused with an SSO provider configured", async () => { const signUp = await handler( new Request(`${BASE}/api/auth/sign-up/email`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "stranger@example.com", password: "stranger-password-123", name: "Stranger", }), }), ); expect(signUp.status).toBe(403); });