Files
mrp-qrcode/lib/session.ts
T
2026-04-20 15:49:01 -05:00

114 lines
3.0 KiB
TypeScript

import { randomBytes, createHash } from "node:crypto";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import { env } from "@/lib/env";
export const SESSION_COOKIE = "mrp_session";
export type Role = "admin" | "operator";
export interface SessionUser {
id: string;
role: Role;
name: string;
email: string | null;
}
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function newToken(): { token: string; hash: string } {
const token = randomBytes(32).toString("base64url");
return { token, hash: sha256(token) };
}
function ttlForRole(role: Role): number {
const hours = role === "admin" ? env.ADMIN_SESSION_HOURS : env.OPERATOR_SESSION_HOURS;
return hours * 60 * 60 * 1000;
}
export interface CreateSessionInput {
userId: string;
role: Role;
userAgent?: string | null;
ipAddress?: string | null;
deviceLabel?: string | null;
}
export async function createSession(input: CreateSessionInput): Promise<{ token: string; expiresAt: Date }> {
const { token, hash } = newToken();
const expiresAt = new Date(Date.now() + ttlForRole(input.role));
await prisma.session.create({
data: {
userId: input.userId,
tokenHash: hash,
expiresAt,
userAgent: input.userAgent ?? null,
ipAddress: input.ipAddress ?? null,
deviceLabel: input.deviceLabel ?? null,
},
});
const jar = await cookies();
jar.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
path: "/",
expires: expiresAt,
});
return { token, expiresAt };
}
export async function getSessionUser(): Promise<SessionUser | null> {
const jar = await cookies();
const token = jar.get(SESSION_COOKIE)?.value;
if (!token) return null;
const hash = sha256(token);
const session = await prisma.session.findUnique({
where: { tokenHash: hash },
include: { user: true },
});
if (!session) return null;
if (session.expiresAt.getTime() < Date.now()) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => {});
return null;
}
if (!session.user.active) return null;
// touch lastSeenAt at most once per minute to limit write load
const stale = Date.now() - session.lastSeenAt.getTime() > 60_000;
if (stale) {
prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => {});
}
return {
id: session.user.id,
role: session.user.role as Role,
name: session.user.name,
email: session.user.email,
};
}
export async function destroyCurrentSession(): Promise<void> {
const jar = await cookies();
const token = jar.get(SESSION_COOKIE)?.value;
if (token) {
await prisma.session.deleteMany({ where: { tokenHash: sha256(token) } });
}
jar.delete(SESSION_COOKIE);
}
export async function purgeExpiredSessions(): Promise<number> {
const result = await prisma.session.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}