Skip to content

Add opt-in reverse-proxy header auth (e.g. OpenHost SSO) - #2060

Open
CarlKho-Minerva wants to merge 3 commits into
CapSoftware:mainfrom
CarlKho-Minerva:carl/trusted-proxy-auth
Open

CarlKho-Minerva wants to merge 3 commits into
CapSoftware:mainfrom
CarlKho-Minerva:carl/trusted-proxy-auth

Conversation

@CarlKho-Minerva

@CarlKho-Minerva CarlKho-Minerva commented Jul 31, 2026 •

Copy link
Copy Markdown

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.

RetriggerConfidence Score: 5/5

The PR appears safe to merge.

Summary

Adds opt-in reverse-proxy authentication:

  • Registers a trusted-proxy credentials provider when the required header and account email are configured.
  • Validates the proxy assertion, optional shared secret, and configured database user before authentication.
  • Adds server environment validation, a one-time security warning, and focused unit coverage.

Reviews (2) · Last reviewed commit: "address review: warn once, fail closed w..." · Reviewed by Greptile

Comment thread packages/env/server.ts
Comment on lines +88 to +90
TRUSTED_PROXY_AUTH_EMAIL: z
.string()
.optional()

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()

Comment on lines +75 to +82
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.",
);
}

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.

Comment on lines +119 to +120
...(serverEnv().TRUSTED_PROXY_AUTH_HEADER &&
serverEnv().TRUSTED_PROXY_AUTH_EMAIL

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.

@richiemcilroy

Copy link
Copy Markdown
Member

Thanks. This now conflicts with main's auth-options.ts. Please rebase, add a test for the enabled and partially configured cases like the existing Apple provider test, and re-trigger Greptile. We need Greptile 5/5 on the latest commit and green CI before we review.

CarlKho-Minerva and others added 3 commits October 6, 2026 22:17
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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…est 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.
@CarlKho-Minerva
CarlKho-Minerva force-pushed the carl/trusted-proxy-auth branch from ab22dc9 to e13b79e Compare October 7, 2026 05:22

@superagent-security superagent-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Superagent found 1 security concern(s).

// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The trusted-proxy provider permits account takeover when its optional shared secret is unset.

With the shared secret unset, a caller can send the trusted header directly and receive a session for the configured account.

Require the proxy secret whenever this provider is enabled, and keep the Cap origin inaccessible except through the proxy.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="packages/database/auth/auth-options.ts">
<violation number="1" location="packages/database/auth/auth-options.ts:178">
<priority>P1</priority>
<title>The trusted-proxy provider permits account takeover when its optional shared secret is unset.</title>
<evidence>When TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are set, the new CredentialsProvider is registered (lines 153–165). Its authorize callback accepts the configured header value at line 173, but the check for x-trusted-proxy-secret runs only when TRUSTED_PROXY_AUTH_SECRET is truthy (lines 178–188); with the secret unset, a request carrying the assertion proceeds to load and return the configured existing user's identity (lines 190–207). NextAuth v4's Credentials provider calls authorize with credentials submitted via HTTP POST and treats a returned user as a successful sign-in (official v4 docs: https://next-auth.js.org/providers/credentials). The web route dispatches non-WorkOS auth requests directly to NextAuth (apps/web/app/api/auth/[...nextauth]/route.ts:78–80). The repository's standard docker-compose.yml publishes the web container port on the host (lines 35–36), so when this opt-in mode is used with that published origin reachable alongside a reverse proxy, an unauthenticated remote caller can send the trusted assertion directly to Cap and sign in as the configured account unless the optional secret is configured. The lockfile resolves next-auth to 4.24.14. In the base implementation, auth-options.ts had no CredentialsProvider and only the pre-existing email/OAuth/WorkOS authentication routes; this newly added route lets a caller authenticate without owning the account's email or external identity.</evidence>
<recommendation>Require a non-empty TRUSTED_PROXY_AUTH_SECRET whenever trusted-proxy auth is enabled and reject requests unless it matches; configure the proxy to overwrite/strip client copies of both trust headers. Also keep the Cap origin private or firewall it so requests cannot bypass the authenticating proxy.</recommendation>
</violation>
</file>

@CarlKho-Minerva

Copy link
Copy Markdown
Author

hey @richiemcilroy! rebased onto main. the conflict was just main's new conditional workos block, so i kept that and put trusted-proxy right after it

added tests the same shape as the apple one: the provider only shows up when both TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are set, and it's gone if either is missing. threw in a few for authorize failing closed too (no header, wrong value, unknown user, bad secret)

also fixed the tembo stuff. the warning was firing on every request haha, now it's once per process

ci looks like it's waiting on a maintainer to approve the runs since it's from a fork 🙏

@greptileai please re-review the latest commit

@CarlKho-Minerva
CarlKho-Minerva force-pushed the carl/trusted-proxy-auth branch from e13b79e to c5bd88c Compare October 17, 2026 05:45
@vercel

vercel Bot commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

@CarlKho-Minerva is attempting to deploy a commit to the Cap Software Inc Team on Vercel.

A member of the Team first needs to authorize it.

@CarlKho-Minerva
CarlKho-Minerva force-pushed the carl/trusted-proxy-auth branch from c5bd88c to e13b79e Compare October 17, 2026 06:02

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants