Skip to content

Commit aa0ef34

Browse files
Require trusted-proxy secret and document sign-in
1 parent 67da535 commit aa0ef34

5 files changed

Lines changed: 237 additions & 47 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ S3_PUBLIC_URL=https://s3.yourdomain.com
110110

111111
See the [self-hosting guide](https://cap.so/docs/self-hosting) for email setup, AI providers, SSL, storage, production hardening, and troubleshooting.
112112

113+
For single-owner instances behind an authenticating reverse proxy, see
114+
[trusted-proxy authentication](docs/trusted-proxy-auth.md) for the required shared
115+
secret, proxy hardening, and how to trigger sign-in without a built-in login button.
116+
113117
## Local Development
114118

115119
Cap is a Turborepo monorepo with Rust, TypeScript, Tauri, SolidStart, Next.js, Drizzle, MySQL, Tailwind CSS, and shared media crates.

‎apps/web/__tests__/unit/auth-options.test.ts‎

Lines changed: 113 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ const env = vi.hoisted(() => ({
4444
GOOGLE_CLIENT_ID: "google-client",
4545
GOOGLE_CLIENT_SECRET: "google-secret",
4646
NEXTAUTH_SECRET: "next-auth-secret",
47+
OIDC_ISSUER: undefined as string | undefined,
48+
OIDC_CLIENT_ID: undefined as string | undefined,
49+
OIDC_CLIENT_SECRET: undefined as string | undefined,
4750
RESEND_API_KEY: undefined,
4851
WORKOS_API_KEY: "workos-secret",
4952
WORKOS_CLIENT_ID: "workos-client",
@@ -123,6 +126,9 @@ describe("authOptions", () => {
123126
env.APPLE_CLIENT_SECRET = "apple-secret";
124127
env.WORKOS_CLIENT_ID = "workos-client";
125128
env.WORKOS_API_KEY = "workos-secret";
129+
env.OIDC_ISSUER = undefined;
130+
env.OIDC_CLIENT_ID = undefined;
131+
env.OIDC_CLIENT_SECRET = undefined;
126132
env.CAP_ALLOWED_SIGNUP_DOMAINS = undefined;
127133
env.CAP_BLOCKED_SIGNUP_DOMAINS = undefined;
128134
env.TRUSTED_PROXY_AUTH_HEADER = undefined;
@@ -182,9 +188,10 @@ describe("authOptions", () => {
182188
},
183189
);
184190

185-
it("enables trusted-proxy sign-in when both header and email are configured", () => {
191+
it("enables trusted-proxy sign-in when header, email and secret are configured", () => {
186192
env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner";
187193
env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email;
194+
env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret";
188195

189196
expect(providerIds(authOptions())).toContain("trusted-proxy");
190197
});
@@ -193,17 +200,66 @@ describe("authOptions", () => {
193200
expect(providerIds(authOptions())).not.toContain("trusted-proxy");
194201
});
195202

196-
it.each(["TRUSTED_PROXY_AUTH_HEADER", "TRUSTED_PROXY_AUTH_EMAIL"] as const)(
203+
it.each([
204+
"TRUSTED_PROXY_AUTH_HEADER",
205+
"TRUSTED_PROXY_AUTH_EMAIL",
206+
"TRUSTED_PROXY_AUTH_SECRET",
207+
] as const)(
197208
"does not expose a partially configured trusted-proxy provider when %s is missing",
198209
(key) => {
199210
env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner";
200211
env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email;
212+
env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret";
201213
env[key] = undefined;
202214

203215
expect(providerIds(authOptions())).not.toContain("trusted-proxy");
204216
},
205217
);
206218

219+
it.each([undefined, "", " "])(
220+
"does not register trusted-proxy with only header/email and an unusable secret (%s)",
221+
(secret) => {
222+
env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner";
223+
env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email;
224+
env.TRUSTED_PROXY_AUTH_SECRET = secret;
225+
226+
expect(providerIds(authOptions())).not.toContain("trusted-proxy");
227+
},
228+
);
229+
230+
it.each([undefined, "proxy-secret"])(
231+
"preserves independent OIDC sign-in and its verified-email gate with proxy secret %s",
232+
async (secret) => {
233+
env.OIDC_ISSUER = "https://idp.example";
234+
env.OIDC_CLIENT_ID = "oidc-client";
235+
env.OIDC_CLIENT_SECRET = "oidc-secret";
236+
env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner";
237+
env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email;
238+
env.TRUSTED_PROXY_AUTH_SECRET = secret;
239+
const options = authOptions();
240+
expect(providerIds(options)).toContain("oidc");
241+
expect(providerIds(options).includes("trusted-proxy")).toBe(!!secret);
242+
const { signIn } = callbacksFor(options);
243+
const account: Account = {
244+
provider: "oidc",
245+
providerAccountId: "oidc-user",
246+
type: "oauth",
247+
};
248+
for (const email_verified of [undefined, false, true]) {
249+
const profile = { email: PROFILE.email, email_verified };
250+
await expect(
251+
signIn({
252+
user: AUTHENTICATED_USER,
253+
account,
254+
profile,
255+
}),
256+
).resolves.toBe(
257+
email_verified === true ? true : "/login?error=OidcEmailUnverified",
258+
);
259+
}
260+
},
261+
);
262+
207263
describe("trusted-proxy authorize", () => {
208264
type Authorize = (
209265
credentials: Record<string, string> | undefined,
@@ -223,46 +279,81 @@ describe("authOptions", () => {
223279
beforeEach(() => {
224280
env.TRUSTED_PROXY_AUTH_HEADER = "X-OpenHost-Is-Owner";
225281
env.TRUSTED_PROXY_AUTH_EMAIL = "Alex@Company.example";
282+
env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret";
226283
});
227284

228-
it("signs in the configured user when the proxy sets the header", async () => {
285+
const validHeaders = {
286+
"x-openhost-is-owner": "true",
287+
"x-trusted-proxy-secret": "proxy-secret",
288+
};
289+
290+
it("signs in the configured user when the proxy sets both headers", async () => {
229291
await expect(
230-
trustedProxyAuthorize()(
231-
{},
232-
{
233-
headers: { "x-openhost-is-owner": "true" },
234-
},
235-
),
292+
trustedProxyAuthorize()({}, { headers: validHeaders }),
236293
).resolves.toMatchObject({
237294
id: AUTHENTICATED_USER.id,
238295
email: AUTHENTICATED_USER.email,
239296
});
240297
});
241298

242299
it.each([
243-
["header is absent", {}],
244-
["header is not exactly 'true'", { "x-openhost-is-owner": "1" }],
300+
["header is absent", { "x-trusted-proxy-secret": "proxy-secret" }],
301+
[
302+
"header is not exactly 'true'",
303+
{ ...validHeaders, "x-openhost-is-owner": "1" },
304+
],
245305
])("fails closed when the %s", async (_label, headers) => {
246306
await expect(
247307
trustedProxyAuthorize()({}, { headers }),
248308
).resolves.toBeNull();
309+
expect(mocks.select).not.toHaveBeenCalled();
249310
});
250311

251312
it("fails closed when the configured user does not exist", async () => {
252313
env.TRUSTED_PROXY_AUTH_EMAIL = "nobody@company.example";
253314

254315
await expect(
255-
trustedProxyAuthorize()(
256-
{},
257-
{
258-
headers: { "x-openhost-is-owner": "true" },
259-
},
260-
),
316+
trustedProxyAuthorize()({}, { headers: validHeaders }),
261317
).resolves.toBeNull();
318+
expect(mocks.select).toHaveBeenCalledOnce();
262319
});
263320

264-
it("requires the shared secret when one is configured", async () => {
265-
env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret";
321+
it.each([undefined, "", " "])(
322+
"fails closed if the configured secret becomes unusable after registration (%s)",
323+
async (secret) => {
324+
const authorize = trustedProxyAuthorize();
325+
env.TRUSTED_PROXY_AUTH_SECRET = secret;
326+
await expect(
327+
authorize({}, { headers: validHeaders }),
328+
).resolves.toBeNull();
329+
expect(mocks.select).not.toHaveBeenCalled();
330+
},
331+
);
332+
333+
it.each([undefined, "", "wrong", "x".repeat(1024), ["proxy-secret"]])(
334+
"rejects missing, empty, incorrect or malformed request secrets (%s)",
335+
async (secret) => {
336+
await expect(
337+
trustedProxyAuthorize()(
338+
{},
339+
{
340+
headers: {
341+
...validHeaders,
342+
"x-trusted-proxy-secret": secret,
343+
} as Record<string, string>,
344+
},
345+
),
346+
).resolves.toBeNull();
347+
expect(mocks.select).not.toHaveBeenCalled();
348+
},
349+
);
350+
351+
it("rejects requests without headers", async () => {
352+
await expect(trustedProxyAuthorize()({}, {})).resolves.toBeNull();
353+
expect(mocks.select).not.toHaveBeenCalled();
354+
});
355+
356+
it("always requires the shared secret", async () => {
266357
const authorize = trustedProxyAuthorize();
267358

268359
await expect(
@@ -305,6 +396,9 @@ describe("authOptions", () => {
305396
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
306397

307398
try {
399+
void freshAuthOptions().providers;
400+
expect(warn).not.toHaveBeenCalled();
401+
env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret";
308402
for (let i = 0; i < 3; i++) void freshAuthOptions().providers;
309403

310404
expect(

‎docs/trusted-proxy-auth.md‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Trusted reverse-proxy authentication
2+
3+
This opt-in provider lets an authenticating reverse proxy sign requests into one
4+
existing Cap account. It is intended for single-owner self-hosted instances, not
5+
multi-user SSO: every successful sign-in uses the configured account.
6+
7+
## Configuration and security boundary
8+
9+
Set all three variables in the Cap web server's environment:
10+
11+
```dotenv
12+
TRUSTED_PROXY_AUTH_HEADER=x-openhost-is-owner
13+
TRUSTED_PROXY_AUTH_EMAIL=owner@example.com
14+
TRUSTED_PROXY_AUTH_SECRET=<a-long-random-secret-shared-only-with-the-proxy>
15+
```
16+
17+
Create the Cap account through a normal login first. This provider does not create
18+
users. It is absent unless the header, email and a non-blank secret are configured;
19+
ordinary email/OAuth login remains available. Restart Cap after changing its env.
20+
21+
Configure the proxy to:
22+
23+
- Authenticate the owner before forwarding requests to Cap, including `/api/auth/*`.
24+
Do not accept any authenticated proxy user as the owner.
25+
- Strip client-sent copies of **both** the configured trust header and
26+
`x-trusted-proxy-secret`. On owner-authenticated requests only, set the trust
27+
header to the literal string `true` and the secret header to the configured
28+
secret. Never forward a client-provided value for either header.
29+
- Keep the Cap origin private or firewalled so clients cannot bypass the proxy
30+
through a published container port, alternative hostname or ingress. Use HTTPS
31+
on the public endpoint and a protected connection between the proxy and Cap.
32+
33+
Choose a trust-header name distinct from `x-trusted-proxy-secret`. Keep the secret
34+
out of browser code, URLs, responses and logs. It is required even when the origin
35+
is private; a direct request with only the trust header must not authenticate.
36+
37+
## Triggering sign-in
38+
39+
There is no built-in Reverse Proxy button yet. A custom Cap client component can
40+
trigger the provider with NextAuth's client helper:
41+
42+
```tsx
43+
"use client";
44+
45+
import { signIn } from "next-auth/react";
46+
47+
export function ReverseProxySignIn() {
48+
return (
49+
<button onClick={() => void signIn("trusted-proxy", { callbackUrl: "/" })}>
50+
Sign in through proxy
51+
</button>
52+
);
53+
}
54+
```
55+
56+
The helper handles NextAuth's CSRF cookie/token and posts to the credentials
57+
callback. The proxy, not the component, supplies both trust headers. Do not pass
58+
the secret to `signIn`.
59+
60+
For an existing deployment without a custom button, authenticate through the proxy,
61+
open Cap's `/login` page **at its public proxied URL**, then run this in that page's
62+
browser developer console. It obtains a CSRF token and submits a same-origin form;
63+
no secret is entered in the browser:
64+
65+
```js
66+
(async () => {
67+
const response = await fetch("/api/auth/csrf", {
68+
credentials: "same-origin",
69+
cache: "no-store",
70+
});
71+
if (!response.ok) throw new Error("Could not fetch the Cap CSRF token");
72+
const { csrfToken } = await response.json();
73+
if (typeof csrfToken !== "string" || !csrfToken) {
74+
throw new Error("Missing Cap CSRF token; check the proxy login");
75+
}
76+
const form = document.createElement("form");
77+
form.method = "POST";
78+
form.action = "/api/auth/callback/trusted-proxy";
79+
for (const [name, value] of Object.entries({
80+
csrfToken,
81+
callbackUrl: `${location.origin}/`,
82+
})) {
83+
const input = document.createElement("input");
84+
input.type = "hidden";
85+
input.name = name;
86+
input.value = value;
87+
form.appendChild(input);
88+
}
89+
document.body.appendChild(form);
90+
form.submit();
91+
})();
92+
```
93+
94+
A plain GET link to `/api/auth/signin/trusted-proxy` does not complete a credentials
95+
sign-in. Use the helper or the CSRF-protected POST above. Missing/incorrect proxy
96+
headers or a nonexistent configured account reject the login; check the server's
97+
configuration and proxy rules rather than exposing the origin or weakening the
98+
secret requirement.

‎packages/database/auth/auth-options.ts‎

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,14 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => {
109109
const oidcIssuer = serverEnv().OIDC_ISSUER;
110110
const oidcClientId = serverEnv().OIDC_CLIENT_ID;
111111
const oidcClientSecret = serverEnv().OIDC_CLIENT_SECRET;
112-
if (
113-
!trustedProxyWarned &&
112+
const trustedProxyEnabled =
114113
serverEnv().TRUSTED_PROXY_AUTH_HEADER &&
115-
serverEnv().TRUSTED_PROXY_AUTH_EMAIL
116-
) {
114+
serverEnv().TRUSTED_PROXY_AUTH_EMAIL &&
115+
serverEnv().TRUSTED_PROXY_AUTH_SECRET?.trim();
116+
if (!trustedProxyWarned && trustedProxyEnabled) {
117117
trustedProxyWarned = true;
118118
console.warn(
119-
"[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.",
119+
"[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.",
120120
);
121121
}
122122
_providers = [
@@ -196,42 +196,36 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => {
196196
}),
197197
]
198198
: []),
199-
...(serverEnv().TRUSTED_PROXY_AUTH_HEADER &&
200-
serverEnv().TRUSTED_PROXY_AUTH_EMAIL
199+
...(trustedProxyEnabled
201200
? [
202201
CredentialsProvider({
203202
id: "trusted-proxy",
204203
name: "Reverse Proxy",
205204
credentials: {},
206205
// SECURITY: this header is trusted ONLY because the operator
207206
// opted in via env, asserting Cap runs behind a reverse proxy
208-
// that strips any client-sent copy of the header and sets it
209-
// only on authenticated requests. Never enable without such a
207+
// that strips client-sent copies of both trust headers and sets
208+
// them only on owner-authenticated requests. Never enable without such a
210209
// proxy — it would be a login bypass. Fails closed below.
211210
async authorize(_credentials, req) {
212211
const env = serverEnv();
213212
const header = env.TRUSTED_PROXY_AUTH_HEADER?.toLowerCase();
214213
const email = env.TRUSTED_PROXY_AUTH_EMAIL?.toLowerCase();
215-
if (!header || !email) return null;
214+
const secret = env.TRUSTED_PROXY_AUTH_SECRET;
215+
if (!header || !email || !secret?.trim()) return null;
216216
// The router is the sole authority for this header (it strips
217217
// any client-sent copy), so its presence means the request was
218218
// owner-authenticated. Fail closed on anything else.
219219
if (req?.headers?.[header] !== "true") return null;
220220

221-
// Defense-in-depth: when a shared secret is configured, the proxy
222-
// must also send it, so a request that reaches Cap outside the
223-
// proxy can't spoof the header. Constant-time and length-blind.
224-
const secret = env.TRUSTED_PROXY_AUTH_SECRET;
225-
if (secret) {
226-
const provided =
227-
req?.headers?.["x-trusted-proxy-secret"] ?? "";
228-
const digest = (s: string) =>
229-
crypto.createHash("sha256").update(s).digest();
230-
if (
231-
!crypto.timingSafeEqual(digest(provided), digest(secret))
232-
)
233-
return null;
234-
}
221+
// Always require the proxy's secret, even if a caller can reach
222+
// the origin directly. Compare fixed-size digests in constant time.
223+
const provided = req?.headers?.["x-trusted-proxy-secret"];
224+
if (typeof provided !== "string" || !provided) return null;
225+
const digest = (s: string) =>
226+
crypto.createHash("sha256").update(s).digest();
227+
if (!crypto.timingSafeEqual(digest(provided), digest(secret)))
228+
return null;
235229

236230
const [user] = await db()
237231
.select({

0 commit comments

Comments
 (0)