-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Add opt-in reverse-proxy header auth (e.g. OpenHost SSO) #2060
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+119
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This opt-in condition is the security boundary for 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(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this is expected to be an email address, adding
Suggested change
|
||||||||||||||||
| .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", | ||||||||||||||||
|
|
||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This
console.warnlives inside theprovidersgetter, but NextAuth'sgetServerSessionbuilds its options withObject.assign({}, authOptions(), { providers: [] }), which invokes that getter.authOptions()is reconstructed on everygetSession()/getCurrentUser()call (packages/database/auth/session.ts, packages/web-backend/src/Auth.ts), so_providersisundefinedeach 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.