Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
244 changes: 244 additions & 0 deletions apps/web/__tests__/unit/auth-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string, string> | undefined,
req: { headers?: Record<string, string> },
) => Promise<unknown>;

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<string, string>,
},
),
).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", () => {
Expand Down
98 changes: 98 additions & 0 deletions docs/trusted-proxy-auth.md
Original file line number Diff line number Diff line change
@@ -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=<a-long-random-secret-shared-only-with-the-proxy>
```

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 (
<button onClick={() => void signIn("trusted-proxy", { callbackUrl: "/" })}>
Sign in through proxy
</button>
);
}
```

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.
Loading