Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ SITE_RUM=
INTEGRATIONS_ENCRYPTION_KEY=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Cloudflare Turnstile. The secret alone turns on CAPTCHA for public forms;
# add the site key to also protect the sign-in page.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
AUTH_COOKIE_DOMAIN=

# Optional documentation GitHub App. See /self-hosting/github-app in the docs.
Expand Down
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ CUSTOM_DOMAIN_TXT_RECORD_NAME=_frontlit-verify
# honeypot-only even with a secret configured.
CAPTCHA_PROVIDER=
TURNSTILE_SECRET_KEY=
# Also set the site key to put Turnstile on the hosted sign-in page (it checks
# the request that emails a sign-in code). The widget's hostname in Cloudflare
# must include API_PUBLIC_URL's host; Cloudflare's testing keys skip that check.
TURNSTILE_SITE_KEY=

# Shared HMAC secret for the ISR revalidation webhook apps/api pings on
# publish/domain/settings changes (architecture.md §12.2). Must match
Expand Down
12 changes: 6 additions & 6 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@
"@aws-sdk/client-s3": "^3.1111.0",
"@better-auth/cimd": "1.7.7",
"@better-auth/oauth-provider": "1.7.7",
"@codelitdev/mcp-server-kit": "0.4.2",
"@codelitdev/oauth-server-kit": "0.4.2",
"@codelitdev/observability": "0.4.2",
"@codelitdev/platform": "0.4.2",
"@codelitdev/mcp-server-kit": "0.8.0",
"@codelitdev/oauth-server-kit": "0.8.0",
"@codelitdev/observability": "0.8.0",
"@codelitdev/platform": "0.8.0",
"@frontlit/api-contract": "workspace:*",
"@frontlit/page-builder": "workspace:*",
"@frontlit/text-editor": "workspace:*",
Expand Down Expand Up @@ -73,8 +73,8 @@
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@codelitdev/billing": "0.4.2",
"@codelitdev/platform-conformance": "0.4.2",
"@codelitdev/billing": "0.8.0",
"@codelitdev/platform-conformance": "0.8.0",
"@electric-sql/pglite": "^0.2.17",
"@modelcontextprotocol/client": "^2.2.0",
"@types/cors": "^2.8.12",
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/auth/authz-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,6 @@ describe("authorization integration", () => {
kind: "oauth",
accountId: owner.id,
email: owner.email,
claimedTeamId: team.id,
scopes: ["teams:write"],
audiences: ["http://localhost:4000"],
}),
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/auth/better-auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { betterAuth } from "better-auth";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { captcha } from "better-auth/plugins";
import { emailOTP } from "better-auth/plugins/email-otp";
import { jwt } from "better-auth/plugins/jwt";
import { oauthProvider } from "@better-auth/oauth-provider";
Expand All @@ -16,6 +17,12 @@ import { ensureFrontLitAccountForUser } from "../account/queries";
import { MCP_SCOPES_SUPPORTED } from "../mcp/policy";
import { fetchClientMetadataResource } from "./cimd-fetch";
import { AUTH_COOKIE_PREFIX } from "./cookie-prefix";
import {
CAPTCHA_PROTECTED_ENDPOINTS,
TURNSTILE_TESTING_SECRET,
loginCaptchaConfig,
toHostedLoginCaptcha,
} from "./login-captcha";

export const webClientUrl = process.env.WEB_CLIENT || "http://localhost:3000";
const apiUrl = process.env.API_PUBLIC_URL || process.env.BETTER_AUTH_URL;
Expand Down Expand Up @@ -65,6 +72,9 @@ export const mcpProtectedResourceMetadataUrl = `${authBaseUrl}/.well-known/oauth
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;

const loginCaptcha = loginCaptchaConfig();
export const hostedLoginCaptcha = toHostedLoginCaptcha(loginCaptcha);

async function sendOtpEmail(email: string, otp: string) {
try {
await sendMail({
Expand Down Expand Up @@ -218,6 +228,20 @@ export const auth = betterAuth({
max: 3,
},
}),
...(loginCaptcha
? [
captcha({
provider: "cloudflare-turnstile",
secretKey: loginCaptcha.secretKey,
endpoints: CAPTCHA_PROTECTED_ENDPOINTS,
// The widget only renders on the API's login page. Cloudflare's
// testing secrets always report example.com, so they skip this.
...(TURNSTILE_TESTING_SECRET.test(loginCaptcha.secretKey)
? {}
: { allowedHostnames: [new URL(authBaseUrl).hostname] }),
}),
]
: []),
jwt(),
oauthProvider({
...createOAuthProviderOptions({
Expand Down
90 changes: 90 additions & 0 deletions apps/api/src/auth/login-captcha.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import { makeTestDb } from "../test/db";
import { listenApp } from "../test/auth-app";

const siteKey = vi.hoisted(() => {
// Cloudflare's always-passes testing keys. Read when better-auth.ts loads.
process.env.TURNSTILE_SITE_KEY = "1x00000000000000000000AA";
process.env.TURNSTILE_SECRET_KEY = "1x0000000000000000000000000000000AA";
return process.env.TURNSTILE_SITE_KEY;
});

vi.mock("../db/client", async () => ({ db: await makeTestDb() }));

import {
TURNSTILE_TESTING_SECRET,
loginCaptchaConfig,
toHostedLoginCaptcha,
} from "./login-captcha";

describe("loginCaptchaConfig", () => {
it("is on only when both keys are set, trimmed", () => {
expect(loginCaptchaConfig({})).toBeUndefined();
expect(loginCaptchaConfig({ TURNSTILE_SITE_KEY: "site" })).toBeUndefined();
// A secret alone is valid: it still enables CAPTCHA on public writes.
expect(loginCaptchaConfig({ TURNSTILE_SECRET_KEY: "secret" })).toBeUndefined();
expect(
loginCaptchaConfig({
TURNSTILE_SITE_KEY: " site ",
TURNSTILE_SECRET_KEY: "secret",
}),
).toEqual({ siteKey: "site", secretKey: "secret" });
});

it("only hands the site key to the login page", () => {
expect(toHostedLoginCaptcha(undefined)).toBeUndefined();
expect(toHostedLoginCaptcha({ siteKey: "site", secretKey: "secret" })).toEqual({
provider: "cloudflare-turnstile",
siteKey: "site",
});
});

it("recognises Cloudflare's testing secrets", () => {
for (const secret of [
"1x0000000000000000000000000000000AA",
"2x0000000000000000000000000000000AA",
"3x0000000000000000000000000000000AA",
]) {
expect(TURNSTILE_TESTING_SECRET.test(secret)).toBe(true);
}
expect(TURNSTILE_TESTING_SECRET.test("0x4AAAAAAAreal")).toBe(false);
});
});

describe("login with Turnstile configured", () => {
it("renders the widget on the hosted login page", async () => {
const listening = await listenApp();
try {
const response = await fetch(`${listening.origin}/login`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain(`data-sitekey="${siteKey}"`);
expect(html).toContain("challenges.cloudflare.com/turnstile/v0/api.js");
} finally {
await listening.close();
}
});

it("refuses to email a sign-in code without a Turnstile token", async () => {
const listening = await listenApp();
try {
const response = await fetch(
`${listening.origin}/api/auth/email-otp/send-verification-otp`,
{
method: "POST",
headers: {
"content-type": "application/json",
origin: "http://localhost:3000",
},
body: JSON.stringify({ email: "someone@example.com", type: "sign-in" }),
},
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
code: "MISSING_RESPONSE",
});
} finally {
await listening.close();
}
});
});
35 changes: 35 additions & 0 deletions apps/api/src/auth/login-captcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { HostedLoginCaptcha } from "@codelitdev/oauth-server-kit/express";

/** The hosted login page sends the email-OTP request here. The code itself is
* only checked after the person proves they got the email, so this is the
* request bots abuse to send mail. */
export const CAPTCHA_PROTECTED_ENDPOINTS = ["/email-otp/send-verification-otp"];

// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
export const TURNSTILE_TESTING_SECRET = /^[123]x0{31}AA$/;

export type LoginCaptchaConfig = {
siteKey: string;
secretKey: string;
};

/** Cloudflare Turnstile on the hosted sign-in form. On only when both keys
* are set: the page sends the widget's token and Better Auth's `captcha`
* plugin verifies it, so one without the other would refuse every email
* sign-in. `TURNSTILE_SECRET_KEY` alone is valid — it still turns on CAPTCHA
* for public writes (`services/captcha.ts`). */
export function loginCaptchaConfig(
env: Record<string, string | undefined> = process.env,
): LoginCaptchaConfig | undefined {
const siteKey = env.TURNSTILE_SITE_KEY?.trim();
const secretKey = env.TURNSTILE_SECRET_KEY?.trim();
return siteKey && secretKey ? { siteKey, secretKey } : undefined;
}

export function toHostedLoginCaptcha(
config: LoginCaptchaConfig | undefined,
): HostedLoginCaptcha | undefined {
return config
? { provider: "cloudflare-turnstile", siteKey: config.siteKey }
: undefined;
}
15 changes: 15 additions & 0 deletions apps/api/src/auth/oauth-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ vi.mock("./better-auth", () => ({
webClientUrl: "http://localhost:3000",
authBasePath: "/api/auth",
hostedLoginMethods: [{ type: "email-otp" }],
hostedLoginCaptcha: {
provider: "cloudflare-turnstile",
siteKey: "0x4AAAAAAAtest",
},
}));
vi.mock("better-auth/node", () => ({
fromNodeHeaders: (headers: unknown) => headers,
Expand Down Expand Up @@ -63,6 +67,17 @@ describe("FrontLit OAuth pages", () => {
expect(response.headers.get("x-frame-options")).toBe("DENY");
});

it.each(["/login", "/oauth/login"])(
"renders the Turnstile widget on %s when it is configured",
async (path) => {
const response = await request(path);
expect(response.body).toContain(
'class="cf-turnstile" data-sitekey="0x4AAAAAAAtest"',
);
expect(response.body).toContain("var captcha=true");
},
);

it("brands the hosted login with the FrontLit logo, favicon, and color", async () => {
const response = await request("/login?redirect=http://localhost:3000/pages/foo");
expect(response.body).toContain('src="http://localhost:3000/icon.svg"');
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/auth/oauth-pages.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Router } from "express";
import { createOAuthPagesRouter } from "@codelitdev/oauth-server-kit/express";
import { authBasePath, hostedLoginMethods, webClientUrl } from "./better-auth";
import {
authBasePath,
hostedLoginCaptcha,
hostedLoginMethods,
webClientUrl,
} from "./better-auth";

const router = Router();

Expand All @@ -14,6 +19,7 @@ router.use(
faviconUrl: new URL("/icon.svg", webClientUrl).toString(),
primaryColor: "oklch(0.47 0.14 230)",
loginMethods: hostedLoginMethods,
captcha: hostedLoginCaptcha,
legacyHostOnlySessionCookieNames: process.env.AUTH_COOKIE_DOMAIN
? [
"__Secure-better-auth.session_token",
Expand Down
1 change: 0 additions & 1 deletion apps/api/src/auth/session-freshness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,6 @@ describe("resolveAuth sessionAuthenticatedAt on the production path", () => {
createdAt,
updatedAt: createdAt,
}),
getTeamMembership: async () => null,
getSessionAuthenticatedAt,
},
);
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/conformance-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export function createFrontLitConformanceAdapter(
}
return {
status: 200,
body: { result: { content: [{ json: { items: [] } }] } },
body: { result: { structuredContent: { items: [] } } },
};
},
async readAuditEvents() {
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
"@codelitdev/design-system": "0.4.2",
"@codelitdev/design-system": "0.8.0",
"@frontlit/api-contract": "workspace:*",
"@frontlit/page-builder": "workspace:*",
"@frontlit/text-editor": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"test": "vitest run"
},
"dependencies": {
"@codelitdev/design-system": "0.4.2",
"@codelitdev/design-system": "0.8.0",
"@frontlit/api-contract": "workspace:*",
"@frontlit/media-uploader": "workspace:*",
"@frontlit/page-builder": "workspace:*",
Expand Down
Loading
Loading