874 lines
25 KiB
TypeScript
874 lines
25 KiB
TypeScript
import { v4 as uuidV4 } from "uuid";
|
|
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
import type { H3Event } from "vinxi/http";
|
|
import {
|
|
clearSession,
|
|
getSession,
|
|
getCookie,
|
|
setCookie,
|
|
updateSession
|
|
} from "vinxi/http";
|
|
import { ConnectionFactory } from "./database";
|
|
import { env } from "~/env/server";
|
|
import { AUTH_CONFIG, expiryToSeconds, CACHE_CONFIG } from "~/config";
|
|
import { logAuditEvent } from "./audit";
|
|
import type { SessionData } from "./session-config";
|
|
import { sessionConfig, getSessionConfig } from "./session-config";
|
|
import { getDeviceInfo } from "./device-utils";
|
|
import { cache } from "./cache";
|
|
|
|
/**
|
|
* In-memory throttle for session activity updates
|
|
* Tracks last update time per session to avoid excessive DB writes
|
|
* In serverless, this is per-instance, but that's fine - updates are best-effort
|
|
*/
|
|
const sessionUpdateTimestamps = new Map<string, number>();
|
|
|
|
/**
|
|
* Update session activity (last_used, last_active_at) with throttling
|
|
* Only updates DB if > SESSION_ACTIVITY_UPDATE_THRESHOLD_MS since last update
|
|
* Reduces 6,210 writes/period to ~60-100 writes (95%+ reduction)
|
|
*
|
|
* Security: Still secure - session validation happens every request (DB read)
|
|
* UX: Session activity timestamps within 5min accuracy is acceptable
|
|
*
|
|
* @param sessionId - Session ID to update
|
|
*/
|
|
async function updateSessionActivityThrottled(
|
|
sessionId: string
|
|
): Promise<void> {
|
|
const now = Date.now();
|
|
const lastUpdate = sessionUpdateTimestamps.get(sessionId) || 0;
|
|
const timeSinceLastUpdate = now - lastUpdate;
|
|
|
|
// Skip DB update if we updated recently
|
|
if (timeSinceLastUpdate < CACHE_CONFIG.SESSION_ACTIVITY_UPDATE_THRESHOLD_MS) {
|
|
return;
|
|
}
|
|
|
|
// Update timestamp tracker
|
|
sessionUpdateTimestamps.set(sessionId, now);
|
|
|
|
// Cleanup old entries (prevent memory leak in long-running instances)
|
|
if (sessionUpdateTimestamps.size > 1000) {
|
|
const oldestAllowed =
|
|
now - 2 * CACHE_CONFIG.SESSION_ACTIVITY_UPDATE_THRESHOLD_MS;
|
|
for (const [sid, timestamp] of sessionUpdateTimestamps.entries()) {
|
|
if (timestamp < oldestAllowed) {
|
|
sessionUpdateTimestamps.delete(sid);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Perform DB update
|
|
const conn = ConnectionFactory();
|
|
await conn.execute({
|
|
sql: "UPDATE Session SET last_used = datetime('now'), last_active_at = datetime('now') WHERE id = ?",
|
|
args: [sessionId]
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate a cryptographically secure refresh token
|
|
* @returns Base64URL-encoded random token (32 bytes = 256 bits)
|
|
*/
|
|
export function generateRefreshToken(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
/**
|
|
* Hash refresh token for storage (one-way hash)
|
|
* Using SHA-256 since refresh tokens are high-entropy random values
|
|
* @param token - Plaintext refresh token
|
|
* @returns Hex-encoded hash
|
|
*/
|
|
export function hashRefreshToken(token: string): string {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
/**
|
|
* Create a new session in database and Vinxi session
|
|
* @param event - H3Event
|
|
* @param userId - User ID
|
|
* @param rememberMe - Whether to use extended session duration
|
|
* @param ipAddress - Client IP address
|
|
* @param userAgent - Client user agent string
|
|
* @param parentSessionId - ID of parent session if this is a rotation (null for new sessions)
|
|
* @param tokenFamily - Token family UUID for rotation chain (generated if null)
|
|
* @returns Session data
|
|
*/
|
|
export async function createAuthSession(
|
|
event: H3Event,
|
|
userId: string,
|
|
rememberMe: boolean,
|
|
ipAddress: string,
|
|
userAgent: string,
|
|
parentSessionId: string | null = null,
|
|
tokenFamily: string | null = null
|
|
): Promise<SessionData> {
|
|
const conn = ConnectionFactory();
|
|
|
|
// Fetch is_admin from database
|
|
const userResult = await conn.execute({
|
|
sql: "SELECT is_admin FROM User WHERE id = ?",
|
|
args: [userId]
|
|
});
|
|
|
|
if (userResult.rows.length === 0) {
|
|
throw new Error(`User not found: ${userId}`);
|
|
}
|
|
|
|
const isAdmin = userResult.rows[0].is_admin === 1;
|
|
|
|
const sessionId = uuidV4();
|
|
const family = tokenFamily || uuidV4();
|
|
const refreshToken = generateRefreshToken();
|
|
const tokenHash = hashRefreshToken(refreshToken);
|
|
|
|
// Parse device information
|
|
const deviceInfo = getDeviceInfo(event);
|
|
|
|
// Calculate refresh token expiration
|
|
const refreshExpiry = rememberMe
|
|
? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG
|
|
: AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT;
|
|
|
|
const expiresAt = new Date();
|
|
if (refreshExpiry.endsWith("d")) {
|
|
const days = parseInt(refreshExpiry);
|
|
expiresAt.setDate(expiresAt.getDate() + days);
|
|
} else if (refreshExpiry.endsWith("h")) {
|
|
const hours = parseInt(refreshExpiry);
|
|
expiresAt.setHours(expiresAt.getHours() + hours);
|
|
}
|
|
|
|
// Calculate access token expiry
|
|
const accessExpiresAt = new Date();
|
|
const accessExpiry =
|
|
env.NODE_ENV === "production"
|
|
? AUTH_CONFIG.ACCESS_TOKEN_EXPIRY
|
|
: AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_DEV;
|
|
|
|
if (accessExpiry.endsWith("m")) {
|
|
const minutes = parseInt(accessExpiry);
|
|
accessExpiresAt.setMinutes(accessExpiresAt.getMinutes() + minutes);
|
|
} else if (accessExpiry.endsWith("h")) {
|
|
const hours = parseInt(accessExpiry);
|
|
accessExpiresAt.setHours(accessExpiresAt.getHours() + hours);
|
|
}
|
|
|
|
// Get rotation count from parent if exists
|
|
let rotationCount = 0;
|
|
if (parentSessionId) {
|
|
const parentResult = await conn.execute({
|
|
sql: "SELECT rotation_count FROM Session WHERE id = ?",
|
|
args: [parentSessionId]
|
|
});
|
|
if (parentResult.rows.length > 0) {
|
|
rotationCount = (parentResult.rows[0].rotation_count as number) + 1;
|
|
}
|
|
}
|
|
|
|
// Insert session into database with device metadata
|
|
await conn.execute({
|
|
sql: `INSERT INTO Session
|
|
(id, user_id, token_family, refresh_token_hash, parent_session_id,
|
|
rotation_count, expires_at, access_token_expires_at, ip_address, user_agent,
|
|
device_name, device_type, browser, os, last_active_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
|
args: [
|
|
sessionId,
|
|
userId,
|
|
family,
|
|
tokenHash,
|
|
parentSessionId,
|
|
rotationCount,
|
|
expiresAt.toISOString(),
|
|
accessExpiresAt.toISOString(),
|
|
ipAddress,
|
|
userAgent,
|
|
deviceInfo.deviceName || null,
|
|
deviceInfo.deviceType || null,
|
|
deviceInfo.browser || null,
|
|
deviceInfo.os || null
|
|
]
|
|
});
|
|
|
|
// Create session data
|
|
const sessionData: SessionData = {
|
|
userId,
|
|
sessionId,
|
|
tokenFamily: family,
|
|
isAdmin,
|
|
refreshToken,
|
|
rememberMe
|
|
};
|
|
|
|
console.log("[Session Create] Creating session with data:", {
|
|
userId,
|
|
sessionId,
|
|
isAdmin,
|
|
hasRefreshToken: !!refreshToken,
|
|
rememberMe
|
|
});
|
|
|
|
// Update Vinxi session with dynamic maxAge based on rememberMe
|
|
// Use getSessionConfig() to ensure password is read at runtime
|
|
const baseConfig = getSessionConfig();
|
|
const configWithMaxAge = {
|
|
...baseConfig,
|
|
maxAge: rememberMe
|
|
? expiryToSeconds(AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG)
|
|
: undefined // Session cookie (expires on browser close)
|
|
};
|
|
|
|
const session = await updateSession(event, configWithMaxAge, sessionData);
|
|
|
|
// Explicitly seal/flush the session to ensure cookie is written
|
|
// This is important in serverless environments where response might stream early
|
|
const { sealSession } = await import("vinxi/http");
|
|
await sealSession(event, configWithMaxAge);
|
|
|
|
setCookie(event, "session_id", sessionId, {
|
|
httpOnly: true,
|
|
secure: env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: configWithMaxAge.maxAge
|
|
});
|
|
|
|
try {
|
|
const cookieName = sessionConfig.name || "session";
|
|
const cookieValue = getCookie(event, cookieName);
|
|
|
|
const verifySession = await getSession<SessionData>(
|
|
event,
|
|
configWithMaxAge
|
|
);
|
|
} catch (verifyError) {
|
|
console.error("[Session Create] Failed to verify session:", verifyError);
|
|
}
|
|
|
|
// Log audit event
|
|
await logAuditEvent({
|
|
userId,
|
|
eventType: "auth.session_created",
|
|
eventData: {
|
|
sessionId,
|
|
tokenFamily: family,
|
|
rememberMe,
|
|
parentSessionId,
|
|
deviceName: deviceInfo.deviceName,
|
|
deviceType: deviceInfo.deviceType
|
|
},
|
|
success: true
|
|
});
|
|
|
|
return sessionData;
|
|
}
|
|
|
|
/**
|
|
* Get current session from Vinxi and validate against database
|
|
* @param event - H3Event
|
|
* @param skipUpdate - If true, don't update the session cookie (for SSR contexts)
|
|
* @returns Session data or null if invalid/expired
|
|
*/
|
|
export async function getAuthSession(
|
|
event: H3Event,
|
|
skipUpdate = false
|
|
): Promise<SessionData | null> {
|
|
try {
|
|
// In SSR contexts where headers may already be sent, use unsealSession directly
|
|
if (skipUpdate) {
|
|
const { unsealSession } = await import("vinxi/http");
|
|
const cookieName = sessionConfig.name || "session";
|
|
const cookieValue = getCookie(event, cookieName);
|
|
|
|
if (!cookieValue) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// unsealSession returns Partial<Session<T>>, not T directly
|
|
const session = await unsealSession(event, sessionConfig, cookieValue);
|
|
|
|
if (!session?.data || typeof session.data !== "object") {
|
|
const sessionIdCookie = getCookie(event, "session_id");
|
|
if (sessionIdCookie) {
|
|
const restored = await restoreSessionFromDB(event, sessionIdCookie);
|
|
if (restored) {
|
|
return restored;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const data = session.data as SessionData;
|
|
|
|
if (!data.userId || !data.sessionId) {
|
|
const sessionIdCookie = getCookie(event, "session_id");
|
|
|
|
if (sessionIdCookie) {
|
|
const restored = await restoreSessionFromDB(event, sessionIdCookie);
|
|
if (restored) {
|
|
return restored;
|
|
} else {
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Validate session against database
|
|
const isValid = await validateSessionInDB(
|
|
data.sessionId,
|
|
data.userId,
|
|
data.refreshToken
|
|
);
|
|
|
|
return isValid ? data : null;
|
|
} catch (err) {
|
|
console.error(
|
|
"[Session Get] Error in skipUpdate path (likely decryption failure):",
|
|
err
|
|
);
|
|
// If decryption failed (after server restart), try DB restoration
|
|
const sessionIdCookie = getCookie(event, "session_id");
|
|
if (sessionIdCookie) {
|
|
const restored = await restoreSessionFromDB(event, sessionIdCookie);
|
|
if (restored) {
|
|
return restored;
|
|
} else {
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Normal path - allow session updates
|
|
|
|
const session = await getSession<SessionData>(event, sessionConfig);
|
|
|
|
if (!session.data || !session.data.userId || !session.data.sessionId) {
|
|
// Fallback: Try to restore from DB using session_id cookie
|
|
const sessionIdCookie = getCookie(event, "session_id");
|
|
|
|
if (sessionIdCookie) {
|
|
const restored = await restoreSessionFromDB(event, sessionIdCookie);
|
|
if (restored) {
|
|
return restored;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Validate session against database
|
|
const isValid = await validateSessionInDB(
|
|
session.data.sessionId,
|
|
session.data.userId,
|
|
session.data.refreshToken
|
|
);
|
|
|
|
if (!isValid) {
|
|
// Clear invalid session - wrap in try/catch for headers-sent error
|
|
try {
|
|
await clearSession(event, sessionConfig);
|
|
} catch (clearError: any) {
|
|
// If headers already sent, we can't clear the cookie, but that's OK
|
|
// The session is invalid in DB anyway
|
|
if (clearError?.code !== "ERR_HTTP_HEADERS_SENT") {
|
|
throw clearError;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return session.data;
|
|
} catch (error: any) {
|
|
// If headers already sent, we can't read the session cookie properly
|
|
// This can happen in SSR when response streaming has started
|
|
if (error?.code === "ERR_HTTP_HEADERS_SENT") {
|
|
// Retry with skipUpdate
|
|
return getAuthSession(event, true);
|
|
}
|
|
console.error("Error getting auth session:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the latest valid session in a rotation chain
|
|
* Recursively follows child sessions until finding the most recent one
|
|
* @param conn - Database connection
|
|
* @param sessionId - Starting session ID
|
|
* @param maxDepth - Maximum depth to traverse (prevents infinite loops)
|
|
* @returns Latest session row or null if chain is invalid
|
|
*/
|
|
async function findLatestSessionInChain(
|
|
conn: ReturnType<typeof ConnectionFactory>,
|
|
sessionId: string,
|
|
maxDepth: number = 100
|
|
): Promise<any | null> {
|
|
if (maxDepth <= 0) {
|
|
return null;
|
|
}
|
|
|
|
// Get the current session
|
|
const result = await conn.execute({
|
|
sql: `SELECT id, user_id, token_family, revoked, expires_at
|
|
FROM Session
|
|
WHERE id = ?`,
|
|
args: [sessionId]
|
|
});
|
|
|
|
if (result.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const currentSession = result.rows[0];
|
|
|
|
// Check if this session has been rotated (has a child)
|
|
const childCheck = await conn.execute({
|
|
sql: `SELECT id FROM Session
|
|
WHERE parent_session_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
args: [sessionId]
|
|
});
|
|
|
|
if (childCheck.rows.length > 0) {
|
|
return findLatestSessionInChain(
|
|
conn,
|
|
childCheck.rows[0].id as string,
|
|
maxDepth - 1
|
|
);
|
|
}
|
|
|
|
if (currentSession.revoked === 1) {
|
|
return null;
|
|
}
|
|
|
|
const expiresAt = new Date(currentSession.expires_at as string);
|
|
if (expiresAt < new Date()) {
|
|
return null;
|
|
}
|
|
|
|
return currentSession;
|
|
}
|
|
|
|
/**
|
|
* Restore session from database when cookie data is empty/corrupt
|
|
* This provides a fallback mechanism for session recovery
|
|
* @param event - H3Event
|
|
* @param sessionId - Session ID from fallback cookie
|
|
* @returns Session data or null if cannot restore
|
|
*/
|
|
async function restoreSessionFromDB(
|
|
event: H3Event,
|
|
sessionId: string
|
|
): Promise<SessionData | null> {
|
|
try {
|
|
const conn = ConnectionFactory();
|
|
|
|
const { getRequestIP } = await import("vinxi/http");
|
|
const ipAddress = getRequestIP(event) || "unknown";
|
|
const userAgent = event.node?.req?.headers["user-agent"] || "unknown";
|
|
|
|
const result = await conn.execute({
|
|
sql: `SELECT s.id, s.user_id, s.token_family, s.refresh_token_hash,
|
|
s.revoked, s.expires_at, u.is_admin
|
|
FROM Session s
|
|
JOIN User u ON s.user_id = u.id
|
|
WHERE s.id = ?`,
|
|
args: [sessionId]
|
|
});
|
|
|
|
if (result.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const dbSession = result.rows[0];
|
|
|
|
const expiresAt = new Date(dbSession.expires_at as string);
|
|
if (expiresAt < new Date()) {
|
|
return null;
|
|
}
|
|
|
|
// Check if this session has already been rotated (has a child session)
|
|
// If so, follow the chain to find the latest valid session
|
|
// We check this BEFORE checking if revoked because revoked parents can have valid children
|
|
const childCheck = await conn.execute({
|
|
sql: `SELECT id, revoked, expires_at, refresh_token_hash
|
|
FROM Session
|
|
WHERE parent_session_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
args: [sessionId]
|
|
});
|
|
|
|
if (childCheck.rows.length > 0) {
|
|
const latestSession = await findLatestSessionInChain(
|
|
conn,
|
|
childCheck.rows[0].id as string
|
|
);
|
|
|
|
if (!latestSession) {
|
|
return null;
|
|
}
|
|
|
|
const newSession = await createAuthSession(
|
|
event,
|
|
latestSession.user_id as string,
|
|
true, // Assume rememberMe=true for restoration
|
|
ipAddress,
|
|
userAgent,
|
|
latestSession.id as string, // Parent is the latest session
|
|
latestSession.token_family as string // Reuse family
|
|
);
|
|
|
|
await conn.execute({
|
|
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
|
args: [latestSession.id]
|
|
});
|
|
|
|
return newSession;
|
|
}
|
|
|
|
// No children - this is the current session
|
|
// Validate it's not revoked (if no children, revoked = invalid)
|
|
if (dbSession.revoked === 1) {
|
|
return null;
|
|
}
|
|
|
|
// We can't restore the refresh token (it's hashed in DB)
|
|
|
|
const newSession = await createAuthSession(
|
|
event,
|
|
dbSession.user_id as string,
|
|
true, // Assume rememberMe=true for restoration
|
|
ipAddress,
|
|
userAgent,
|
|
sessionId, // Parent session
|
|
dbSession.token_family as string // Reuse family
|
|
);
|
|
|
|
// Mark parent session as revoked now that we've rotated it
|
|
await conn.execute({
|
|
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
|
args: [sessionId]
|
|
});
|
|
|
|
return newSession;
|
|
} catch (error) {
|
|
console.error("[Session Restore] Error restoring session:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate session against database
|
|
* Checks if session exists, not revoked, not expired, and refresh token matches
|
|
* Also validates that no child sessions exist (indicating this session was rotated)
|
|
* @param sessionId - Session ID
|
|
* @param userId - User ID
|
|
* @param refreshToken - Plaintext refresh token
|
|
* @returns true if valid, false otherwise
|
|
*/
|
|
async function validateSessionInDB(
|
|
sessionId: string,
|
|
userId: string,
|
|
refreshToken: string
|
|
): Promise<boolean> {
|
|
try {
|
|
const conn = ConnectionFactory();
|
|
const tokenHash = hashRefreshToken(refreshToken);
|
|
|
|
const result = await conn.execute({
|
|
sql: `SELECT revoked, expires_at, refresh_token_hash, token_family
|
|
FROM Session
|
|
WHERE id = ? AND user_id = ?`,
|
|
args: [sessionId, userId]
|
|
});
|
|
|
|
if (result.rows.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const session = result.rows[0];
|
|
|
|
// Check if revoked
|
|
if (session.revoked === 1) {
|
|
return false;
|
|
}
|
|
|
|
// Check if expired
|
|
const expiresAt = new Date(session.expires_at as string);
|
|
if (expiresAt < new Date()) {
|
|
return false;
|
|
}
|
|
|
|
// Validate refresh token hash (timing-safe comparison)
|
|
const storedHash = session.refresh_token_hash as string;
|
|
if (
|
|
!timingSafeEqual(
|
|
Buffer.from(tokenHash, "hex"),
|
|
Buffer.from(storedHash, "hex")
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// CRITICAL: Check if this session has been rotated (has a child session)
|
|
// If a child exists, check if we're within the grace period for cookie propagation
|
|
// This handles SSR/serverless cases where client may not have received new cookies yet
|
|
const childCheck = await conn.execute({
|
|
sql: `SELECT id, created_at FROM Session
|
|
WHERE parent_session_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
args: [sessionId]
|
|
});
|
|
|
|
if (childCheck.rows.length > 0) {
|
|
// This session has been rotated - check grace period
|
|
const childSession = childCheck.rows[0];
|
|
const childCreatedAt = new Date(childSession.created_at as string);
|
|
const now = new Date();
|
|
const timeSinceRotation = now.getTime() - childCreatedAt.getTime();
|
|
|
|
// Grace period allows client to receive and use new cookies from rotation
|
|
// This is critical for SSR/serverless where response cookies may be delayed
|
|
if (timeSinceRotation >= AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Update last_used and last_active_at timestamps (throttled)
|
|
// Only update DB if last update was > 5 minutes ago (reduces writes by 95%+)
|
|
updateSessionActivityThrottled(sessionId).catch((err) =>
|
|
console.error("Failed to update session timestamps:", err)
|
|
);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Session validation error:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Invalidate a specific session in database and clear Vinxi session
|
|
* @param event - H3Event
|
|
* @param sessionId - Session ID to invalidate
|
|
*/
|
|
export async function invalidateAuthSession(
|
|
event: H3Event,
|
|
sessionId: string
|
|
): Promise<void> {
|
|
const conn = ConnectionFactory();
|
|
|
|
await conn.execute({
|
|
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
|
args: [sessionId]
|
|
});
|
|
|
|
await clearSession(event, sessionConfig);
|
|
|
|
// Also clear the session_id fallback cookie
|
|
setCookie(event, "session_id", "", {
|
|
httpOnly: true,
|
|
secure: env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: 0 // Expire immediately
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Revoke all sessions in a token family
|
|
* Used when breach is detected (token reuse)
|
|
* @param tokenFamily - Token family ID to revoke
|
|
* @param reason - Reason for revocation (for audit)
|
|
*/
|
|
export async function revokeTokenFamily(
|
|
tokenFamily: string,
|
|
reason: string = "breach_detected"
|
|
): Promise<void> {
|
|
const conn = ConnectionFactory();
|
|
|
|
// Get all sessions in family for audit log
|
|
const sessions = await conn.execute({
|
|
sql: "SELECT id, user_id FROM Session WHERE token_family = ? AND revoked = 0",
|
|
args: [tokenFamily]
|
|
});
|
|
|
|
// Revoke all sessions in family
|
|
|
|
await conn.execute({
|
|
sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?",
|
|
args: [tokenFamily]
|
|
});
|
|
|
|
// Log audit events for each affected session
|
|
for (const session of sessions.rows) {
|
|
await logAuditEvent({
|
|
userId: session.user_id as string,
|
|
eventType: "auth.token_family_revoked",
|
|
eventData: {
|
|
tokenFamily,
|
|
sessionId: session.id as string,
|
|
reason
|
|
},
|
|
success: true
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect if a token is being reused after rotation
|
|
* Implements grace period for race conditions
|
|
* @param sessionId - Session ID being validated
|
|
* @returns true if reuse detected (and revocation occurred), false otherwise
|
|
*/
|
|
export async function detectTokenReuse(sessionId: string): Promise<boolean> {
|
|
const conn = ConnectionFactory();
|
|
|
|
// Check if this session has already been rotated (has child session)
|
|
const childCheck = await conn.execute({
|
|
sql: `SELECT id, created_at FROM Session
|
|
WHERE parent_session_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
args: [sessionId]
|
|
});
|
|
|
|
if (childCheck.rows.length === 0) {
|
|
// No child session, this is legitimate first use
|
|
return false;
|
|
}
|
|
|
|
const childSession = childCheck.rows[0];
|
|
const childCreatedAt = new Date(childSession.created_at as string);
|
|
const now = new Date();
|
|
const timeSinceRotation = now.getTime() - childCreatedAt.getTime();
|
|
|
|
// Grace period for race conditions
|
|
if (timeSinceRotation < AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) {
|
|
return false;
|
|
}
|
|
|
|
// Reuse detected outside grace period - this is a breach!
|
|
|
|
// Get token family and revoke entire family
|
|
const sessionInfo = await conn.execute({
|
|
sql: "SELECT token_family, user_id FROM Session WHERE id = ?",
|
|
args: [sessionId]
|
|
});
|
|
|
|
if (sessionInfo.rows.length > 0) {
|
|
const tokenFamily = sessionInfo.rows[0].token_family as string;
|
|
const userId = sessionInfo.rows[0].user_id as string;
|
|
|
|
await revokeTokenFamily(tokenFamily, "token_reuse_detected");
|
|
|
|
// Log critical security event
|
|
await logAuditEvent({
|
|
userId,
|
|
eventType: "auth.token_reuse_detected",
|
|
eventData: {
|
|
sessionId,
|
|
tokenFamily,
|
|
timeSinceRotation
|
|
},
|
|
success: false
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Rotate refresh token: invalidate old, issue new tokens
|
|
* Implements automatic breach detection
|
|
* @param event - H3Event
|
|
* @param oldSessionData - Current session data
|
|
* @param ipAddress - Client IP address for new session
|
|
* @param userAgent - Client user agent for new session
|
|
* @returns New session data or null if rotation fails
|
|
*/
|
|
export async function rotateAuthSession(
|
|
event: H3Event,
|
|
oldSessionData: SessionData,
|
|
ipAddress: string,
|
|
userAgent: string
|
|
): Promise<SessionData | null> {
|
|
// Validate old session exists in DB
|
|
const isValid = await validateSessionInDB(
|
|
oldSessionData.sessionId,
|
|
oldSessionData.userId,
|
|
oldSessionData.refreshToken
|
|
);
|
|
|
|
if (!isValid) {
|
|
return null;
|
|
}
|
|
|
|
// Detect token reuse (breach detection)
|
|
const reuseDetected = await detectTokenReuse(oldSessionData.sessionId);
|
|
if (reuseDetected) {
|
|
return null;
|
|
}
|
|
|
|
// Check rotation limit
|
|
const conn = ConnectionFactory();
|
|
const sessionCheck = await conn.execute({
|
|
sql: "SELECT rotation_count FROM Session WHERE id = ?",
|
|
args: [oldSessionData.sessionId]
|
|
});
|
|
|
|
if (sessionCheck.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const rotationCount = sessionCheck.rows[0].rotation_count as number;
|
|
if (rotationCount >= AUTH_CONFIG.MAX_ROTATION_COUNT) {
|
|
await invalidateAuthSession(event, oldSessionData.sessionId);
|
|
return null;
|
|
}
|
|
|
|
// Create new session (linked to old via parent_session_id)
|
|
const newSessionData = await createAuthSession(
|
|
event,
|
|
oldSessionData.userId,
|
|
oldSessionData.rememberMe,
|
|
ipAddress,
|
|
userAgent,
|
|
oldSessionData.sessionId, // parent session
|
|
oldSessionData.tokenFamily // reuse family
|
|
);
|
|
|
|
// Invalidate old session
|
|
await conn.execute({
|
|
sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
|
|
args: [oldSessionData.sessionId]
|
|
});
|
|
|
|
// Log rotation event
|
|
await logAuditEvent({
|
|
userId: oldSessionData.userId,
|
|
eventType: "auth.token_rotated",
|
|
eventData: {
|
|
oldSessionId: oldSessionData.sessionId,
|
|
newSessionId: newSessionData.sessionId,
|
|
tokenFamily: oldSessionData.tokenFamily,
|
|
rotationCount: rotationCount + 1
|
|
},
|
|
success: true
|
|
});
|
|
|
|
return newSessionData;
|
|
}
|