fix: support Discord sign-in behind a trusted TLS proxy

This commit is contained in:
syntaxbullet
2026-09-07 11:50:22 +02:00
parent c7aa5a1c65
commit 50d7838de3
4 changed files with 36 additions and 1 deletions

View File

@@ -1,5 +1,8 @@
# Loaded by bun start and bun run db:migrate:production. # Loaded by bun start and bun run db:migrate:production.
# Set your public origin, e.g. https://habits.example.com # Set your public origin, e.g. https://habits.example.com
APP_ORIGIN= APP_ORIGIN=
# Enable only behind a private proxy that overwrites X-Forwarded-Proto.
TRUST_PROXY=false
# Docker sets HOST=0.0.0.0; local startup defaults to 127.0.0.1.
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID, # Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET. # DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.

View File

@@ -55,6 +55,32 @@ beforeEach(() => {
afterEach(() => sqlite.close()); afterEach(() => sqlite.close());
describe("Discord sign-in and sessions", () => { describe("Discord sign-in and sessions", () => {
test("trusted TLS proxy supports sign-in and secure callback cookies", async () => {
const publicOrigin = "https://habits.example";
const proxyApp = createApi(db, { ...config, origin: publicOrigin, trustProxy: true }, request, () => timestamp);
const response = await proxyApp.request("http://habits.example/api/auth/discord", { headers: { "X-Forwarded-Proto": "https" } });
const location = new URL(response.headers.get("Location")!);
expect(location.origin).toBe("https://discord.com");
expect(location.searchParams.get("redirect_uri")).toBe(`${publicOrigin}/api/auth/discord/callback`);
expect(response.headers.get("Set-Cookie")).toContain("Secure");
const callback = await proxyApp.request(`http://habits.example/api/auth/discord/callback?state=${location.searchParams.get("state")}&code=test-code`, {
headers: { "X-Forwarded-Proto": "https", Cookie: cookie(response, "__Host-minabot_oauth") },
});
expect(cookie(callback, "__Host-minabot_session")).not.toBe("");
expect(callback.headers.get("Set-Cookie")).toContain("Secure");
});
test("forwarded protocol is opt-in and cannot override the actual hostname", async () => {
for (const [trustProxy, host] of [[false, "habits.example"], [true, "wrong.example"]] as const) {
const proxyApp = createApi(db, { ...config, origin: "https://habits.example", trustProxy }, request, () => timestamp);
const response = await proxyApp.request(`http://${host}/api/auth/discord`, {
headers: { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "habits.example" },
});
expect(new URL(response.headers.get("Location")!).origin).toBe("https://habits.example");
expect(response.headers.get("Set-Cookie")).toBeNull();
}
});
test("me requires a valid session and health stays public", async () => { test("me requires a valid session and health stays public", async () => {
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) { for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } }); const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });

View File

@@ -3,6 +3,7 @@ export type AuthConfig = {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
cookieSecret: string; cookieSecret: string;
trustProxy?: boolean;
}; };
export function readAuthConfig(env = process.env): AuthConfig { export function readAuthConfig(env = process.env): AuthConfig {
@@ -28,6 +29,7 @@ export function readAuthConfig(env = process.env): AuthConfig {
clientId: env.DISCORD_CLIENT_ID ?? "", clientId: env.DISCORD_CLIENT_ID ?? "",
clientSecret: env.DISCORD_CLIENT_SECRET ?? "", clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
cookieSecret: env.AUTH_COOKIE_SECRET ?? "", cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
trustProxy: env.TRUST_PROXY === "true",
}; };
} }

View File

@@ -75,7 +75,11 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
routes.get("/discord", async c => { routes.get("/discord", async c => {
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured"); if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
// Use one configured origin for redirects and cookie ownership. // Use one configured origin for redirects and cookie ownership.
if (new URL(c.req.url).origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`); const requestUrl = new URL(c.req.url);
// Enable only behind a private proxy that overwrites X-Forwarded-Proto.
// Keep the actual Host; never trust a client-supplied forwarded hostname.
if (config.trustProxy && c.req.header("X-Forwarded-Proto") === "https") requestUrl.protocol = "https:";
if (requestUrl.origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
const state = randomToken(); const state = randomToken();
await setSignedCookie(c, stateName, JSON.stringify({ await setSignedCookie(c, stateName, JSON.stringify({
state, state,