From b4c5aff1c4a92d60af614b0d5adb03bbc5759156 Mon Sep 17 00:00:00 2001 From: Carl Kho Date: Fri, 31 Jul 2026 10:31:17 -0700 Subject: [PATCH 1/2] Add opt-in reverse-proxy header auth (e.g. OpenHost SSO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets self-hosters who front Cap with an authenticating reverse proxy sign in via that proxy instead of Cap's email code. A CredentialsProvider is added to the providers array only when TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are both set; it signs in the one configured account when the trusted header equals "true", and fails closed otherwise. Same opt-in pattern as Grafana auth.proxy / Gitea reverse-proxy auth. Hardening: - Optional TRUSTED_PROXY_AUTH_SECRET, compared in constant time and length-blind (sha256 both sides, then timingSafeEqual), so a request reaching Cap outside the proxy can't spoof the header. - A boot-time console.warn whenever the provider is enabled. Works with Cap's existing jwt/session callbacks unchanged — the jwt callback already re-fetches by token.email and stamps id + sessionVersion. Typechecked against next-auth 4.24.5 under --strict. Trigger via signIn("trusted-proxy"); a login-page button is a small follow-up. Co-Authored-By: Claude Opus 4.8 --- packages/database/auth/auth-options.ts | 65 ++++++++++++++++++++++++++ packages/env/server.ts | 23 +++++++++ 2 files changed, 88 insertions(+) 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..b53ed338d37 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -75,6 +75,29 @@ 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() + .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", From ab22dc9e6053767ed74ec05ef0bc45ac29f8955a Mon Sep 17 00:00:00 2001 From: Carl Kho Date: Fri, 31 Jul 2026 18:05:17 -0700 Subject: [PATCH 2/2] address review: validate TRUSTED_PROXY_AUTH_EMAIL as an email Per tembo's review: add .email() so a malformed value (e.g. a bare username) fails loudly at startup instead of silently returning no DB rows at runtime. Co-Authored-By: Claude Opus 4.8 --- packages/env/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/env/server.ts b/packages/env/server.ts index b53ed338d37..019a641daae 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -87,6 +87,7 @@ function createServerEnv() { ), TRUSTED_PROXY_AUTH_EMAIL: z .string() + .email() .optional() .describe( "Email of the Cap account to sign in when the trusted header is present.",