import { sha256 } from '@oslojs/crypto/sha2'; import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding'; import { type Session, type User, UserSecurityAuditLogType } from '@prisma/client'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; import { AUTH_SESSION_LIFETIME } from '../../config'; /** * The user object to pass around the app. * * Do not put anything sensitive in here since it will be public. */ export type SessionUser = Pick< User, | 'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' >; export type SessionValidationResult = | { session: Session; user: SessionUser; isAuthenticated: true; } | { session: null; user: null; isAuthenticated: false }; export const generateSessionToken = (): string => { const bytes = new Uint8Array(20); crypto.getRandomValues(bytes); const token = encodeBase32LowerCaseNoPadding(bytes); return token; }; export const createSession = async ( token: string, userId: number, metadata: RequestMetadata, ): Promise => { const hashedSessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); const session: Session = { id: hashedSessionId, sessionToken: hashedSessionId, userId, updatedAt: new Date(), createdAt: new Date(), expiresAt: new Date(Date.now() + AUTH_SESSION_LIFETIME), ipAddress: metadata.ipAddress ?? null, userAgent: metadata.userAgent ?? null, }; await prisma.session.create({ data: session, }); await prisma.userSecurityAuditLog.create({ data: { userId, ipAddress: metadata.ipAddress, userAgent: metadata.userAgent, type: UserSecurityAuditLogType.SIGN_IN, }, }); return session; }; export const validateSessionToken = async (token: string): Promise => { const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); const result = await prisma.session.findUnique({ where: { id: sessionId, }, include: { user: { /** * Do not expose anything sensitive here. */ select: { id: true, name: true, email: true, emailVerified: true, avatarImageId: true, twoFactorEnabled: true, roles: true, signature: true, }, }, }, }); if (!result?.user) { return { session: null, user: null, isAuthenticated: false }; } const { user, ...session } = result; if (Date.now() >= session.expiresAt.getTime()) { await prisma.session.delete({ where: { id: sessionId } }); return { session: null, user: null, isAuthenticated: false }; } if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); await prisma.session.update({ where: { id: session.id, }, data: { expiresAt: session.expiresAt, }, }); } return { session, user, isAuthenticated: true }; }; type InvalidateSessionsOptions = { userId: number; sessionIds: string[]; metadata: RequestMetadata; isRevoke?: boolean; }; export const invalidateSessions = async ({ userId, sessionIds, metadata, isRevoke, }: InvalidateSessionsOptions): Promise => { if (sessionIds.length === 0) { return; } await prisma.$transaction(async (tx) => { const { count } = await tx.session.deleteMany({ where: { userId, id: { in: sessionIds }, }, }); if (count !== sessionIds.length) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'One or more sessions are not valid.', }); } await tx.userSecurityAuditLog.createMany({ data: sessionIds.map(() => ({ userId, ipAddress: metadata.ipAddress, userAgent: metadata.userAgent, type: isRevoke ? UserSecurityAuditLogType.SESSION_REVOKED : UserSecurityAuditLogType.SIGN_OUT, })), }); }); };