diff --git a/README.md b/README.md index 42532769551..389a250fbf6 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,10 @@ S3_PUBLIC_URL=https://s3.yourdomain.com See the [self-hosting guide](https://cap.so/docs/self-hosting) for email setup, AI providers, SSL, storage, production hardening, and troubleshooting. +For single-owner instances behind an authenticating reverse proxy, see +[trusted-proxy authentication](docs/trusted-proxy-auth.md) for the required shared +secret, proxy hardening, and how to trigger sign-in without a built-in login button. + ## Local Development Cap is a Turborepo monorepo with Rust, TypeScript, Tauri, SolidStart, Next.js, Drizzle, MySQL, Tailwind CSS, and shared media crates. diff --git a/apps/web/__tests__/unit/auth-options.test.ts b/apps/web/__tests__/unit/auth-options.test.ts index 8f919507e6d..3b2ccf11cca 100644 --- a/apps/web/__tests__/unit/auth-options.test.ts +++ b/apps/web/__tests__/unit/auth-options.test.ts @@ -44,9 +44,15 @@ const env = vi.hoisted(() => ({ GOOGLE_CLIENT_ID: "google-client", GOOGLE_CLIENT_SECRET: "google-secret", NEXTAUTH_SECRET: "next-auth-secret", + OIDC_ISSUER: undefined as string | undefined, + OIDC_CLIENT_ID: undefined as string | undefined, + OIDC_CLIENT_SECRET: undefined as string | undefined, RESEND_API_KEY: undefined, WORKOS_API_KEY: "workos-secret", WORKOS_CLIENT_ID: "workos-client", + TRUSTED_PROXY_AUTH_HEADER: undefined as string | undefined, + TRUSTED_PROXY_AUTH_EMAIL: undefined as string | undefined, + TRUSTED_PROXY_AUTH_SECRET: undefined as string | undefined, })); vi.mock("@cap/env", () => ({ @@ -105,14 +111,29 @@ function callbacksFor(options: NextAuthOptions) { return { signIn, jwt }; } +// next-auth keeps a Credentials provider's custom id in `options` until it +// merges providers at runtime, so read it from there when present. +function providerIds(options: NextAuthOptions) { + return options.providers.map( + (provider) => + (provider.options as { id?: string } | undefined)?.id ?? provider.id, + ); +} + describe("authOptions", () => { beforeEach(() => { env.APPLE_CLIENT_ID = "so.cap.auth"; env.APPLE_CLIENT_SECRET = "apple-secret"; env.WORKOS_CLIENT_ID = "workos-client"; env.WORKOS_API_KEY = "workos-secret"; + env.OIDC_ISSUER = undefined; + env.OIDC_CLIENT_ID = undefined; + env.OIDC_CLIENT_SECRET = undefined; env.CAP_ALLOWED_SIGNUP_DOMAINS = undefined; env.CAP_BLOCKED_SIGNUP_DOMAINS = undefined; + env.TRUSTED_PROXY_AUTH_HEADER = undefined; + env.TRUSTED_PROXY_AUTH_EMAIL = undefined; + env.TRUSTED_PROXY_AUTH_SECRET = undefined; mocks.validate.mockReset().mockResolvedValue(IDENTITY); mocks.provision.mockReset().mockResolvedValue(undefined); mocks.adapter.mockClear(); @@ -167,6 +188,229 @@ describe("authOptions", () => { }, ); + it("enables trusted-proxy sign-in when header, email and secret are configured", () => { + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret"; + + expect(providerIds(authOptions())).toContain("trusted-proxy"); + }); + + it("does not expose trusted-proxy sign-in by default", () => { + expect(providerIds(authOptions())).not.toContain("trusted-proxy"); + }); + + it.each([ + "TRUSTED_PROXY_AUTH_HEADER", + "TRUSTED_PROXY_AUTH_EMAIL", + "TRUSTED_PROXY_AUTH_SECRET", + ] as const)( + "does not expose a partially configured trusted-proxy provider when %s is missing", + (key) => { + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret"; + env[key] = undefined; + + expect(providerIds(authOptions())).not.toContain("trusted-proxy"); + }, + ); + + it.each([undefined, "", " "])( + "does not register trusted-proxy with only header/email and an unusable secret (%s)", + (secret) => { + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + env.TRUSTED_PROXY_AUTH_SECRET = secret; + + expect(providerIds(authOptions())).not.toContain("trusted-proxy"); + }, + ); + + it.each([undefined, "proxy-secret"])( + "preserves independent OIDC sign-in and its verified-email gate with proxy secret %s", + async (secret) => { + env.OIDC_ISSUER = "https://idp.example"; + env.OIDC_CLIENT_ID = "oidc-client"; + env.OIDC_CLIENT_SECRET = "oidc-secret"; + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + env.TRUSTED_PROXY_AUTH_SECRET = secret; + const options = authOptions(); + expect(providerIds(options)).toContain("oidc"); + expect(providerIds(options).includes("trusted-proxy")).toBe(!!secret); + const { signIn } = callbacksFor(options); + const account: Account = { + provider: "oidc", + providerAccountId: "oidc-user", + type: "oauth", + }; + for (const email_verified of [undefined, false, true]) { + const profile = { email: PROFILE.email, email_verified }; + await expect( + signIn({ + user: AUTHENTICATED_USER, + account, + profile, + }), + ).resolves.toBe( + email_verified === true ? true : "/login?error=OidcEmailUnverified", + ); + } + }, + ); + + describe("trusted-proxy authorize", () => { + type Authorize = ( + credentials: Record | undefined, + req: { headers?: Record }, + ) => Promise; + + function trustedProxyAuthorize() { + const options = authOptions(); + const provider = options.providers[ + providerIds(options).indexOf("trusted-proxy") + ] as { options?: { authorize?: Authorize } } | undefined; + const authorize = provider?.options?.authorize; + if (!authorize) throw new Error("trusted-proxy provider is missing"); + return authorize; + } + + beforeEach(() => { + env.TRUSTED_PROXY_AUTH_HEADER = "X-OpenHost-Is-Owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = "Alex@Company.example"; + env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret"; + }); + + const validHeaders = { + "x-openhost-is-owner": "true", + "x-trusted-proxy-secret": "proxy-secret", + }; + + it("signs in the configured user when the proxy sets both headers", async () => { + await expect( + trustedProxyAuthorize()({}, { headers: validHeaders }), + ).resolves.toMatchObject({ + id: AUTHENTICATED_USER.id, + email: AUTHENTICATED_USER.email, + }); + }); + + it.each([ + ["header is absent", { "x-trusted-proxy-secret": "proxy-secret" }], + [ + "header is not exactly 'true'", + { ...validHeaders, "x-openhost-is-owner": "1" }, + ], + ])("fails closed when the %s", async (_label, headers) => { + await expect( + trustedProxyAuthorize()({}, { headers }), + ).resolves.toBeNull(); + expect(mocks.select).not.toHaveBeenCalled(); + }); + + it("fails closed when the configured user does not exist", async () => { + env.TRUSTED_PROXY_AUTH_EMAIL = "nobody@company.example"; + + await expect( + trustedProxyAuthorize()({}, { headers: validHeaders }), + ).resolves.toBeNull(); + expect(mocks.select).toHaveBeenCalledOnce(); + }); + + it.each([undefined, "", " "])( + "fails closed if the configured secret becomes unusable after registration (%s)", + async (secret) => { + const authorize = trustedProxyAuthorize(); + env.TRUSTED_PROXY_AUTH_SECRET = secret; + await expect( + authorize({}, { headers: validHeaders }), + ).resolves.toBeNull(); + expect(mocks.select).not.toHaveBeenCalled(); + }, + ); + + it.each([undefined, "", "wrong", "x".repeat(1024), ["proxy-secret"]])( + "rejects missing, empty, incorrect or malformed request secrets (%s)", + async (secret) => { + await expect( + trustedProxyAuthorize()( + {}, + { + headers: { + ...validHeaders, + "x-trusted-proxy-secret": secret, + } as Record, + }, + ), + ).resolves.toBeNull(); + expect(mocks.select).not.toHaveBeenCalled(); + }, + ); + + it("rejects requests without headers", async () => { + await expect(trustedProxyAuthorize()({}, {})).resolves.toBeNull(); + expect(mocks.select).not.toHaveBeenCalled(); + }); + + it("always requires the shared secret", async () => { + const authorize = trustedProxyAuthorize(); + + await expect( + authorize({}, { headers: { "x-openhost-is-owner": "true" } }), + ).resolves.toBeNull(); + await expect( + authorize( + {}, + { + headers: { + "x-openhost-is-owner": "true", + "x-trusted-proxy-secret": "wrong", + }, + }, + ), + ).resolves.toBeNull(); + await expect( + authorize( + {}, + { + headers: { + "x-openhost-is-owner": "true", + "x-trusted-proxy-secret": "proxy-secret", + }, + }, + ), + ).resolves.toMatchObject({ id: AUTHENTICATED_USER.id }); + }); + }); + + // authOptions() is rebuilt on every session lookup, so a per-instance + // warning would log on every authenticated request. + it("warns about trusted-proxy sign-in once per process", async () => { + vi.resetModules(); + const { authOptions: freshAuthOptions } = await import( + "@cap/database/auth/auth-options" + ); + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + void freshAuthOptions().providers; + expect(warn).not.toHaveBeenCalled(); + env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret"; + for (let i = 0; i < 3; i++) void freshAuthOptions().providers; + + expect( + warn.mock.calls.filter(([message]) => + String(message).includes("trusted-proxy sign-in is ENABLED"), + ), + ).toHaveLength(1); + } finally { + warn.mockRestore(); + } + }); + // Without an explicit maxAge next-auth falls back to 24 hours, which is far // too long for a 6-digit code and contradicts what the OTP email tells users. it("expires email verification codes after the advertised 10 minutes", () => { diff --git a/docs/trusted-proxy-auth.md b/docs/trusted-proxy-auth.md new file mode 100644 index 00000000000..bdb208ee502 --- /dev/null +++ b/docs/trusted-proxy-auth.md @@ -0,0 +1,98 @@ +# Trusted reverse-proxy authentication + +This opt-in provider lets an authenticating reverse proxy sign requests into one +existing Cap account. It is intended for single-owner self-hosted instances, not +multi-user SSO: every successful sign-in uses the configured account. + +## Configuration and security boundary + +Set all three variables in the Cap web server's environment: + +```dotenv +TRUSTED_PROXY_AUTH_HEADER=x-openhost-is-owner +TRUSTED_PROXY_AUTH_EMAIL=owner@example.com +TRUSTED_PROXY_AUTH_SECRET= +``` + +Create the Cap account through a normal login first. This provider does not create +users. It is absent unless the header, email and a non-blank secret are configured; +ordinary email/OAuth login remains available. Restart Cap after changing its env. + +Configure the proxy to: + +- Authenticate the owner before forwarding requests to Cap, including `/api/auth/*`. + Do not accept any authenticated proxy user as the owner. +- Strip client-sent copies of **both** the configured trust header and + `x-trusted-proxy-secret`. On owner-authenticated requests only, set the trust + header to the literal string `true` and the secret header to the configured + secret. Never forward a client-provided value for either header. +- Keep the Cap origin private or firewalled so clients cannot bypass the proxy + through a published container port, alternative hostname or ingress. Use HTTPS + on the public endpoint and a protected connection between the proxy and Cap. + +Choose a trust-header name distinct from `x-trusted-proxy-secret`. Keep the secret +out of browser code, URLs, responses and logs. It is required even when the origin +is private; a direct request with only the trust header must not authenticate. + +## Triggering sign-in + +There is no built-in Reverse Proxy button yet. A custom Cap client component can +trigger the provider with NextAuth's client helper: + +```tsx +"use client"; + +import { signIn } from "next-auth/react"; + +export function ReverseProxySignIn() { + return ( + + ); +} +``` + +The helper handles NextAuth's CSRF cookie/token and posts to the credentials +callback. The proxy, not the component, supplies both trust headers. Do not pass +the secret to `signIn`. + +For an existing deployment without a custom button, authenticate through the proxy, +open Cap's `/login` page **at its public proxied URL**, then run this in that page's +browser developer console. It obtains a CSRF token and submits a same-origin form; +no secret is entered in the browser: + +```js +(async () => { + const response = await fetch("/api/auth/csrf", { + credentials: "same-origin", + cache: "no-store", + }); + if (!response.ok) throw new Error("Could not fetch the Cap CSRF token"); + const { csrfToken } = await response.json(); + if (typeof csrfToken !== "string" || !csrfToken) { + throw new Error("Missing Cap CSRF token; check the proxy login"); + } + const form = document.createElement("form"); + form.method = "POST"; + form.action = "/api/auth/callback/trusted-proxy"; + for (const [name, value] of Object.entries({ + csrfToken, + callbackUrl: `${location.origin}/`, + })) { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = name; + input.value = value; + form.appendChild(input); + } + document.body.appendChild(form); + form.submit(); +})(); +``` + +A plain GET link to `/api/auth/signin/trusted-proxy` does not complete a credentials +sign-in. Use the helper or the CSRF-protected POST above. Missing/incorrect proxy +headers or a nonexistent configured account reject the login; check the server's +configuration and proxy rules rather than exposing the origin or weakening the +secret requirement. diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index 8d9c00f7272..0446c990f05 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -7,6 +7,7 @@ import { getServerSession as _getServerSession } from "next-auth"; import type { Adapter } from "next-auth/adapters"; import { decode, type JWT, type JWTDecodeParams } from "next-auth/jwt"; import AppleProvider from "next-auth/providers/apple"; +import CredentialsProvider from "next-auth/providers/credentials"; import EmailProvider from "next-auth/providers/email"; import GoogleProvider from "next-auth/providers/google"; import type { Provider } from "next-auth/providers/index"; @@ -32,6 +33,10 @@ export const maxDuration = 120; const OTP_CODE_MAX_AGE_SECONDS = 10 * 60; +// authOptions() is rebuilt per request (getServerSession, getCurrentUser), so +// the trusted-proxy warning is gated at module scope to log once per process. +let trustedProxyWarned = false; + export async function decodeSessionToken( params: JWTDecodeParams, ): Promise { @@ -104,6 +109,16 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { const oidcIssuer = serverEnv().OIDC_ISSUER; const oidcClientId = serverEnv().OIDC_CLIENT_ID; const oidcClientSecret = serverEnv().OIDC_CLIENT_SECRET; + const trustedProxyEnabled = + serverEnv().TRUSTED_PROXY_AUTH_HEADER && + serverEnv().TRUSTED_PROXY_AUTH_EMAIL && + serverEnv().TRUSTED_PROXY_AUTH_SECRET?.trim(); + if (!trustedProxyWarned && trustedProxyEnabled) { + trustedProxyWarned = true; + console.warn( + "[auth] trusted-proxy sign-in is ENABLED. Keep the origin private and use an authenticating reverse proxy that strips client-sent copies of both the trust and secret headers and sets them only on owner-authenticated requests.", + ); + } _providers = [ ...(oidcIssuer && oidcClientId && oidcClientSecret ? [ @@ -181,6 +196,59 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { }), ] : []), + ...(trustedProxyEnabled + ? [ + CredentialsProvider({ + id: "trusted-proxy", + name: "Reverse Proxy", + credentials: {}, + // SECURITY: this header is trusted ONLY because the operator + // opted in via env, asserting Cap runs behind a reverse proxy + // that strips client-sent copies of both trust headers and sets + // them only on owner-authenticated requests. Never enable without such a + // proxy — it would be a login bypass. Fails closed below. + async authorize(_credentials, req) { + const env = serverEnv(); + const header = env.TRUSTED_PROXY_AUTH_HEADER?.toLowerCase(); + const email = env.TRUSTED_PROXY_AUTH_EMAIL?.toLowerCase(); + const secret = env.TRUSTED_PROXY_AUTH_SECRET; + if (!header || !email || !secret?.trim()) return null; + // The router is the sole authority for this header (it strips + // any client-sent copy), so its presence means the request was + // owner-authenticated. Fail closed on anything else. + if (req?.headers?.[header] !== "true") return null; + + // Always require the proxy's secret, even if a caller can reach + // the origin directly. Compare fixed-size digests in constant time. + const provided = req?.headers?.["x-trusted-proxy-secret"]; + if (typeof provided !== "string" || !provided) return null; + const digest = (s: string) => + crypto.createHash("sha256").update(s).digest(); + if (!crypto.timingSafeEqual(digest(provided), digest(secret))) + return null; + + const [user] = await db() + .select({ + id: users.id, + email: users.email, + name: users.name, + image: users.image, + }) + .from(users) + .where(eq(users.email, email)) + .limit(1); + if (!user) return null; + + return { + id: user.id, + email: user.email, + name: user.name, + image: user.image, + }; + }, + }), + ] + : []), EmailProvider({ // next-auth defaults to 24h, but the code is 6 digits and the // verify path has no attempt limiting, so a day-long window is a diff --git a/packages/env/server.ts b/packages/env/server.ts index 4ae865b54f7..7ba84c853ca 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -85,6 +85,30 @@ function createServerEnv() { OIDC_CLIENT_SECRET: z.string().optional(), OIDC_NAME: z.string().optional(), + /// Reverse-proxy (trusted header) auth + // Sign in via an authenticating reverse proxy (e.g. OpenHost). Set all + // three values to enable. SECURITY: keep the origin private; the proxy + // must strip client-sent trust/secret headers and set both only after auth. + TRUSTED_PROXY_AUTH_HEADER: z + .string() + .optional() + .describe( + "Header an authenticating reverse proxy sets to 'true' on owner-authenticated requests (e.g. 'x-openhost-is-owner'). Unset disables the provider.", + ), + TRUSTED_PROXY_AUTH_EMAIL: z + .string() + .email() + .optional() + .describe( + "Email of the Cap account to sign in when the trusted header is present.", + ), + TRUSTED_PROXY_AUTH_SECRET: z + .string() + .optional() + .describe( + "Required to enable trusted-proxy auth: a non-blank shared secret the proxy must send as 'x-trusted-proxy-secret'. Unset or blank disables the provider. Keep this server-side, never in browser code.", + ), + /// Settings CAP_VIDEOS_DEFAULT_PUBLIC: boolString(true).describe( "Should videos be public or private by default",