fix: next child chain

This commit is contained in:
Michael Freno
2026-01-12 21:03:00 -05:00
parent 744a0cd611
commit 53dc7c1aab

View File

@@ -476,6 +476,80 @@ export async function getAuthSession(
} }
} }
/**
* 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) {
console.log("[Session Chain] Max depth reached, stopping traversal");
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) {
// Session has a child, follow the chain
console.log(
`[Session Chain] Session ${sessionId} has child, following chain...`
);
return findLatestSessionInChain(
conn,
childCheck.rows[0].id as string,
maxDepth - 1
);
}
// No child found - this is the latest session
// Verify it's valid (not revoked, not expired)
if (currentSession.revoked === 1) {
console.log(
`[Session Chain] Latest session ${sessionId} is revoked - chain invalid`
);
return null;
}
const expiresAt = new Date(currentSession.expires_at as string);
if (expiresAt < new Date()) {
console.log(
`[Session Chain] Latest session ${sessionId} is expired - chain invalid`
);
return null;
}
console.log(`[Session Chain] Found valid latest session: ${sessionId}`);
return currentSession;
}
/** /**
* Restore session from database when cookie data is empty/corrupt * Restore session from database when cookie data is empty/corrupt
* This provides a fallback mechanism for session recovery * This provides a fallback mechanism for session recovery
@@ -491,6 +565,11 @@ async function restoreSessionFromDB(
console.log("[Session Restore] Starting restore for sessionId:", sessionId); console.log("[Session Restore] Starting restore for sessionId:", sessionId);
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Get IP and user agent early since we'll need them for any rotation
const { getRequestIP } = await import("vinxi/http");
const ipAddress = getRequestIP(event) || "unknown";
const userAgent = event.node?.req?.headers["user-agent"] || "unknown";
// Query DB for session with all necessary data including is_admin // Query DB for session with all necessary data including is_admin
const result = await conn.execute({ const result = await conn.execute({
sql: `SELECT s.id, s.user_id, s.token_family, s.refresh_token_hash, sql: `SELECT s.id, s.user_id, s.token_family, s.refresh_token_hash,
@@ -518,34 +597,76 @@ async function restoreSessionFromDB(
expiresAt: dbSession.expires_at expiresAt: dbSession.expires_at
}); });
// Validate session is still valid // Check if refresh token is expired (applies to all sessions in chain)
if (dbSession.revoked === 1) {
console.log("[Session Restore] Session is revoked");
return null;
}
const expiresAt = new Date(dbSession.expires_at as string); const expiresAt = new Date(dbSession.expires_at as string);
if (expiresAt < new Date()) { if (expiresAt < new Date()) {
console.log("[Session Restore] Session is expired"); console.log("[Session Restore] Session refresh token expired");
return null; return null;
} }
// Check if this session has already been rotated (has a child session) // Check if this session has already been rotated (has a child session)
// If so, we cannot restore from it - user must create a new 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({ const childCheck = await conn.execute({
sql: `SELECT id FROM Session WHERE parent_session_id = ? LIMIT 1`, sql: `SELECT id, revoked, expires_at, refresh_token_hash
FROM Session
WHERE parent_session_id = ?
ORDER BY created_at DESC
LIMIT 1`,
args: [sessionId] args: [sessionId]
}); });
if (childCheck.rows.length > 0) { if (childCheck.rows.length > 0) {
console.log( console.log(
"[Session Restore] Session has already been rotated - cannot restore" "[Session Restore] Session has already been rotated - following chain to latest child"
); );
// Revoke this session to ensure it's marked as used
// Follow the chain to find the latest valid session
const latestSession = await findLatestSessionInChain(
conn,
childCheck.rows[0].id as string
);
if (!latestSession) {
console.log("[Session Restore] Could not find valid session in chain");
return null;
}
console.log(
"[Session Restore] Found latest session in chain:",
latestSession.id
);
// Use the latest session to restore
// Generate new refresh token and rotate from the latest session
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
);
// Mark the latest session as revoked now that we've rotated it
await conn.execute({ await conn.execute({
sql: "UPDATE Session SET revoked = 1 WHERE id = ?", sql: "UPDATE Session SET revoked = 1 WHERE id = ?",
args: [sessionId] args: [latestSession.id]
}); });
console.log(
"[Session Restore] Successfully restored from latest session in chain"
);
return newSession;
}
// No children - this is the current session
// Validate it's not revoked (if no children, revoked = invalid)
if (dbSession.revoked === 1) {
console.log(
"[Session Restore] Session is revoked and has no children - cannot restore"
);
return null; return null;
} }
@@ -554,11 +675,6 @@ async function restoreSessionFromDB(
console.log("[Session Restore] Creating new rotated session..."); console.log("[Session Restore] Creating new rotated session...");
// Get IP and user agent
const { getRequestIP } = await import("vinxi/http");
const ipAddress = getRequestIP(event) || "unknown";
const userAgent = event.node?.req?.headers["user-agent"] || "unknown";
// Create a new session (this will be a rotation) // Create a new session (this will be a rotation)
const newSession = await createAuthSession( const newSession = await createAuthSession(
event, event,