owltide/server/utils/session.ts

191 lines
5.9 KiB
TypeScript
Raw Normal View History

/*
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { H3Event } from "h3";
import {
nextSessionId,
readSessions,
readSubscriptions,
readUsers,
type ServerSession,
type ServerUser,
writeSessions,
writeSubscriptions
} from "~/server/database";
import { broadcastEvent } from "../streams";
import type { ApiAuthenticationProvider, ApiSession } from "~/shared/types/api";
async function removeSessionSubscription(sessionId: number) {
const subscriptions = await readSubscriptions();
const index = subscriptions.findIndex(subscription => subscription.sessionId === sessionId);
if (index !== -1) {
subscriptions.splice(index, 1);
await writeSubscriptions(subscriptions);
}
}
async function clearServerSessionInternal(event: H3Event, sessions: ServerSession[]) {
const existingSessionCookie = await getSignedCookie(event, "session");
if (existingSessionCookie) {
const sessionId = parseInt(existingSessionCookie, 10);
const session = sessions.find(session => session.id === sessionId);
if (session) {
session.expiresAtMs = Date.now();
broadcastEvent({
type: "session-expired",
sessionId,
});
await removeSessionSubscription(sessionId);
return true;
}
}
return false;
}
export async function clearServerSession(event: H3Event) {
const sessions = await readSessions();
if (await clearServerSessionInternal(event, sessions)) {
await writeSessions(sessions);
}
deleteCookie(event, "session");
}
export async function setServerSession(
event: H3Event,
account: ServerUser | undefined,
authenticationProvider?: ApiAuthenticationProvider,
authenticationSlug?: string,
authenticationName?: string,
) {
const sessions = await readSessions();
const runtimeConfig = useRuntimeConfig(event);
await clearServerSessionInternal(event, sessions);
const now = Date.now();
const newSession: ServerSession = {
access: account?.type ?? "anonymous",
accountId: account?.id,
authenticationProvider,
authenticationSlug,
authenticationName,
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
id: await nextSessionId(),
};
sessions.push(newSession);
await writeSessions(sessions);
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
return newSession;
}
async function rotateSession(event: H3Event, sessions: ServerSession[], session: ServerSession) {
const runtimeConfig = useRuntimeConfig(event);
const users = await readUsers();
const account = users.find(user => !user.deleted && user.id === session.accountId);
const now = Date.now();
const newSession: ServerSession = {
accountId: account?.id,
access: account?.type ?? "anonymous",
// Authentication provider is removed to avoid possibility of an infinite delay before using it.
rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000,
discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000,
id: await nextSessionId(),
};
session.successor = newSession.id;
session.expiresAtMs = Date.now() + 10 * 1000;
sessions.push(newSession);
await writeSessions(sessions);
await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout)
return newSession;
}
export async function getServerSession(event: H3Event, ignoreTaken: boolean) {
const sessionCookie = await getSignedCookie(event, "session");
if (sessionCookie) {
const sessionId = parseInt(sessionCookie, 10);
const sessions = await readSessions();
const session = sessions.find(session => session.id === sessionId);
if (session) {
const nowMs = Date.now();
if (nowMs >= session.discardAtMs) {
return undefined;
}
if (session.expiresAtMs !== undefined && nowMs >= session.expiresAtMs) {
if (!ignoreTaken && session.successor !== undefined) {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
message: "Session has been taken by another agent.",
data: { code: "SESSION_TAKEN" },
});
}
return undefined;
}
if (nowMs >= session.rotatesAtMs && session.successor === undefined) {
return await rotateSession(event, sessions, session);
}
}
return session;
}
}
export async function requireServerSession(event: H3Event, message: string) {
const session = await getServerSession(event, false);
if (!session)
throw createError({
statusCode: 401,
statusMessage: "Unauthorized",
message,
});
return session;
}
export async function requireServerSessionWithUser(event: H3Event) {
const message = "User session required";
const session = await requireServerSession(event, message);
const users = await readUsers();
const account = users.find(user => user.id === session.accountId);
if (session.accountId === undefined || !account || account.deleted)
throw createError({
statusCode: 401,
statusMessage: "Uauthorized",
message,
});
return { ...session, accountId: session.accountId };
}
export async function requireServerSessionWithAdmin(event: H3Event) {
const message = "Admin session required";
const session = await requireServerSession(event, message);
const users = await readUsers();
const account = users.find(user => user.id === session.accountId);
if (session.access !== "admin" || account?.type !== "admin") {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
message,
});
}
return { ...session, accountId: session.accountId };
}
export async function serverSessionToApi(event: H3Event, session: ServerSession): Promise<ApiSession> {
const users = await readUsers();
const account = users.find(user => !user.deleted && user.id === session.accountId);
const subscriptions = await readSubscriptions();
const push = Boolean(
subscriptions.find(sub => sub.type === "push" && sub.sessionId === session.id)
);
return {
id: session.id,
account,
authenticationProvider: session.authenticationProvider,
authenticationName: session.authenticationName,
push,
};
}