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:
146
src/auth/index.ts
Normal file
146
src/auth/index.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user