fix(auth): block social OAuth callback when SSO is enforced

This commit is contained in:
detail-app[bot]
2026-09-07 16:54:09 +00:00
committed by GitHub
parent 3b065db8a0
commit 28e5ee0df5
4 changed files with 335 additions and 1 deletions
@@ -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/<provider> 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<string> {
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);
});
});
@@ -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<string> {
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);
});
});
@@ -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<string>();
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"]);
});
});
+2 -1
View File
@@ -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();