feat(auth): add Discord OAuth sign-in and user timezones

Add persistent user and session storage, protected profile requests, and React sign-in controls. Capture and preserve each user's timezone, and load development and production configuration before migrations and server startup.
This commit is contained in:
syntaxbullet
2026-09-04 08:29:48 +02:00
parent 29bf75760b
commit d3cbf2f7dc
22 changed files with 997 additions and 24 deletions

146
src/auth/index.ts Normal file
View File

@@ -0,0 +1,146 @@
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 } };
export type AppDatabase = BunSQLiteDatabase<typeof schema>;
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";
}
}
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<AuthEnv>();
// Apply to all future cookie-authenticated mutations, including JSON requests.
const requireSameOrigin = createMiddleware<AuthEnv>(async (c, next) => {
if (c.req.header("Origin") !== config.origin) return c.json({ error: "Forbidden origin" }, 403);
await next();
});
const requireAuth = createMiddleware<AuthEnv>(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.
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 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 };
}