mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
fix(auth): block social OAuth callback when SSO is enforced
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user