fix: support Discord sign-in behind a trusted TLS proxy
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user