From e5209f80fec06509cceaa0c1b0e54ff88d8ab72b Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Mon, 5 Oct 2026 00:09:53 -0400 Subject: [PATCH] fix(auth): key rate limits on the resolved client IP and restrict the Expo auth proxy Resolve the client IP from the TCP peer plus trusted-proxy X-Forwarded-For hops (TRUSTED_PROXIES) and hand it to Better Auth in an internal header, so spoofed forwarding headers can no longer rotate sign-in rate-limit buckets. The Expo authorization proxy now only redirects to the OIDC provider's authorization endpoint. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 7 ++ apps/server/src/client-ip.ts | 138 ++++++++++++++++++++++++++ apps/server/src/expo-proxy-guard.ts | 43 ++++++++ apps/server/src/routes/auth.ts | 16 ++- apps/server/test/auth-handler.test.ts | 101 +++++++++++++++++++ apps/server/test/client-ip.test.ts | 109 ++++++++++++++++++++ docs/content/docs/configuration.mdx | 11 +- docs/content/docs/index.mdx | 1 + packages/auth/src/config.ts | 13 +++ packages/auth/src/server.ts | 10 +- 10 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/client-ip.ts create mode 100644 apps/server/src/expo-proxy-guard.ts create mode 100644 apps/server/test/auth-handler.test.ts create mode 100644 apps/server/test/client-ip.test.ts diff --git a/.env.example b/.env.example index 99a3715..bfa5029 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,13 @@ BETTER_AUTH_URL=http://localhost:3000 # OIDC_AUTO_REGISTER=true # Auto-create users on first OIDC login (default: true) # DISABLE_PASSWORD_LOGIN=false # Set to "true" to hide email/password form when OIDC is configured +# ─── Reverse Proxy (optional) ─────────────────────────────────────────── +# Proxies whose X-Forwarded-For hops are trusted when rate-limiting sign-ins. +# Default: loopback and private ranges (a proxy on the same host/Docker network). +# Add your proxy's IP/CIDR if it is elsewhere (e.g. Cloudflare's ranges when +# Cloudflare connects straight to Sofa), or set "none" to ignore forwarded headers. +# TRUSTED_PROXIES=127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 + # ─── Logging ────────────────────────────────────────────────────────── # Log verbosity: error, warn, info, debug (default: info) # LOG_LEVEL=info diff --git a/apps/server/src/client-ip.ts b/apps/server/src/client-ip.ts new file mode 100644 index 0000000..ae19f55 --- /dev/null +++ b/apps/server/src/client-ip.ts @@ -0,0 +1,138 @@ +import { BlockList, isIP } from "node:net"; + +import type { Context } from "hono"; +import { getConnInfo } from "hono/bun"; + +import { CLIENT_IP_HEADER } from "@sofa/auth/config"; +import { createLogger } from "@sofa/logger"; + +const log = createLogger("server"); + +/** Loopback and private ranges: a reverse proxy on the same host or Docker network. */ +export const DEFAULT_TRUSTED_PROXIES = [ + "127.0.0.0/8", + "::1/128", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7", +]; + +/** + * Parses `TRUSTED_PROXIES`: a comma-separated list of IPs/CIDRs. Unset or blank → the defaults; + * "none" → trust nothing (the TCP peer address is always the client). + */ +export function parseTrustedProxies(raw: string | undefined): string[] { + const value = raw?.trim(); + if (!value) return DEFAULT_TRUSTED_PROXIES; + if (value.toLowerCase() === "none") return []; + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +/** Builds the matcher for trusted proxies; malformed entries are returned in `invalid`. */ +export function buildTrustedProxyList(entries: readonly string[]): { + list: BlockList; + invalid: string[]; +} { + const list = new BlockList(); + const invalid: string[] = []; + for (const entry of entries) { + const [address = "", prefix, ...rest] = entry.split("/"); + const family = isIP(address); + const bits = prefix === undefined ? undefined : Number(prefix); + const maxBits = family === 6 ? 128 : 32; + if ( + family === 0 || + rest.length > 0 || + (prefix !== undefined && (!/^\d+$/.test(prefix) || (bits ?? 0) > maxBits)) + ) { + invalid.push(entry); + continue; + } + const type = family === 6 ? "ipv6" : "ipv4"; + if (bits === undefined) list.addAddress(address, type); + else list.addSubnet(address, bits, type); + } + return { list, invalid }; +} + +/** Strips the IPv4-mapped IPv6 prefix Bun reports on dual-stack sockets ("::ffff:1.2.3.4"). */ +export function normalizeAddress(address: string): string { + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address); + return mapped ? mapped[1] : address; +} + +function isTrusted(address: string, trusted: BlockList): boolean { + const family = isIP(address); + return family !== 0 && trusted.check(address, family === 6 ? "ipv6" : "ipv4"); +} + +/** + * The client IP for rate limiting. Walks from the TCP peer address leftwards through + * X-Forwarded-For, skipping trusted proxies; the first untrusted hop is the client. If every hop + * is trusted (a LAN client), the left-most one is the client. A malformed hop stops the walk at + * the last valid hop to its right. Returns null only when the peer address is unknown or invalid, + * so a client-sent header is never trusted on its own. + */ +export function resolveClientIp( + forwardedFor: string | null, + peerAddress: string | undefined, + trusted: BlockList, +): string | null { + if (!peerAddress) return null; + const hops = (forwardedFor ?? "") + .split(",") + .map((hop) => hop.trim()) + .filter(Boolean); + hops.push(peerAddress); + let client: string | null = null; + for (let i = hops.length - 1; i >= 0; i--) { + const hop = normalizeAddress(hops[i]); + if (isIP(hop) === 0) break; + client = hop; + if (!isTrusted(hop, trusted)) break; + } + return client; +} + +const configured = parseTrustedProxies(process.env.TRUSTED_PROXIES); +const { list: trustedProxies, invalid } = buildTrustedProxyList(configured); +if (invalid.length > 0) { + log.warn(`Ignoring invalid TRUSTED_PROXIES entries: ${invalid.join(", ")}`); +} +let warnedUntrustedForwarder = false; + +/** + * A copy of `req` whose CLIENT_IP_HEADER holds the resolved client IP (or is removed when the + * peer address is unknown). Better Auth rate-limits on that header. + */ +export function withClientIp(c: Context, req: Request): Request { + let peer: string | undefined; + try { + peer = getConnInfo(c).remote.address; + } catch { + // No Bun server in c.env (tests): the peer is unknown. + } + const forwardedFor = req.headers.get("x-forwarded-for"); + if ( + forwardedFor && + peer && + configured.length > 0 && + !warnedUntrustedForwarder && + !isTrusted(normalizeAddress(peer), trustedProxies) + ) { + warnedUntrustedForwarder = true; + log.warn( + `Ignored X-Forwarded-For from ${normalizeAddress(peer)}, which is not in TRUSTED_PROXIES. ` + + "If that is your reverse proxy, add its address to TRUSTED_PROXIES so sign-in rate limits apply per client.", + ); + } + const headers = new Headers(req.headers); + const clientIp = resolveClientIp(forwardedFor, peer, trustedProxies); + if (clientIp) headers.set(CLIENT_IP_HEADER, clientIp); + else headers.delete(CLIENT_IP_HEADER); + return new Request(req, { headers }); +} diff --git a/apps/server/src/expo-proxy-guard.ts b/apps/server/src/expo-proxy-guard.ts new file mode 100644 index 0000000..b922f3a --- /dev/null +++ b/apps/server/src/expo-proxy-guard.ts @@ -0,0 +1,43 @@ +import { getOidcDiscoveryURL, isOidcConfigured } from "@sofa/auth/config"; + +let cachedEndpoint: Promise | undefined; + +async function fetchAuthorizationEndpoint(discoveryURL: string): Promise { + try { + const res = await fetch(discoveryURL, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) return null; + const doc = (await res.json()) as { authorization_endpoint?: unknown } | null; + const value = doc?.authorization_endpoint; + return typeof value === "string" && URL.canParse(value) ? new URL(value) : null; + } catch { + return null; + } +} + +/** The IdP's `authorization_endpoint`, cached after the first successful fetch. */ +function getAuthorizationEndpoint(discoveryURL: string): Promise { + cachedEndpoint ??= fetchAuthorizationEndpoint(discoveryURL).then((url) => { + if (!url) cachedEndpoint = undefined; // retry on the next request + return url; + }); + return cachedEndpoint; +} + +/** Forgets the cached endpoint (tests change OIDC env between cases). */ +export function clearAuthorizationEndpointCache(): void { + cachedEndpoint = undefined; +} + +/** + * Whether the Expo plugin's authorization proxy may redirect to `target`. Only the configured + * OIDC provider's authorization endpoint is allowed: same origin and path as the discovery + * document's `authorization_endpoint`; the query string (client_id, state, PKCE…) is free. + */ +export async function isAllowedAuthorizationURL(target: string | undefined): Promise { + const discoveryURL = getOidcDiscoveryURL(); + if (!isOidcConfigured() || !discoveryURL || !target || !URL.canParse(target)) return false; + const endpoint = await getAuthorizationEndpoint(discoveryURL); + if (!endpoint) return false; + const url = new URL(target); + return url.origin === endpoint.origin && url.pathname === endpoint.pathname; +} diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index e0ad45d..16fc695 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -2,17 +2,29 @@ import { Hono } from "hono"; import { auth } from "@sofa/auth/server"; +import { withClientIp } from "../client-ip"; +import { isAllowedAuthorizationURL } from "../expo-proxy-guard"; + const app = new Hono(); +// The Expo plugin's proxy redirects to any https URL; allow only the OIDC provider's +// authorization endpoint (upstream FIXME in @better-auth/expo routes.ts). +app.get("/expo-authorization-proxy", async (c, next) => { + if (!(await isAllowedAuthorizationURL(c.req.query("authorizationURL")))) { + return c.json({ error: "Invalid authorizationURL" }, 400); + } + await next(); +}); + // Better Auth >= 1.7 serves genericOAuth callbacks at the core `/callback/:id` // endpoint. Existing IdP registrations (and `getOidcRedirectURI()`) still use // the legacy `/oauth2/callback/:id` path, so forward it to the new endpoint. app.all("/oauth2/callback/:providerId", (c) => { const url = new URL(c.req.url); url.pathname = url.pathname.replace("/oauth2/callback/", "/callback/"); - return auth.handler(new Request(url.toString(), c.req.raw)); + return auth.handler(withClientIp(c, new Request(url.toString(), c.req.raw))); }); -app.all("/*", (c) => auth.handler(c.req.raw)); +app.all("/*", (c) => auth.handler(withClientIp(c, c.req.raw))); export default app; diff --git a/apps/server/test/auth-handler.test.ts b/apps/server/test/auth-handler.test.ts new file mode 100644 index 0000000..53eb6c8 --- /dev/null +++ b/apps/server/test/auth-handler.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { auth } from "@sofa/auth/server"; + +import { clearAuthorizationEndpointCache } from "../src/expo-proxy-guard"; +import app from "../src/routes/auth"; + +vi.mock("@sofa/auth/server", () => ({ + auth: { + api: {}, + handler: vi.fn<(req: Request) => Promise>(async () => new Response("ok")), + }, +})); + +const handler = vi.mocked(auth.handler); + +const fetchMock = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>( + async () => + Response.json({ authorization_endpoint: "https://idp.example.com/application/o/authorize/" }), +); + +const ALLOWED = "https://idp.example.com/application/o/authorize/?client_id=sofa&state=abc"; + +const proxy = (target: string) => + app.request(`/expo-authorization-proxy?authorizationURL=${encodeURIComponent(target)}`); + +beforeEach(() => { + handler.mockClear(); + fetchMock.mockClear(); + clearAuthorizationEndpointCache(); + process.env.OIDC_CLIENT_ID = "sofa"; + process.env.OIDC_CLIENT_SECRET = "test-secret"; + process.env.OIDC_ISSUER_URL = "https://idp.example.com/application/o/sofa"; + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; + delete process.env.OIDC_ISSUER_URL; + vi.unstubAllGlobals(); +}); + +describe("expo-authorization-proxy guard", () => { + test("rejects a foreign host", async () => { + const res = await proxy("https://evil.example.net/application/o/authorize/?state=abc"); + expect(res.status).toBe(400); + expect(handler).not.toHaveBeenCalled(); + }); + + test("allows the exact authorization endpoint plus query", async () => { + const res = await proxy(ALLOWED); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + expect(handler).toHaveBeenCalledTimes(1); + }); + + test("rejects the same host on a different path", async () => { + const res = await proxy( + "https://idp.example.com/application/o/sofa/end-session/?next=https://evil.example.net", + ); + expect(res.status).toBe(400); + }); + + test("fetches the discovery document once", async () => { + await proxy(ALLOWED); + await proxy(ALLOWED); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe( + "https://idp.example.com/application/o/sofa/.well-known/openid-configuration", + ); + }); + + test("does not cache a discovery failure", async () => { + fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 })); + expect((await proxy(ALLOWED)).status).toBe(400); + expect((await proxy(ALLOWED)).status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("rejects everything when OIDC is not configured", async () => { + delete process.env.OIDC_CLIENT_SECRET; + const res = await proxy(ALLOWED); + expect(res.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("auth handler forwarding", () => { + test("never trusts a client-sent IP header on its own", async () => { + await app.request("/get-session", { + headers: { "x-forwarded-for": "6.6.6.6", "x-sofa-client-ip": "6.6.6.6" }, + }); + expect(handler.mock.calls[0][0].headers.get("x-sofa-client-ip")).toBeNull(); + }); + + test("forwards the legacy callback path to the core endpoint", async () => { + await app.request("/oauth2/callback/oidc?code=x&state=y"); + expect(new URL(handler.mock.calls[0][0].url).pathname).toBe("/callback/oidc"); + }); +}); diff --git a/apps/server/test/client-ip.test.ts b/apps/server/test/client-ip.test.ts new file mode 100644 index 0000000..c16c8d1 --- /dev/null +++ b/apps/server/test/client-ip.test.ts @@ -0,0 +1,109 @@ +import { getIP } from "@better-auth/core/utils/ip"; +import { describe, expect, test } from "vitest"; + +import { CLIENT_IP_HEADER } from "@sofa/auth/config"; + +import { + buildTrustedProxyList, + DEFAULT_TRUSTED_PROXIES, + normalizeAddress, + parseTrustedProxies, + resolveClientIp, +} from "../src/client-ip"; + +const defaults = buildTrustedProxyList(DEFAULT_TRUSTED_PROXIES).list; +const none = buildTrustedProxyList([]).list; + +describe("resolveClientIp", () => { + test("direct internet client spoofing X-Forwarded-For is keyed on the peer", () => { + expect(resolveClientIp("6.6.6.6", "203.0.113.9", defaults)).toBe("203.0.113.9"); + }); + + test("direct internet client with no header", () => { + expect(resolveClientIp(null, "203.0.113.9", defaults)).toBe("203.0.113.9"); + }); + + test("LAN client direct, no header (all-trusted fallback)", () => { + expect(resolveClientIp(null, "192.168.1.5", defaults)).toBe("192.168.1.5"); + }); + + test("dual-stack mapped peer is normalized", () => { + expect(resolveClientIp(null, "::ffff:192.168.1.5", defaults)).toBe("192.168.1.5"); + }); + + test("local proxy, internet client", () => { + expect(resolveClientIp("198.51.100.7", "172.18.0.5", defaults)).toBe("198.51.100.7"); + }); + + test("local proxy, LAN client", () => { + expect(resolveClientIp("192.168.1.5", "127.0.0.1", defaults)).toBe("192.168.1.5"); + }); + + test("spoof through a local proxy", () => { + expect(resolveClientIp("6.6.6.6, 198.51.100.7", "172.18.0.5", defaults)).toBe("198.51.100.7"); + }); + + test("malformed hop left of a LAN client", () => { + expect(resolveClientIp("garbage, 192.168.1.5", "127.0.0.1", defaults)).toBe("192.168.1.5"); + }); + + test("malformed hop right after the peer", () => { + expect(resolveClientIp("garbage", "127.0.0.1", defaults)).toBe("127.0.0.1"); + }); + + test("trust nothing", () => { + expect(resolveClientIp("6.6.6.6", "127.0.0.1", none)).toBe("127.0.0.1"); + }); + + test("IPv6 client through a local proxy", () => { + expect(resolveClientIp("2001:db8::1", "::1", defaults)).toBe("2001:db8::1"); + }); + + test("unknown peer", () => { + expect(resolveClientIp("6.6.6.6", undefined, defaults)).toBeNull(); + }); +}); + +describe("TRUSTED_PROXIES parsing", () => { + test("parseTrustedProxies", () => { + expect(parseTrustedProxies(undefined)).toBe(DEFAULT_TRUSTED_PROXIES); + expect(parseTrustedProxies(" ")).toBe(DEFAULT_TRUSTED_PROXIES); + expect(parseTrustedProxies("None")).toEqual([]); + expect(parseTrustedProxies(" 1.2.3.4 , 10.0.0.0/24 ")).toEqual(["1.2.3.4", "10.0.0.0/24"]); + }); + + test("buildTrustedProxyList reports invalid entries", () => { + expect( + buildTrustedProxyList([ + "1.2.3.4", + "10.0.0.0/24", + "nope", + "10.0.0.0/33", + "::1/129", + "1.2.3.4/8/1", + "10.0.0.0/x", + ]).invalid, + ).toEqual(["nope", "10.0.0.0/33", "::1/129", "1.2.3.4/8/1", "10.0.0.0/x"]); + expect(buildTrustedProxyList(DEFAULT_TRUSTED_PROXIES).invalid).toEqual([]); + }); + + test("normalizeAddress leaves plain IPv6 unchanged", () => { + expect(normalizeAddress("2001:db8::1")).toBe("2001:db8::1"); + }); +}); + +describe("Better Auth reads the resolved header", () => { + const options = { advanced: { ipAddress: { ipAddressHeaders: [CLIENT_IP_HEADER] } } }; + + test("uses the internal header and ignores a client-sent X-Forwarded-For", () => { + expect(getIP(new Headers({ [CLIENT_IP_HEADER]: "198.51.100.7" }), options)).toBe( + "198.51.100.7", + ); + expect( + getIP( + new Headers({ [CLIENT_IP_HEADER]: "192.168.1.5", "x-forwarded-for": "6.6.6.6" }), + options, + ), + ).toBe("192.168.1.5"); + }); +}); diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 423b996..568091e 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -137,13 +137,16 @@ These are only needed if you're proxying TMDB requests or using an alternative e ## Network -| Variable | Default | Description | -| ------------- | ----------------------- | --------------------- | -| `CORS_ORIGIN` | `http://localhost:3000` | Allowed CORS origin | -| `PORT` | `3000` | Server listening port | +| Variable | Default | Description | +| --- | --- | --- | +| `CORS_ORIGIN` | `http://localhost:3000` | Allowed CORS origin | +| `PORT` | `3000` | Server listening port | +| `TRUSTED_PROXIES` | loopback + private ranges | Comma-separated IPs/CIDRs of reverse proxies whose `X-Forwarded-For` hops are trusted for sign-in rate limiting; `none` to trust none | Set `CORS_ORIGIN` to your public URL when using a reverse proxy. +Sofa rate-limits sign-in attempts per client IP. The default works when Sofa is reached directly, or through a reverse proxy or Cloudflare Tunnel (`cloudflared`) running on the same host or Docker network. If your proxy runs elsewhere (for example on a VPS that reaches Sofa over the internet or Tailscale, or Cloudflare proxying straight to Sofa's port), add its addresses to `TRUSTED_PROXIES` (Cloudflare publishes its ranges at https://www.cloudflare.com/ips/). Otherwise every user shares the proxy's rate limit, and Sofa logs `Ignored X-Forwarded-For from …` once. Set `none` if untrusted devices on your LAN can reach Sofa directly and you don't use a proxy. + ## Update Checks Sofa checks for new versions every 6 hours by contacting the public API. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index b433737..7c348c7 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -70,6 +70,7 @@ If you're running Sofa behind a reverse proxy (Caddy, nginx, Traefik, etc.), mak 1. Set `BETTER_AUTH_URL` to your public URL (e.g. `https://sofa.example.com`) 2. Set `CORS_ORIGIN` to the same URL if your proxy uses a different origin 3. Forward the `Host` header and WebSocket connections +4. If your proxy doesn't run on the same host, Docker network, or private LAN as Sofa (for example a VPS reaching it over the internet or Tailscale), set `TRUSTED_PROXIES` to the proxy's address so sign-in rate limits apply per user (see [Configuration](/docs/configuration#network)). Example with Caddy: diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 392190f..f3786b7 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -40,3 +40,16 @@ export function getOidcRedirectURI(): string | undefined { const authBaseURL = new URL(trimmed).pathname === "/" ? `${trimmed}/api/auth` : trimmed; return `${authBaseURL}/oauth2/callback/oidc`; } + +/** + * Internal request header carrying the client IP that apps/server resolved from the TCP peer + * address and trusted proxy hops (see apps/server/src/client-ip.ts). Better Auth reads only this + * header for rate limiting; apps/server overwrites or removes any client-sent value. + */ +export const CLIENT_IP_HEADER = "x-sofa-client-ip"; + +/** The OIDC discovery document URL, or `undefined` when `OIDC_ISSUER_URL` isn't set. */ +export function getOidcDiscoveryURL(): string | undefined { + const issuer = process.env.OIDC_ISSUER_URL; + return issuer ? `${issuer}/.well-known/openid-configuration` : undefined; +} diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 048b67c..15eb7aa 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -11,6 +11,8 @@ import * as schema from "@sofa/db/schema"; import { createLogger } from "@sofa/logger"; import { + CLIENT_IP_HEADER, + getOidcDiscoveryURL, getOidcRedirectURI, isOidcAutoRegisterEnabled, isOidcConfigured, @@ -67,7 +69,7 @@ export const auth = betterAuth({ providerId: "oidc", clientId: process.env.OIDC_CLIENT_ID ?? "", clientSecret: process.env.OIDC_CLIENT_SECRET ?? "", - discoveryUrl: `${process.env.OIDC_ISSUER_URL}/.well-known/openid-configuration`, + discoveryUrl: getOidcDiscoveryURL() ?? "", redirectURI: getOidcRedirectURI(), scopes: ["openid", "email", "profile"], pkce: true, @@ -92,6 +94,12 @@ export const auth = betterAuth({ database: { generateId: () => Bun.randomUUIDv7(), }, + // apps/server resolves the client IP (TCP peer + TRUSTED_PROXIES hops) and passes it in this + // header; see apps/server/src/client-ip.ts. Don't set `trustedProxies` here: Better Auth would + // then discard resolved LAN addresses, because they fall inside the trusted ranges. + ipAddress: { + ipAddressHeaders: [CLIENT_IP_HEADER], + }, }, hooks: { before: createAuthMiddleware(async (ctx) => {