anydrop/server/src/http/session.ts
ordinarthur 2913618ee6
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 1m47s
feat: stealth accounts + data layer (Phase 1)
2026-04-20 09:57:22 +02:00

111 lines
2.9 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import { eq, and, gt } from "drizzle-orm";
import type { Context } from "hono";
import { getSignedCookie, setSignedCookie, deleteCookie } from "hono/cookie";
import { db } from "../db/client.js";
import { sessions, users, type User } from "../db/schema.js";
const SESSION_COOKIE = "anydrop_session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function getSessionSecret(): string {
const secret = process.env.SESSION_SECRET;
if (!secret || secret.length < 32) {
throw new Error("SESSION_SECRET env var must be set and at least 32 chars");
}
return secret;
}
function isSecureEnv(): boolean {
const url = process.env.APP_URL ?? "";
return url.startsWith("https://");
}
export function generateToken(): string {
return randomBytes(32).toString("base64url");
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function createSession(
userId: string,
userAgent: string | undefined,
ipHash: string | undefined,
): Promise<string> {
const token = generateToken();
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await db.insert(sessions).values({
userId,
tokenHash,
userAgent: userAgent ?? null,
ipHash: ipHash ?? null,
expiresAt,
});
return token;
}
export async function setSessionCookie(c: Context, token: string): Promise<void> {
await setSignedCookie(c, SESSION_COOKIE, token, getSessionSecret(), {
httpOnly: true,
secure: isSecureEnv(),
sameSite: "Lax",
path: "/",
maxAge: SESSION_TTL_MS / 1000,
});
}
export function clearSessionCookie(c: Context): void {
deleteCookie(c, SESSION_COOKIE, {
path: "/",
secure: isSecureEnv(),
});
}
export async function resolveSession(c: Context): Promise<User | null> {
let token: string | false | undefined;
try {
token = await getSignedCookie(c, getSessionSecret(), SESSION_COOKIE);
} catch {
return null;
}
if (!token) return null;
const tokenHash = hashToken(token);
const rows = await db
.select({
user: users,
sessionId: sessions.id,
})
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.tokenHash, tokenHash), gt(sessions.expiresAt, new Date())))
.limit(1);
if (rows.length === 0) return null;
await db
.update(sessions)
.set({ lastUsedAt: new Date() })
.where(eq(sessions.id, rows[0].sessionId));
return rows[0].user;
}
export async function revokeSession(c: Context): Promise<void> {
let token: string | false | undefined;
try {
token = await getSignedCookie(c, getSessionSecret(), SESSION_COOKIE);
} catch {
token = undefined;
}
if (token) {
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
}
clearSessionCookie(c);
}