import type { H3Event } from "h3"; import type { Session, SessionSearchTerms, SessionProvider, SessionWithToken, } from "./types"; import { randomUUID } from "node:crypto"; import { parse as parseCookies } from "cookie-es"; import type { MinimumRequestObject } from "~/server/h3"; import type { DurationLike } from "luxon"; import { DateTime } from "luxon"; import prisma from "../db/database"; // import createMemorySessionHandler from "./memory"; import createDBSessionHandler from "./db"; // import createCacheSessionProvider from "./cache"; /* This implementation may need work. It exposes an API that should stay static, but there are plenty of opportunities for optimisation/organisation under the hood */ // 10 minutes const SUPERLEVEL_LENGTH = 10 * 60 * 1000; const dropTokenCookieName = "drop-token"; const normalSessionLength: DurationLike = { days: 31, }; const extendedSessionLength: DurationLike = { year: 1, }; type SigninResult = ["signin", "2fa", "fail"][number]; export interface SigninOptions { // default value: false rememberMe?: boolean; // set default session data data?: Session["data"]; // set oidc session data oidc?: Session["oidc"]; } export class SessionHandler { private sessionProvider: SessionProvider; constructor() { // Create a new provider // this.sessionProvider = createCacheSessionProvider(); this.sessionProvider = createDBSessionHandler(); // this.sessionProvider = createMemorySessionHandler(); } async signin( h3: H3Event, userId: string, options?: SigninOptions, ): Promise { const mfaCount = await prisma.linkedMFAMec.count({ where: { userId, enabled: true }, }); const rememberMe = options?.rememberMe ?? false; const data = options?.data ?? {}; const oidcData = options?.oidc; const expiresAt = this.createExipreAt(rememberMe); const token = this.getSessionToken(h3) ?? this.createSessionCookie(h3, expiresAt); const defaultSession: Session = { expiresAt, data, }; const session = (await this.sessionProvider.getSession(token)) ?? defaultSession; const wasAuthenticated = !!session.authenticated; // set authenticated session data session.authenticated = { userId, level: session.authenticated?.level ?? 10, requiredLevel: mfaCount > 0 ? 20 : 10, superleveledExpiry: undefined, }; if (oidcData) session.oidc = oidcData; // handle superlevel expiry if ( wasAuthenticated && session.authenticated.level >= session.authenticated.requiredLevel ) { session.authenticated.superleveledExpiry = Date.now() + SUPERLEVEL_LENGTH; } const success = await this.sessionProvider.setSession(token, session); if (!success) return "fail"; if (session.authenticated.level < session.authenticated.requiredLevel) return "2fa"; return "signin"; } async mfa(h3: H3Event, amount: number) { const token = this.getSessionToken(h3); if (!token) throw createError({ statusCode: 403, message: "User not signed in" }); const session = await this.sessionProvider.getSession(token); if (!session || !session.authenticated) throw createError({ statusCode: 403, message: "User not signed in" }); session.authenticated.level += amount; await this.sessionProvider.setSession(token, session); } /** * Get a session associated with a request * @returns session */ async getSession(request: MinimumRequestObject) { const token = this.getSessionToken(request); if (!token) return undefined; const session = await this.sessionProvider.getSession(token); if (!session) return undefined; // if expired session if (new Date(session.expiresAt).getTime() < Date.now()) { await this.sessionProvider.removeSession(token); // TODO: should probably call signout to clear the cookie // session expired return undefined; } return session; } async getSessionDataKey( request: MinimumRequestObject, key: string, ): Promise { const token = this.getSessionToken(request); if (!token) return undefined; const session = await this.sessionProvider.getSession(token); if (!session) return undefined; return session.data[key] as T; } async setSessionDataKey(request: H3Event, key: string, value: T) { const expiresAt = this.createExipreAt(true); const token = this.getSessionToken(request) ?? this.createSessionCookie(request, expiresAt); const defaultSession: Session = { expiresAt, data: {}, }; const session = (await this.sessionProvider.getSession(token)) ?? defaultSession; session.data[key] = value; await this.sessionProvider.setSession(token, session); return true; } async deleteSessionDataKey(request: MinimumRequestObject, key: string) { const token = this.getSessionToken(request); if (!token) return false; const session = await this.sessionProvider.getSession(token); if (!session) return false; // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete session.data[key]; await this.sessionProvider.setSession(token, session); return true; } /** * Signout session associated with request and deauthenticates it * @param request * @returns */ async signout(h3: H3Event) { const token = this.getSessionToken(h3); if (!token) return false; if (!this.signoutByToken(token)) return false; deleteCookie(h3, dropTokenCookieName); return true; } /** * Signout session by token * @Note Should only be used in special cases (eg OIDC logout) * @param token * @returns */ async signoutByToken(token: string) { const res = await this.sessionProvider.removeSession(token); return res; } /** * Clean up expired sessions */ async cleanupSessions() { await this.sessionProvider.cleanupSessions(); } /** * Search sessions * @param terms search terms * @returns found sessions */ async searchSessions(terms: SessionSearchTerms) { return await this.sessionProvider.findSessions(terms); } async getNumberActiveSessions() { return this.sessionProvider.getNumberActiveSessions(); } /** * Update session info * @param token session token * @param data new session data * @returns success or not */ private async updateSession(token: string, data: Session) { return await this.sessionProvider.updateSession(token, data); } // ---------------------- Private API Below ------------------------ /** * Get session token on a request * @param request * @returns session token */ private getSessionToken( request: MinimumRequestObject | undefined, ): string | undefined { if (!request) throw new Error("Native web request not available"); const cookieHeader = request.headers.get("Cookie"); if (!cookieHeader) return undefined; const cookies = parseCookies(cookieHeader); const cookie = cookies[dropTokenCookieName]; return cookie; } private createExipreAt(rememberMe: boolean) { return DateTime.now() .plus(rememberMe ? extendedSessionLength : normalSessionLength) .toJSDate(); } /** * Creates cookie that represents user session * @param h3 * @param extend * @returns */ private createSessionCookie(h3: H3Event, expiresAt: Date) { const token = randomUUID(); // TODO: we should probably switch to jwts to minimize possibility of someone // trying to guess a session id (jwts let us sign + encrypt stuff in a std way) setCookie(h3, dropTokenCookieName, token, { expires: expiresAt }); return token; } } export const sessionHandler = new SessionHandler(); export default sessionHandler;