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

View File

@@ -1,4 +1,5 @@
import { Route, Routes, useLocation, useNavigate } from "react-router";
import { AuthControls } from "./components/AuthControls";
import { Home } from "./pages/Home";
import { About } from "./pages/About";
import { Settings } from "./pages/Settings";
@@ -14,6 +15,7 @@ export function App() {
<button type="button" disabled={pathname === "/about"} onClick={() => navigate("/about")}>About</button>{" "}
<button type="button" disabled={pathname === "/settings"} onClick={() => navigate("/settings")}>Settings</button>
</nav>
<AuthControls />
<main>
<Routes>
<Route path="/" element={<Home />} />

22
src/api.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Hono } from "hono";
import { sql } from "drizzle-orm";
import { createAuth, type AppDatabase, type AuthEnv } from "./auth";
import type { AuthConfig } from "./auth/config";
import type { FetchDiscord } from "./auth/discord";
export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number) {
const app = new Hono<AuthEnv>();
const auth = createAuth(db, config, request, now);
app.get("/api/health", c => {
db.get(sql`SELECT 1`);
return c.json({ status: "ok" });
});
app.route("/api/auth", auth.routes);
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
app.notFound(c => c.json({ error: "Not found" }, 404));
app.onError((_error, c) => {
c.header("Cache-Control", "no-store");
return c.json({ error: "Internal server error" }, 500);
});
return app;
}

206
src/auth/auth.test.ts Normal file
View File

@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { eq } from "drizzle-orm";
import { createApi } from "../api";
import * as schema from "../db/schema";
import { hashToken, normalizeTimezone } from "./index";
import { readAuthConfig, type AuthConfig } from "./config";
import type { DiscordProfile, FetchDiscord } from "./discord";
const origin = "http://127.0.0.1:3000";
const config: AuthConfig = { origin, clientId: "test-client", clientSecret: "test-secret", cookieSecret: "test-only-signing-secret-at-least-32-characters" };
let sqlite: Database;
let db: ReturnType<typeof drizzle<typeof schema>>;
let app: ReturnType<typeof createApi>;
let timestamp: number;
let calls: { url: string; init: RequestInit }[];
let profile: DiscordProfile;
let failure: "token" | "profile" | "malformed" | "throw" | undefined;
const request: FetchDiscord = async (url, init) => {
calls.push({ url, init });
if (failure === "throw") throw new Error("provider secret response");
if (url.endsWith("/token")) {
if (failure === "token") return new Response("provider secret response", { status: 400 });
return Response.json({ access_token: "secret-access", refresh_token: "secret-refresh", token_type: "Bearer", scope: "identify" });
}
if (failure === "profile") return new Response("provider secret response", { status: 503 });
return Response.json(failure === "malformed" ? { id: 123, username: "bad" } : profile);
};
const cookie = (response: Response, name: string) => response.headers.getSetCookie().find(value => value.startsWith(`${name}=`))?.split(";")[0] ?? "";
async function begin(timezone = "Europe/Belgrade", target = app, base = origin) {
const response = await target.request(`${base}/api/auth/discord?${new URLSearchParams({ timezone })}`);
const location = new URL(response.headers.get("Location")!);
return { response, state: location.searchParams.get("state")!, cookie: cookie(response, base.startsWith("https:") ? "__Host-minabot_oauth" : "minabot_oauth") };
}
async function login(timezone = "Europe/Belgrade", previous = "") {
const flow = await begin(timezone);
const response = await app.request(`${origin}/api/auth/discord/callback?${new URLSearchParams({ state: flow.state, code: "test-code" })}`, {
headers: { Cookie: [flow.cookie, previous].filter(Boolean).join("; ") },
});
return { response, cookie: cookie(response, "minabot_session") };
}
beforeEach(() => {
sqlite = new Database(":memory:");
sqlite.exec("PRAGMA foreign_keys = ON");
db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder: "./drizzle" });
timestamp = Date.now(); calls = []; failure = undefined;
profile = { id: "123456789012345678", username: "mina", global_name: "Mina", avatar: null };
app = createApi(db, config, request, () => timestamp);
});
afterEach(() => sqlite.close());
describe("Discord sign-in and sessions", () => {
test("me requires a valid session and health stays public", async () => {
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });
expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: "Unauthorized" });
expect(response.headers.get("Cache-Control")).toBe("no-store");
}
expect((await app.request(`${origin}/api/health`)).status).toBe(200);
expect((await app.request(`${origin}/api/missing`)).status).toBe(404);
});
test("authorization uses identify, random state, and a signed expiring HttpOnly cookie", async () => {
const first = await begin(); const second = await begin();
const url = new URL(first.response.headers.get("Location")!);
expect(url.origin).toBe("https://discord.com");
expect(url.searchParams.get("scope")).toBe("identify");
expect(url.searchParams.get("redirect_uri")).toBe(`${origin}/api/auth/discord/callback`);
expect(first.state).not.toBe(second.state);
const header = first.response.headers.get("Set-Cookie")!;
expect(header).toContain("HttpOnly"); expect(header).toContain("SameSite=Lax"); expect(header).toContain("Max-Age=600");
});
test("callback creates a user and hashed session, and me returns only public fields", async () => {
const result = await login();
expect(result.response.headers.get("Location")).toBe("/");
const user = db.select().from(schema.users).get()!;
const session = db.select().from(schema.sessions).get()!;
expect(user.timezone).toBe("Europe/Belgrade");
expect(session.tokenHash).toBe(hashToken(result.cookie.split("=")[1]!));
expect(session.expiresAt - session.createdAt).toBe(30 * 24 * 60 * 60 * 1000);
expect(result.response.headers.getSetCookie().join(";")).toContain("Max-Age=2592000");
expect(result.response.headers.getSetCookie().join(";")).toContain("Max-Age=0");
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: result.cookie } });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ id: user.id, discordId: profile.id, username: "mina", displayName: "Mina", avatarUrl: null, timezone: "Europe/Belgrade" });
const body = calls[0]!.init.body as URLSearchParams;
expect(body.get("client_secret")).toBe(config.clientSecret);
expect(body.get("code")).toBe("test-code");
expect(calls[1]!.init.headers).toEqual({ Authorization: "Bearer secret-access" });
expect(JSON.stringify({ user, session })).not.toContain("secret-access");
});
test("repeat login updates profile but preserves ID, creation time and timezone; rotates session", async () => {
const first = await login();
const oldUser = db.select().from(schema.users).get()!;
timestamp += 1000;
profile = { ...profile, username: "new-name", global_name: null, avatar: "a_abcdef" };
const second = await login("America/New_York", first.cookie);
expect(db.select().from(schema.users).all()).toHaveLength(1);
const user = db.select().from(schema.users).get()!;
expect(user.id).toBe(oldUser.id); expect(user.createdAt).toBe(oldUser.createdAt);
expect(user.updatedAt).toBe(timestamp); expect(user.timezone).toBe("Europe/Belgrade");
expect(db.select().from(schema.sessions).all()).toHaveLength(1);
expect((await app.request(`${origin}/api/me`, { headers: { Cookie: first.cookie } })).status).toBe(401);
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: second.cookie } });
const data = await response.json();
expect(data.displayName).toBe("new-name");
expect(data.avatarUrl).toBe(`https://cdn.discordapp.com/avatars/${profile.id}/a_abcdef.gif`);
});
test("invalid or missing timezone falls back to UTC", async () => {
expect(normalizeTimezone(undefined)).toBe("UTC");
expect(normalizeTimezone("+02:00")).toBe("UTC");
expect(normalizeTimezone("Europe/Belgrade")).toBe("Europe/Belgrade");
await login("invalid/timezone");
expect(db.select().from(schema.users).get()!.timezone).toBe("UTC");
});
test("rejects missing, mismatched, tampered and expired OAuth state before contacting Discord", async () => {
const flow = await begin();
const cases = [
{ state: flow.state, cookie: "" },
{ state: "b".repeat(43), cookie: flow.cookie },
{ state: flow.state, cookie: `${flow.cookie}tampered` },
{ state: "", cookie: flow.cookie },
];
for (const value of cases) {
const response = await app.request(`${origin}/api/auth/discord/callback?${new URLSearchParams({ state: value.state, code: "test-code" })}`, { headers: { Cookie: value.cookie } });
expect(response.headers.get("Location")).toBe("/?auth_error=invalid_state");
}
timestamp += 600_000;
const response = await app.request(`${origin}/api/auth/discord/callback?state=${flow.state}&code=test-code`, { headers: { Cookie: flow.cookie } });
expect(response.headers.get("Location")).toBe("/?auth_error=invalid_state");
expect(calls).toHaveLength(0); expect(db.select().from(schema.users).all()).toHaveLength(0);
});
test("handles consent denial and missing code without creating a session", async () => {
for (const [query, error] of [["error=access_denied", "denied"], ["", "invalid_code"]]) {
const flow = await begin();
const response = await app.request(`${origin}/api/auth/discord/callback?state=${flow.state}&${query}`, { headers: { Cookie: flow.cookie } });
expect(response.headers.get("Location")).toBe(`/?auth_error=${error}`);
}
expect(calls).toHaveLength(0); expect(db.select().from(schema.sessions).all()).toHaveLength(0);
});
test.each(["token", "profile", "malformed", "throw"] as const)("provider failure (%s) leaves no account or session", async value => {
failure = value;
const result = await login();
expect(result.response.headers.get("Location")).toBe("/?auth_error=discord_unavailable");
expect(result.cookie).toBe("");
expect(db.select().from(schema.users).all()).toHaveLength(0);
expect(db.select().from(schema.sessions).all()).toHaveLength(0);
});
test("expired sessions are rejected at their exact expiry", async () => {
const result = await login();
timestamp = db.select().from(schema.sessions).get()!.expiresAt;
expect((await app.request(`${origin}/api/me`, { headers: { Cookie: result.cookie } })).status).toBe(401);
expect(db.select().from(schema.sessions).all()).toHaveLength(0);
});
test("logout rejects cross-origin or missing origins and revokes only the current session", async () => {
const first = await login(); const second = await login();
for (const headers of [new Headers({ Cookie: first.cookie }), new Headers({ Cookie: first.cookie, Origin: "https://evil.example" })]) {
expect((await app.request(`${origin}/api/auth/logout`, { method: "POST", headers })).status).toBe(403);
}
expect(db.select().from(schema.sessions).all()).toHaveLength(2);
const response = await app.request(`${origin}/api/auth/logout`, { method: "POST", headers: { Cookie: first.cookie, Origin: origin } });
expect(response.status).toBe(204); expect(response.headers.get("Set-Cookie")).toContain("Max-Age=0");
expect((await app.request(`${origin}/api/me`, { headers: { Cookie: first.cookie } })).status).toBe(401);
expect((await app.request(`${origin}/api/me`, { headers: { Cookie: second.cookie } })).status).toBe(200);
expect((await app.request(`${origin}/api/auth/logout`)).status).toBe(404);
});
test("foreign keys cascade session deletion and duplicate Discord IDs are rejected", async () => {
await login();
const user = db.select().from(schema.users).get()!;
expect(() => db.insert(schema.users).values({ ...user, id: "different" }).run()).toThrow();
db.delete(schema.users).where(eq(schema.users.id, user.id)).run();
expect(db.select().from(schema.sessions).all()).toHaveLength(0);
});
test("HTTPS cookies are Secure and use host-only names", async () => {
const secureConfig = { ...config, origin: "https://habits.example" };
const secureApp = createApi(db, secureConfig, request, () => timestamp);
const flow = await begin("UTC", secureApp, secureConfig.origin);
const response = await secureApp.request(`${secureConfig.origin}/api/auth/discord/callback?state=${flow.state}&code=test-code`, { headers: { Cookie: flow.cookie } });
expect(response.headers.getSetCookie().find(value => value.startsWith("__Host-minabot_session="))).toContain("Secure");
expect(flow.response.headers.get("Set-Cookie")).toContain("Secure");
});
test("missing credentials fail gracefully and unsafe configured origins fail fast", async () => {
const unconfigured = createApi(db, { ...config, clientSecret: "" }, request);
const response = await unconfigured.request(`${origin}/api/auth/discord`);
expect(response.headers.get("Location")).toBe("/?auth_error=not_configured");
expect(calls).toHaveLength(0);
expect(() => readAuthConfig({ APP_ORIGIN: "http://habits.example" })).toThrow();
expect(() => readAuthConfig({ APP_ORIGIN: "https://habits.example/path" })).toThrow();
});
});

20
src/auth/config.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { expect, test } from "bun:test";
import { readAuthConfig } from "./config";
test("reads the active environment's APP_ORIGIN and normalizes it", () => {
expect(readAuthConfig({ APP_ORIGIN: " http://127.0.0.1:4000/ " }).origin).toBe("http://127.0.0.1:4000");
expect(readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: "https://habits.example.com" }).origin).toBe("https://habits.example.com");
});
test("only development defaults to the local port", () => {
expect(readAuthConfig({}).origin).toBe("http://127.0.0.1:3000");
expect(readAuthConfig({ PORT: "4000" }).origin).toBe("http://127.0.0.1:4000");
expect(() => readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: " " })).toThrow("Set APP_ORIGIN");
});
test("rejects invalid origins and allows explicitly configured local production testing", () => {
for (const value of ["not a URL", "http://habits.example.com", "https://habits.example.com/path"]) {
expect(() => readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: value })).toThrow("APP_ORIGIN");
}
expect(readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: "http://127.0.0.1:3000" }).origin).toBe("http://127.0.0.1:3000");
});

36
src/auth/config.ts Normal file
View File

@@ -0,0 +1,36 @@
export type AuthConfig = {
origin: string;
clientId: string;
clientSecret: string;
cookieSecret: string;
};
export function readAuthConfig(env = process.env): AuthConfig {
const production = env.NODE_ENV === "production";
const configuredOrigin = env.APP_ORIGIN?.trim();
if (production && !configuredOrigin) {
throw new Error("Set APP_ORIGIN in .env.production or the process environment before starting in production.");
}
const origin = configuredOrigin || `http://127.0.0.1:${env.PORT ?? 3000}`;
let url: URL;
try {
url = new URL(origin);
} catch {
throw new Error("APP_ORIGIN must be a valid origin.");
}
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash ||
(url.protocol !== "https:" && !(local && url.protocol === "http:"))) {
throw new Error("APP_ORIGIN must be an HTTPS origin (HTTP is allowed for localhost).");
}
return {
origin: url.origin,
clientId: env.DISCORD_CLIENT_ID ?? "",
clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
};
}
export function isAuthConfigured(config: AuthConfig) {
return Boolean(config.clientId && config.clientSecret && config.cookieSecret.length >= 32);
}

45
src/auth/discord.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { AuthConfig } from "./config";
export type DiscordProfile = {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
};
export type FetchDiscord = (url: string, init: RequestInit) => Promise<Response>;
export async function fetchDiscordProfile(code: string, config: AuthConfig, request: FetchDiscord): Promise<DiscordProfile> {
const tokenResponse = await request("https://discord.com/api/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: `${config.origin}/api/auth/discord/callback`,
code,
}),
signal: AbortSignal.timeout(10_000),
});
if (!tokenResponse.ok) throw new Error("Discord token exchange failed");
const token = await tokenResponse.json();
if (typeof token.access_token !== "string" || !token.access_token ||
typeof token.token_type !== "string" || token.token_type.toLowerCase() !== "bearer" ||
typeof token.scope !== "string" || !token.scope.split(" ").includes("identify")) {
throw new Error("Invalid Discord token response");
}
const profileResponse = await request("https://discord.com/api/v10/users/@me", {
headers: { Authorization: `Bearer ${token.access_token}` },
signal: AbortSignal.timeout(10_000),
});
if (!profileResponse.ok) throw new Error("Discord profile request failed");
const profile = await profileResponse.json();
if (!profile || typeof profile.id !== "string" || !/^\d{1,20}$/.test(profile.id) ||
typeof profile.username !== "string" || !profile.username ||
!(profile.global_name === null || typeof profile.global_name === "string") ||
!(profile.avatar === null || (typeof profile.avatar === "string" && /^(a_)?[a-f0-9]+$/.test(profile.avatar)))) {
throw new Error("Invalid Discord profile");
}
return profile;
}

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 };
}

View File

@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { useLocation } from "react-router";
import type { PublicUser } from "../shared/user";
const authErrors: Record<string, string> = {
not_configured: "Discord sign-in is not configured yet.",
invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.",
denied: "Discord sign-in was cancelled. You can try again when ready.",
invalid_code: "Discord did not return a sign-in code. Please try again.",
discord_unavailable: "Could not complete Discord sign-in. Please try again.",
};
export function AuthControls() {
const [user, setUser] = useState<PublicUser | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { search } = useLocation();
const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""];
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch("/api/me", { signal: controller.signal });
if (response.status === 401) return;
if (!response.ok) throw new Error("Could not load your account. Please reload to try again.");
setUser(await response.json());
} catch (error) {
if (!controller.signal.aborted) setError(error instanceof Error ? error.message : "Could not load your account.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
void loadUser();
return () => controller.abort();
}, []);
function signIn() {
let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
}
async function signOut() {
setBusy(true);
setError("");
try {
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null);
} catch (error) {
setError(error instanceof Error ? error.message : "Could not sign out.");
} finally { setBusy(false); }
}
return (
<section aria-label="Account">
{loading ? <p role="status">Loading account</p> : user ? (
<p>
Signed in as <strong>{user.displayName}</strong> · {user.timezone}{" "}
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
<a href="/api/me">View my profile</a>
</p>
) : <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
{(error || signInError) && <p role="alert">{error || signInError}</p>}
</section>
);
}

View File

@@ -1,2 +1,22 @@
// Define application tables here with drizzle-orm/sqlite-core.
export {};
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
discordId: text("discord_id").notNull().unique(),
username: text("username").notNull(),
globalName: text("global_name"),
avatarHash: text("avatar_hash"),
timezone: text("timezone").notNull().default("UTC"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const sessions = sqliteTable("sessions", {
tokenHash: text("token_hash").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: integer("created_at").notNull(),
expiresAt: integer("expires_at").notNull(),
}, table => [
index("sessions_user_id_idx").on(table.userId),
index("sessions_expires_at_idx").on(table.expiresAt),
]);

View File

@@ -1,15 +1,9 @@
import { Hono } from "hono";
import { sql } from "drizzle-orm";
import { db } from "./db";
import { createApi } from "./api";
import { readAuthConfig } from "./auth/config";
import index from "./index.html";
const app = new Hono();
app.get("/api/health", c => {
db.get(sql`SELECT 1`);
return c.json({ status: "ok" });
});
app.notFound(c => c.json({ error: "Not found" }, 404));
const app = createApi(db, readAuthConfig());
const server = Bun.serve({
hostname: "127.0.0.1",

8
src/shared/user.ts Normal file
View File

@@ -0,0 +1,8 @@
export type PublicUser = {
id: string;
discordId: string;
username: string;
displayName: string;
avatarUrl: string | null;
timezone: string;
};