import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { and, eq, gt, lte } from "drizzle-orm"; import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; import { Hono } from "hono"; import { createMiddleware } from "hono/factory"; import { deleteCookie, getCookie, getSignedCookie, setCookie, setSignedCookie } from "hono/cookie"; import * as schema from "../db/schema"; import type { PublicUser } from "../shared/user"; import { isAuthConfigured, type AuthConfig } from "./config"; import { fetchDiscordProfile, type FetchDiscord } from "./discord"; const { users, sessions } = schema; const SESSION_SECONDS = 30 * 24 * 60 * 60; const STATE_SECONDS = 10 * 60; export type AuthEnv = { Variables: { user: PublicUser; requestId: string } }; export type AppDatabase = BunSQLiteDatabase; export const hashToken = (token: string) => createHash("sha256").update(token).digest("hex"); const randomToken = () => randomBytes(32).toString("base64url"); export function normalizeTimezone(value: unknown): string { if (typeof value !== "string" || !value || value.length > 100 || /^[+-]/.test(value)) return "UTC"; try { return new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone; } catch { return "UTC"; } } export function publicUser(user: typeof users.$inferSelect): PublicUser { return { id: user.id, discordId: user.discordId, username: user.username, displayName: user.globalName ?? user.username, avatarUrl: user.avatarHash ? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.${user.avatarHash.startsWith("a_") ? "gif" : "png"}` : null, timezone: user.timezone, }; } export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDiscord = fetch, now = Date.now) { const secure = config.origin.startsWith("https:"); const sessionName = secure ? "__Host-minabot_session" : "minabot_session"; const stateName = secure ? "__Host-minabot_oauth" : "minabot_oauth"; const cookieOptions = { path: "/", httpOnly: true, sameSite: "Lax" as const, secure }; const readToken = (value: string | undefined) => value && /^[A-Za-z0-9_-]{43}$/.test(value) ? value : undefined; const routes = new Hono(); // Apply to all future cookie-authenticated mutations, including JSON requests. const requireSameOrigin = createMiddleware(async (c, next) => { if (c.req.header("Origin") !== config.origin) return c.json({ error: "Forbidden origin" }, 403); await next(); }); const requireAuth = createMiddleware(async (c, next) => { c.header("Cache-Control", "no-store"); const token = readToken(getCookie(c, sessionName)); const session = token ? db.select({ user: users }).from(sessions) .innerJoin(users, eq(users.id, sessions.userId)) .where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, now()))).get() : undefined; if (!session) { if (token) db.delete(sessions).where(and(eq(sessions.tokenHash, hashToken(token)), lte(sessions.expiresAt, now()))).run(); deleteCookie(c, sessionName, cookieOptions); return c.json({ error: "Unauthorized" }, 401); } c.set("user", publicUser(session.user)); await next(); }); routes.use("*", async (c, next) => { c.header("Cache-Control", "no-store"); c.header("Referrer-Policy", "no-referrer"); await next(); }); routes.get("/discord", async c => { if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured"); // Use one configured origin for redirects and cookie ownership. 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(); await setSignedCookie(c, stateName, JSON.stringify({ state, timezone: normalizeTimezone(c.req.query("timezone")), expiresAt: now() + STATE_SECONDS * 1000, }), config.cookieSecret, { ...cookieOptions, maxAge: STATE_SECONDS }); const url = new URL("https://discord.com/oauth2/authorize"); url.search = new URLSearchParams({ client_id: config.clientId, response_type: "code", scope: "identify", redirect_uri: `${config.origin}/api/auth/discord/callback`, state, }).toString(); return c.redirect(url.toString()); }); routes.get("/discord/callback", async c => { if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured"); const signedState = await getSignedCookie(c, config.cookieSecret, stateName); deleteCookie(c, stateName, cookieOptions); let state: { state?: unknown; timezone?: unknown; expiresAt?: unknown } | null = null; try { state = signedState ? JSON.parse(signedState) : null; } catch { /* Invalid cookie. */ } const returnedState = c.req.query("state"); if (!state || typeof state.state !== "string" || typeof returnedState !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(state.state) || !/^[A-Za-z0-9_-]{43}$/.test(returnedState) || !timingSafeEqual(Buffer.from(state.state), Buffer.from(returnedState)) || typeof state.expiresAt !== "number" || state.expiresAt <= now()) { return c.redirect("/?auth_error=invalid_state"); } if (c.req.query("error")) return c.redirect("/?auth_error=denied"); const code = c.req.query("code"); if (!code || code.length > 2048) return c.redirect("/?auth_error=invalid_code"); let profile; try { profile = await fetchDiscordProfile(code, config, request); } catch { // Never log authorization codes, provider responses, or credentials. return c.redirect("/?auth_error=discord_unavailable"); } const timestamp = now(); const token = randomToken(); const previousToken = readToken(getCookie(c, sessionName)); db.transaction(tx => { const user = tx.insert(users).values({ id: crypto.randomUUID(), discordId: profile.id, username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, timezone: normalizeTimezone(state.timezone), createdAt: timestamp, updatedAt: timestamp, }).onConflictDoUpdate({ target: users.discordId, set: { username: profile.username, globalName: profile.global_name, avatarHash: profile.avatar, updatedAt: timestamp, // A browser or travel timezone must not overwrite an existing preference. } }).returning().get(); if (previousToken) tx.delete(sessions).where(eq(sessions.tokenHash, hashToken(previousToken))).run(); tx.delete(sessions).where(lte(sessions.expiresAt, timestamp)).run(); tx.insert(sessions).values({ tokenHash: hashToken(token), userId: user.id, createdAt: timestamp, expiresAt: timestamp + SESSION_SECONDS * 1000 }).run(); }); setCookie(c, sessionName, token, { ...cookieOptions, maxAge: SESSION_SECONDS }); return c.redirect("/"); }); routes.post("/logout", requireSameOrigin, c => { const token = readToken(getCookie(c, sessionName)); if (token) db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token))).run(); deleteCookie(c, sessionName, cookieOptions); return c.body(null, 204); }); return { routes, requireAuth, requireSameOrigin, clearSession: (c: Parameters[0]) => deleteCookie(c, sessionName, cookieOptions) }; }