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
65 changes: 65 additions & 0 deletions packages/database/auth/auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.",
);
}
Comment on lines +75 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This console.warn lives inside the providers getter, but NextAuth's getServerSession builds its options with Object.assign({}, authOptions(), { providers: [] }), which invokes that getter. authOptions() is reconstructed on every getSession()/getCurrentUser() call (packages/database/auth/session.ts, packages/web-backend/src/Auth.ts), so _providers is undefined each time and the warning fires on every authenticated request rather than once at boot. A security warning that logs per-request becomes noise operators tune out. Guarding it with a module-level flag (e.g. let _trustedProxyWarned = false) checked here would make it fire once.

_providers = [
...(appleClientId && appleClientSecret
? [
Expand Down Expand Up @@ -107,6 +116,62 @@ export const authOptions = (): NextAuthOptions => {
};
},
}),
...(serverEnv().TRUSTED_PROXY_AUTH_HEADER &&
serverEnv().TRUSTED_PROXY_AUTH_EMAIL
Comment on lines +119 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This opt-in condition is the security boundary for the provider. apps/web/__tests__/unit/auth-options.test.ts already tests the same enable/partially-configured pattern for Apple — a parallel case asserting trusted-proxy is registered only when both TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are set (and absent otherwise) would lock this in and catch a regression that always registers the provider.

? [
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();
Expand Down
24 changes: 24 additions & 0 deletions packages/env/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +88 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is expected to be an email address, adding .email() would catch a malformed value at startup (e.g. a username instead of an email) instead of letting it silently fail at runtime when the DB lookup returns no rows.

Suggested change
TRUSTED_PROXY_AUTH_EMAIL: z
.string()
.optional()
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",
Expand Down