From f9d433b1806955ef5c044dfc3e75d51fe4bbb2ff Mon Sep 17 00:00:00 2001 From: Carl Kho Date: Fri, 31 Jul 2026 10:31:17 -0700 Subject: [PATCH 1/4] 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. --- 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 8d9c00f7272..f1d1da18020 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"; @@ -104,6 +105,14 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { const oidcIssuer = serverEnv().OIDC_ISSUER; const oidcClientId = serverEnv().OIDC_CLIENT_ID; const oidcClientSecret = serverEnv().OIDC_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 = [ ...(oidcIssuer && oidcClientId && oidcClientSecret ? [ @@ -181,6 +190,62 @@ export const authOptions = (ssoContext?: SsoAuthContext): 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({ // 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..eb1d39d1f52 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -85,6 +85,29 @@ 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 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 92d8776e2e7dc2b0999812f17d2cb7de10722b14 Mon Sep 17 00:00:00 2001 From: Carl Kho Date: Fri, 31 Jul 2026 18:05:17 -0700 Subject: [PATCH 2/4] 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. --- packages/env/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/env/server.ts b/packages/env/server.ts index eb1d39d1f52..d6cd9947c1c 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -97,6 +97,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.", From 67da535047f5fc520aca15044364886cb9d392e7 Mon Sep 17 00:00:00 2001 From: Carl Kho Date: Tue, 6 Oct 2026 22:22:11 -0700 Subject: [PATCH 3/4] address review: warn once, fail closed without non-null assertions, test opt-in - Gate the trusted-proxy warning at module scope; authOptions() is rebuilt per request, so the per-instance warning logged on every session lookup. - Replace the env non-null assertions in authorize with a fail-closed guard. - Test that the provider registers only when both TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are set, mirroring the Apple provider test, plus authorize's fail-closed paths and the once-per-process warning. --- apps/web/__tests__/unit/auth-options.test.ts | 150 +++++++++++++++++++ packages/database/auth/auth-options.ts | 15 +- 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/apps/web/__tests__/unit/auth-options.test.ts b/apps/web/__tests__/unit/auth-options.test.ts index 8f919507e6d..2cf84af191e 100644 --- a/apps/web/__tests__/unit/auth-options.test.ts +++ b/apps/web/__tests__/unit/auth-options.test.ts @@ -47,6 +47,9 @@ const env = vi.hoisted(() => ({ 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,6 +108,15 @@ 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"; @@ -113,6 +125,9 @@ describe("authOptions", () => { env.WORKOS_API_KEY = "workos-secret"; 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 +182,141 @@ describe("authOptions", () => { }, ); + it("enables trusted-proxy sign-in when both header and email are configured", () => { + env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner"; + env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email; + + 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"] 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[key] = undefined; + + expect(providerIds(authOptions())).not.toContain("trusted-proxy"); + }, + ); + + 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"; + }); + + it("signs in the configured user when the proxy sets the header", async () => { + await expect( + trustedProxyAuthorize()( + {}, + { + headers: { "x-openhost-is-owner": "true" }, + }, + ), + ).resolves.toMatchObject({ + id: AUTHENTICATED_USER.id, + email: AUTHENTICATED_USER.email, + }); + }); + + it.each([ + ["header is absent", {}], + ["header is not exactly 'true'", { "x-openhost-is-owner": "1" }], + ])("fails closed when the %s", async (_label, headers) => { + await expect( + trustedProxyAuthorize()({}, { headers }), + ).resolves.toBeNull(); + }); + + it("fails closed when the configured user does not exist", async () => { + env.TRUSTED_PROXY_AUTH_EMAIL = "nobody@company.example"; + + await expect( + trustedProxyAuthorize()( + {}, + { + headers: { "x-openhost-is-owner": "true" }, + }, + ), + ).resolves.toBeNull(); + }); + + it("requires the shared secret when one is configured", async () => { + env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret"; + 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 { + 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/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index f1d1da18020..d3862c994c3 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -33,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 { @@ -106,9 +110,11 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { const oidcClientId = serverEnv().OIDC_CLIENT_ID; const oidcClientSecret = serverEnv().OIDC_CLIENT_SECRET; if ( + !trustedProxyWarned && serverEnv().TRUSTED_PROXY_AUTH_HEADER && serverEnv().TRUSTED_PROXY_AUTH_EMAIL ) { + trustedProxyWarned = true; 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.", ); @@ -204,7 +210,9 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { // 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 header = env.TRUSTED_PROXY_AUTH_HEADER?.toLowerCase(); + const email = env.TRUSTED_PROXY_AUTH_EMAIL?.toLowerCase(); + if (!header || !email) 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. @@ -219,11 +227,12 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { req?.headers?.["x-trusted-proxy-secret"] ?? ""; const digest = (s: string) => crypto.createHash("sha256").update(s).digest(); - if (!crypto.timingSafeEqual(digest(provided), digest(secret))) + 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, From aa0ef34fcc557bf0299ed503644e1d621d18a0e3 Mon Sep 17 00:00:00 2001 From: Carl Kho Date: Tue, 20 Oct 2026 13:12:23 -0700 Subject: [PATCH 4/4] Require trusted-proxy secret and document sign-in --- README.md | 4 + apps/web/__tests__/unit/auth-options.test.ts | 132 ++++++++++++++++--- docs/trusted-proxy-auth.md | 98 ++++++++++++++ packages/database/auth/auth-options.ts | 42 +++--- packages/env/server.ts | 8 +- 5 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 docs/trusted-proxy-auth.md 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 2cf84af191e..3b2ccf11cca 100644 --- a/apps/web/__tests__/unit/auth-options.test.ts +++ b/apps/web/__tests__/unit/auth-options.test.ts @@ -44,6 +44,9 @@ 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", @@ -123,6 +126,9 @@ describe("authOptions", () => { 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; @@ -182,9 +188,10 @@ describe("authOptions", () => { }, ); - it("enables trusted-proxy sign-in when both header and email are configured", () => { + 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"); }); @@ -193,17 +200,66 @@ describe("authOptions", () => { expect(providerIds(authOptions())).not.toContain("trusted-proxy"); }); - it.each(["TRUSTED_PROXY_AUTH_HEADER", "TRUSTED_PROXY_AUTH_EMAIL"] as const)( + 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, @@ -223,16 +279,17 @@ describe("authOptions", () => { 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"; }); - it("signs in the configured user when the proxy sets the header", async () => { + 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: { "x-openhost-is-owner": "true" }, - }, - ), + trustedProxyAuthorize()({}, { headers: validHeaders }), ).resolves.toMatchObject({ id: AUTHENTICATED_USER.id, email: AUTHENTICATED_USER.email, @@ -240,29 +297,63 @@ describe("authOptions", () => { }); it.each([ - ["header is absent", {}], - ["header is not exactly 'true'", { "x-openhost-is-owner": "1" }], + ["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: { "x-openhost-is-owner": "true" }, - }, - ), + trustedProxyAuthorize()({}, { headers: validHeaders }), ).resolves.toBeNull(); + expect(mocks.select).toHaveBeenCalledOnce(); }); - it("requires the shared secret when one is configured", async () => { - env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret"; + 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( @@ -305,6 +396,9 @@ describe("authOptions", () => { 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( 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 d3862c994c3..0446c990f05 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -109,14 +109,14 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { const oidcIssuer = serverEnv().OIDC_ISSUER; const oidcClientId = serverEnv().OIDC_CLIENT_ID; const oidcClientSecret = serverEnv().OIDC_CLIENT_SECRET; - if ( - !trustedProxyWarned && + const trustedProxyEnabled = serverEnv().TRUSTED_PROXY_AUTH_HEADER && - serverEnv().TRUSTED_PROXY_AUTH_EMAIL - ) { + 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. Only run this behind a reverse proxy that strips client-sent copies of the trust header, or a login bypass is possible.", + "[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 = [ @@ -196,8 +196,7 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { }), ] : []), - ...(serverEnv().TRUSTED_PROXY_AUTH_HEADER && - serverEnv().TRUSTED_PROXY_AUTH_EMAIL + ...(trustedProxyEnabled ? [ CredentialsProvider({ id: "trusted-proxy", @@ -205,33 +204,28 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => { 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 + // 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(); - if (!header || !email) return null; + 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; - // 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; - } + // 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({ diff --git a/packages/env/server.ts b/packages/env/server.ts index d6cd9947c1c..7ba84c853ca 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -86,9 +86,9 @@ function createServerEnv() { OIDC_NAME: 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. + // 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() @@ -106,7 +106,7 @@ function createServerEnv() { .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.", + "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