151 lines
7.6 KiB
TypeScript
151 lines
7.6 KiB
TypeScript
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<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";
|
|
}
|
|
}
|
|
|
|
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<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.
|
|
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<typeof requireAuth>[0]) => deleteCookie(c, sessionName, cookieOptions) };
|
|
}
|