diff --git a/apps/dokploy/__test__/auth/enforce-sso-callback-cloud.test.ts b/apps/dokploy/__test__/auth/enforce-sso-callback-cloud.test.ts new file mode 100644 index 000000000..f4e0a2f29 --- /dev/null +++ b/apps/dokploy/__test__/auth/enforce-sso-callback-cloud.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * G2.2 — When IS_CLOUD is true (Dokploy Cloud), the SSO deny-list is not + * applied at all (enforceSSO is a self-hosted-only toggle). The `before` hook + * gates on `!IS_CLOUD`, so /callback/ must never be blocked on cloud. + * + * This needs a separate test file because `IS_CLOUD` is captured at import + * time in the `createAuthMiddleware` closure (the auth singleton is created + * once per process). Vitest `pool: forks` isolates each test file in its own + * fork, so this file gets a fresh singleton with IS_CLOUD mocked to true. + */ + +const { getWebServerSettings } = vi.hoisted(() => ({ + getWebServerSettings: vi.fn(), +})); + +vi.mock("@dokploy/server/constants", () => ({ + IS_CLOUD: true, +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args), + updateWebServerSettings: vi.fn(), +})); + +import { auth } from "@dokploy/server/lib/auth"; + +const BASE = "http://localhost:3000/api/auth"; + +const makeRequest = (path: string, init?: RequestInit) => + new Request(`${BASE}${path}`, init); + +async function bodyText(res: Response): Promise { + try { + return await res.text(); + } catch { + return ""; + } +} + +const SSO_MESSAGE = "SSO is enforced"; + +describe("enforceSSO before hook: cloud (IS_CLOUD=true) is never blocked (G2.2)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not block /callback/github on cloud even when enforceSSO is true", async () => { + getWebServerSettings.mockResolvedValue({ enforceSSO: true }); + + // On Dokploy Cloud, the !IS_CLOUD gate is false, so the hook never throws + // regardless of enforceSSO. The request proceeds to the real callback. + let res: Response | null = null; + try { + res = await auth.handler( + makeRequest("/callback/github?code=x&state=y", { method: "GET" }), + ); + } catch { + // Downstream state/DB errors are expected; the guarantee is no SSO block. + } + if (res) expect(await bodyText(res)).not.toContain(SSO_MESSAGE); + }); + + it("does not block /sign-in/email on cloud even when enforceSSO is true", async () => { + getWebServerSettings.mockResolvedValue({ enforceSSO: true }); + + let res: Response | null = null; + try { + res = await auth.handler( + makeRequest("/sign-in/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "a@b.c", password: "x" }), + }), + ); + } catch { + // Downstream errors are expected; the guarantee is no SSO block. + } + if (res) expect(await bodyText(res)).not.toContain(SSO_MESSAGE); + }); +}); diff --git a/apps/dokploy/__test__/auth/enforce-sso-callback.test.ts b/apps/dokploy/__test__/auth/enforce-sso-callback.test.ts new file mode 100644 index 000000000..274538596 --- /dev/null +++ b/apps/dokploy/__test__/auth/enforce-sso-callback.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Regression coverage for the `hooks.before` SSO-enforcement middleware in + * `packages/server/src/lib/auth.ts`. + * + * Pre-fix, the deny-list only covered *initiation* endpoints (e.g. + * `/sign-in/social`) and omitted `/callback/:id` — the social-OAuth endpoint + * that actually exchanges the OAuth code and calls `setSessionCookie`. With + * `enforceSSO` enabled, a session could still be created through a social + * provider callback, bypassing the SSO-only policy. + * + * These tests drive the real better-auth request pipeline (`auth.handler`) + * with synthetic requests, so they exercise: + * - the per-request base-URL rehydration (`basePath = /api/auth`), + * - the user `hooks.before` middleware, + * - and the denial/allow decision based on `webServerSettings.enforceSSO`. + * + * The global `__test__/setup.ts` mock neutralises `@dokploy/server/db`, and + * `IS_CLOUD` is `false` in the test environment (`process.env.IS_CLOUD` unset), + * so the self-hosted `!IS_CLOUD` branch is active exactly as in production. + */ + +// Hoisted so the mock fn exists when `auth.ts` is imported: its module-load +// context creation calls `resolveTrustedOrigins` -> `getWebServerSettings()` +// before top-level `const` initialization would otherwise run. +const { getWebServerSettings } = vi.hoisted(() => ({ + getWebServerSettings: vi.fn(), +})); + +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args), + updateWebServerSettings: vi.fn(), +})); + +import { auth } from "@dokploy/server/lib/auth"; + +const BASE = "http://localhost:3000/api/auth"; + +const makeRequest = (path: string, init?: RequestInit) => + new Request(`${BASE}${path}`, init); + +async function bodyText(res: Response): Promise { + try { + return await res.text(); + } catch { + return ""; + } +} + +const SSO_MESSAGE = "SSO is enforced"; + +describe("enforceSSO before hook: social OAuth callback gating", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("blocks the social OAuth callback /callback/github when enforceSSO is enabled", async () => { + getWebServerSettings.mockResolvedValue({ enforceSSO: true }); + + const res = await auth.handler( + makeRequest("/callback/github?code=x&state=y", { method: "GET" }), + ); + + expect(res.status).toBe(403); + expect(await bodyText(res)).toContain(SSO_MESSAGE); + }); + + it("blocks any provider social OAuth callback (e.g. /callback/google)", async () => { + getWebServerSettings.mockResolvedValue({ enforceSSO: true }); + + const res = await auth.handler( + makeRequest("/callback/google?code=x&state=y", { method: "GET" }), + ); + + expect(res.status).toBe(403); + expect(await bodyText(res)).toContain(SSO_MESSAGE); + }); + + it("does not block /callback/:id when enforceSSO is disabled", async () => { + getWebServerSettings.mockResolvedValue({ enforceSSO: false }); + + // With enforceSSO off, the hook must not short-circuit. The request proceeds + // to the real callback, which fails downstream for unrelated reasons (no real + // OAuth state exists), but it must never surface the SSO-enforcement message. + let res: Response | null = null; + try { + res = await auth.handler( + makeRequest("/callback/github?code=x&state=y", { method: "GET" }), + ); + } catch { + // Downstream state/DB errors are expected under mocked storage; the + // guarantee under test is the absence of the SSO-enforcement denial. + } + if (res) expect(await bodyText(res)).not.toContain(SSO_MESSAGE); + }); + + it("does not block the legitimate SSO callback /sso/callback/:providerId when enforceSSO is enabled", async () => { + getWebServerSettings.mockResolvedValue({ enforceSSO: true }); + + // The `/callback/` prefix must not match `/sso/callback/...`; the enterprise + // SSO callback is the one path the admin wants to keep open under enforceSSO. + let res: Response | null = null; + try { + res = await auth.handler( + makeRequest("/sso/callback/myprovider?code=x&state=y", { + method: "GET", + }), + ); + } catch { + // Downstream SSO state/DB errors are expected; the hook must not block it. + } + if (res) expect(await bodyText(res)).not.toContain(SSO_MESSAGE); + }); + + it("still blocks the email sign-in initiation path when enforceSSO is enabled", async () => { + getWebServerSettings.mockResolvedValue({ enforceSSO: true }); + + // No cookies and no Origin/Sec-Fetch-* headers: CSRF/origin checks are + // skipped, so the before hook is the gate that fires. + let res: Response | null = null; + try { + res = await auth.handler( + makeRequest("/sign-in/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "a@b.c", password: "x" }), + }), + ); + } catch { + // Unreachable for a blocked path — the hook returns a 403 Response. + } + expect(res).not.toBeNull(); + expect(res?.status).toBe(403); + expect(await bodyText(res as Response)).toContain(SSO_MESSAGE); + }); + + it("does not block /callback/:id when enforceSSO settings are absent (G2.3)", async () => { + getWebServerSettings.mockResolvedValue(undefined); + + // When the settings row is missing, settings?.enforceSSO is falsy, so the + // hook must not throw. The request proceeds to the real callback, which + // fails downstream for unrelated reasons, but must never surface the + // SSO-enforcement denial. + let res: Response | null = null; + try { + res = await auth.handler( + makeRequest("/callback/github?code=x&state=y", { method: "GET" }), + ); + } catch { + // Downstream state/DB errors are expected; the guarantee is no SSO block. + } + if (res) expect(await bodyText(res)).not.toContain(SSO_MESSAGE); + }); + + it("does not block /sign-in/sso (the SSO init endpoint) when enforceSSO is enabled (G3.2)", async () => { + getWebServerSettings.mockResolvedValue({ enforceSSO: true }); + + // POST /sign-in/sso is the OIDC SSO initiation endpoint the admin relies on; + // it must remain open under enforceSSO. No cookies and no Origin/Sec-Fetch-* + // headers so CSRF/origin checks are skipped and the before hook is the gate. + let res: Response | null = null; + try { + res = await auth.handler( + makeRequest("/sign-in/sso", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "alice@example.com", + callbackURL: "https://dokploy.example/dashboard", + }), + }), + ); + } catch { + // Downstream SSO provider lookup errors are expected; the guarantee is + // that the SSO-enforcement hook did not block this path. + } + if (res) expect(await bodyText(res)).not.toContain(SSO_MESSAGE); + }); +}); diff --git a/apps/dokploy/__test__/auth/enforce-sso-prefix-precision.test.ts b/apps/dokploy/__test__/auth/enforce-sso-prefix-precision.test.ts new file mode 100644 index 000000000..1ce6101f3 --- /dev/null +++ b/apps/dokploy/__test__/auth/enforce-sso-prefix-precision.test.ts @@ -0,0 +1,71 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * G5.1 — The `/callback/` prefix matches exactly the better-auth social-OAuth + * callback endpoint (`/callback/:id`) and nothing else among all endpoints + * registered by better-auth core and the plugins Dokploy loads + * (sso, passkey, scim, apiKey, organization, twoFactor, admin). + * + * Static cross-check: reads every `createAuthEndpoint("/...")` path string from + * the installed better-auth / @better-auth/* dist bundles and asserts that + * `/callback/:id` is the sole path beginning with `/callback/`. + */ + +const nodeRequire = createRequire(import.meta.url); + +function resolveDist(pkgName: string): string | null { + try { + const entry = nodeRequire.resolve(pkgName); + const dist = path.dirname(entry); + readdirSync(dist); + return dist; + } catch { + return null; + } +} + +function collectEndpointPaths(distDirs: string[]): string[] { + const paths = new Set(); + const re = /createAuthEndpoint\("([^"]+)"/g; + for (const dir of distDirs) { + const stack = [dir]; + while (stack.length) { + const cur = stack.pop() as string; + for (const entry of readdirSync(cur, { withFileTypes: true })) { + const full = path.join(cur, entry.name); + if (entry.isDirectory()) stack.push(full); + else if (entry.name.endsWith(".mjs")) { + const content = readFileSync(full, "utf8"); + let m: RegExpExecArray | null = re.exec(content); + while (m !== null) { + paths.add(m[1]); + m = re.exec(content); + } + } + } + } + } + return [...paths].sort(); +} + +describe("G5.1: /callback/ prefix precision", () => { + it("/callback/:id is the only registered endpoint beginning with /callback/", () => { + const dirs = [ + resolveDist("better-auth"), + resolveDist("@better-auth/sso"), + resolveDist("@better-auth/passkey"), + resolveDist("@better-auth/api-key"), + resolveDist("@better-auth/scim"), + ].filter((d): d is string => d !== null); + + expect(dirs.length).toBeGreaterThan(0); + const allPaths = collectEndpointPaths(dirs); + expect(allPaths.length).toBeGreaterThan(0); + + const callbackPrefixed = allPaths.filter((p) => p.startsWith("/callback/")); + expect(callbackPrefixed).toEqual(["/callback/:id"]); + }); +}); diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 10d62a5b0..cdee11752 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -132,7 +132,8 @@ const createBetterAuth = () => ctx.path.startsWith("/sign-in/passkey") || ctx.path.startsWith("/sign-up/email") || ctx.path.startsWith("/passkey/verify-authentication") || - ctx.path.startsWith("/passkey/generate-authenticate-options"); + ctx.path.startsWith("/passkey/generate-authenticate-options") || + ctx.path.startsWith("/callback/"); if (!IS_CLOUD && isBlockedAuthPath) { const settings = await getWebServerSettings();