diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index d2fa48584bc..8dcde91304d 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"; @@ -71,6 +72,14 @@ export const authOptions = (): NextAuthOptions => { if (_providers) return _providers; const appleClientId = serverEnv().APPLE_CLIENT_ID; const appleClientSecret = serverEnv().APPLE_CLIENT_SECRET; + if ( + serverEnv().TRUSTED_PROXY_AUTH_HEADER && + serverEnv().TRUSTED_PROXY_AUTH_EMAIL + ) { + console.warn( + "[auth] trusted-proxy sign-in is ENABLED. Only run this behind a reverse proxy that strips client-sent copies of the trust header, or a login bypass is possible.", + ); + } _providers = [ ...(appleClientId && appleClientSecret ? [ @@ -107,6 +116,62 @@ export const authOptions = (): NextAuthOptions => { }; }, }), + ...(serverEnv().TRUSTED_PROXY_AUTH_HEADER && + serverEnv().TRUSTED_PROXY_AUTH_EMAIL + ? [ + 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 any client-sent copy of the header and sets it + // only on 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(); + // 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; + + // Defense-in-depth: when a shared secret is configured, the proxy + // must also send it, so a request that reaches Cap outside the + // proxy can't spoof the header. Constant-time and length-blind. + const secret = env.TRUSTED_PROXY_AUTH_SECRET; + if (secret) { + const provided = + req?.headers?.["x-trusted-proxy-secret"] ?? ""; + const digest = (s: string) => + crypto.createHash("sha256").update(s).digest(); + if (!crypto.timingSafeEqual(digest(provided), digest(secret))) + return null; + } + + const email = env.TRUSTED_PROXY_AUTH_EMAIL!.toLowerCase(); + 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({ async generateVerificationToken() { return crypto.randomInt(100000, 1000000).toString(); diff --git a/packages/env/server.ts b/packages/env/server.ts index 71c46370cec..019a641daae 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -75,6 +75,30 @@ function createServerEnv() { WORKOS_CLIENT_ID: z.string().optional(), WORKOS_API_KEY: z.string().optional(), + /// Reverse-proxy (trusted header) auth + // Sign in via an authenticating reverse proxy (e.g. OpenHost). Set BOTH + // to enable. SECURITY: only use behind a proxy that strips any client-sent + // copy of the header — otherwise it is a login bypass. + 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( + "Optional defense-in-depth: if set, the proxy must also send this value as the 'x-trusted-proxy-secret' header, so a request that reaches Cap outside the proxy can't spoof the trust header.", + ), + /// Settings CAP_VIDEOS_DEFAULT_PUBLIC: boolString(true).describe( "Should videos be public or private by default",