mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 18:35:43 -04:00
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 <noreply@anthropic.com>
This commit is contained in:
10 files changed
+442
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getOidcDiscoveryURL, isOidcConfigured } from "@sofa/auth/config";
|
||||
|
||||
let cachedEndpoint: Promise<URL | null> | undefined;
|
||||
|
||||
async function fetchAuthorizationEndpoint(discoveryURL: string): Promise<URL | null> {
|
||||
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<URL | null> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Response>>(async () => new Response("ok")),
|
||||
},
|
||||
}));
|
||||
|
||||
const handler = vi.mocked(auth.handler);
|
||||
|
||||
const fetchMock = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise<Response>>(
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user