session.js (15748B)
1 import crypto from 'node:crypto'; 2 import { parseCookie, stringifySetCookie } from 'cookie'; 3 import { findUserById, getMembership, getUserRoles } from './users.js'; 4 5 export const SHORT_SESSION_TTL = 12 * 60 * 60; 6 export const LONG_SESSION_TTL = 7 * 24 * 60 * 60; 7 export const SESSION_COOKIE = 'auth_session'; 8 const TOUCH_INTERVAL_MS = 60 * 1000; 9 10 function isSecureRequest(req) { 11 if (req.socket?.encrypted) return true; 12 const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase(); 13 return proto === 'https'; 14 } 15 16 function appendSetCookie(res, value) { 17 const existing = res.getHeader('Set-Cookie'); 18 if (!existing) { 19 res.setHeader('Set-Cookie', value); 20 return; 21 } 22 const values = Array.isArray(existing) ? existing : [existing]; 23 res.setHeader('Set-Cookie', [...values, value]); 24 } 25 26 export function setSessionCookie(res, req, token, ttl) { 27 appendSetCookie( 28 res, 29 stringifySetCookie({ 30 name: SESSION_COOKIE, 31 value: token, 32 httpOnly: true, 33 sameSite: 'lax', 34 path: '/', 35 maxAge: ttl, 36 secure: isSecureRequest(req), 37 }) 38 ); 39 } 40 41 export function clearSessionCookie(res, req) { 42 appendSetCookie( 43 res, 44 stringifySetCookie({ 45 name: SESSION_COOKIE, 46 value: '', 47 httpOnly: true, 48 sameSite: 'lax', 49 path: '/', 50 maxAge: 0, 51 secure: isSecureRequest(req), 52 }) 53 ); 54 } 55 56 export function requestToken(req) { 57 const cookies = parseCookie(req.headers.cookie || ''); 58 return cookies[SESSION_COOKIE] || null; 59 } 60 61 export function requestIp(req) { 62 const forwarded = String(req.headers['x-forwarded-for'] || '') 63 .split(',')[0] 64 .trim(); 65 return forwarded || req.socket?.remoteAddress || null; 66 } 67 68 function hashToken(token) { 69 return crypto.createHash('sha256').update(token).digest('hex'); 70 } 71 72 function scopeForContext(context) { 73 if (context.mode === 'canonical') { 74 return { scopeType: 'global', realmId: null, clientId: null }; 75 } 76 if (context.mode === 'realm') { 77 return { scopeType: 'realm', realmId: context.realm.id, clientId: null }; 78 } 79 return { 80 scopeType: 'client', 81 realmId: context.realm.id, 82 clientId: context.clientId, 83 }; 84 } 85 86 function scopeMatches(session, context) { 87 if (session.scope_host !== context.host) return false; 88 if (context.mode === 'canonical') return session.scope_type === 'global'; 89 if (context.mode === 'realm') { 90 return session.scope_type === 'realm' && session.realm_id === context.realm.id; 91 } 92 return ( 93 session.scope_type === 'client' && 94 session.realm_id === context.realm.id && 95 session.client_id === context.clientId 96 ); 97 } 98 99 async function findGroupByToken(systemDb, token) { 100 if (!token) return null; 101 return systemDb.get( 102 `SELECT id, host, active_session_id, created_at, last_seen_at 103 FROM session_groups 104 WHERE id = {id}`, 105 { id: hashToken(token) } 106 ); 107 } 108 109 async function findActiveSession(systemDb, group) { 110 if (!group?.active_session_id) return null; 111 return systemDb.get( 112 `SELECT id, public_id, group_id, user_id, scope_type, scope_host, realm_id, 113 client_id, ttl, auth_time, created_at, expires_at, last_seen_at, 114 user_agent, ip 115 FROM sessions 116 WHERE id = {id} AND group_id = {groupId}`, 117 { id: group.active_session_id, groupId: group.id } 118 ); 119 } 120 121 export async function getSessionByToken(systemDb, token, now = Date.now()) { 122 const group = await findGroupByToken(systemDb, token); 123 if (!group) return null; 124 const session = await findActiveSession(systemDb, group); 125 if (!session || session.expires_at <= now) return null; 126 return { group, session }; 127 } 128 129 async function deleteGroup(systemDb, groupId) { 130 await systemDb.run('DELETE FROM session_groups WHERE id = {id}', { id: groupId }); 131 } 132 133 async function nextActiveSession(systemDb, groupId, now) { 134 return systemDb.get( 135 `SELECT id 136 FROM sessions 137 WHERE group_id = {groupId} AND expires_at > {now} 138 ORDER BY created_at DESC 139 LIMIT 1`, 140 { groupId, now } 141 ); 142 } 143 144 export async function purgeExpiredSessions(systemDb, now = Date.now()) { 145 await systemDb.transaction(async (tx) => { 146 await tx.run('DELETE FROM sessions WHERE expires_at <= {now}', { now }); 147 await tx.run( 148 'DELETE FROM session_groups WHERE id NOT IN (SELECT group_id FROM sessions)' 149 ); 150 }); 151 } 152 153 async function insertSession(tx, { 154 groupId, 155 userId, 156 scope, 157 scopeHost, 158 ttl, 159 userAgent, 160 ip, 161 now, 162 }) { 163 const id = crypto.randomUUID(); 164 const publicId = crypto.randomUUID(); 165 const expiresAt = now + ttl * 1000; 166 const authTime = Math.floor(now / 1000); 167 168 await tx.run( 169 `INSERT INTO sessions 170 (id, public_id, group_id, user_id, scope_type, scope_host, realm_id, 171 client_id, ttl, auth_time, created_at, expires_at, last_seen_at, 172 user_agent, ip) 173 VALUES 174 ({id}, {publicId}, {groupId}, {userId}, {scopeType}, {scopeHost}, {realmId}, 175 {clientId}, {ttl}, {authTime}, {createdAt}, {expiresAt}, {lastSeenAt}, 176 {userAgent}, {ip})`, 177 { 178 id, 179 publicId, 180 groupId, 181 userId, 182 scopeType: scope.scopeType, 183 scopeHost, 184 realmId: scope.realmId, 185 clientId: scope.clientId, 186 ttl, 187 authTime, 188 createdAt: now, 189 expiresAt, 190 lastSeenAt: now, 191 userAgent, 192 ip, 193 } 194 ); 195 196 return { 197 id, 198 public_id: publicId, 199 group_id: groupId, 200 user_id: userId, 201 scope_type: scope.scopeType, 202 scope_host: scopeHost, 203 realm_id: scope.realmId, 204 client_id: scope.clientId, 205 ttl, 206 auth_time: authTime, 207 created_at: now, 208 expires_at: expiresAt, 209 last_seen_at: now, 210 user_agent: userAgent, 211 ip, 212 }; 213 } 214 215 export async function createHostSession( 216 systemDb, 217 { context, token = null, userId, ttl, userAgent = null, ip = null }, 218 now = Date.now() 219 ) { 220 const scope = scopeForContext(context); 221 222 return systemDb.transaction(async (tx) => { 223 let group = await findGroupByToken(tx, token); 224 if (group && group.host !== context.host) group = null; 225 226 if (group) { 227 const existing = await tx.get( 228 `SELECT id, public_id, group_id, user_id, scope_type, scope_host, realm_id, 229 client_id, ttl, auth_time, created_at, expires_at, last_seen_at, 230 user_agent, ip 231 FROM sessions 232 WHERE group_id = {groupId} AND user_id = {userId}`, 233 { groupId: group.id, userId } 234 ); 235 236 if ( 237 existing && 238 existing.expires_at > now && 239 existing.scope_type === scope.scopeType && 240 existing.scope_host === context.host && 241 existing.realm_id === scope.realmId && 242 existing.client_id === scope.clientId 243 ) { 244 const sessionTtl = Math.max(existing.ttl, ttl); 245 const expiresAt = Math.max(existing.expires_at, now + sessionTtl * 1000); 246 await tx.run( 247 `UPDATE sessions 248 SET ttl = {ttl}, expires_at = {expiresAt}, last_seen_at = {now}, 249 user_agent = {userAgent}, ip = {ip} 250 WHERE id = {id}`, 251 { ttl: sessionTtl, expiresAt, now, userAgent, ip, id: existing.id } 252 ); 253 await tx.run( 254 `UPDATE session_groups 255 SET active_session_id = {sessionId}, last_seen_at = {now} 256 WHERE id = {groupId}`, 257 { sessionId: existing.id, now, groupId: group.id } 258 ); 259 return { 260 token, 261 group: { ...group, active_session_id: existing.id, last_seen_at: now }, 262 session: { 263 ...existing, 264 ttl: sessionTtl, 265 expires_at: expiresAt, 266 last_seen_at: now, 267 user_agent: userAgent, 268 ip, 269 }, 270 }; 271 } 272 273 if (existing) { 274 await tx.run('DELETE FROM sessions WHERE id = {id}', { id: existing.id }); 275 } 276 277 const session = await insertSession(tx, { 278 groupId: group.id, 279 userId, 280 scope, 281 scopeHost: context.host, 282 ttl, 283 userAgent, 284 ip, 285 now, 286 }); 287 await tx.run( 288 `UPDATE session_groups 289 SET active_session_id = {sessionId}, last_seen_at = {now} 290 WHERE id = {groupId}`, 291 { sessionId: session.id, now, groupId: group.id } 292 ); 293 294 return { 295 token, 296 group: { ...group, active_session_id: session.id, last_seen_at: now }, 297 session, 298 }; 299 } 300 301 const nextToken = crypto.randomBytes(32).toString('base64url'); 302 const groupId = hashToken(nextToken); 303 const createdAt = now; 304 305 await tx.run( 306 `INSERT INTO session_groups 307 (id, host, active_session_id, created_at, last_seen_at) 308 VALUES 309 ({id}, {host}, NULL, {createdAt}, {lastSeenAt})`, 310 { id: groupId, host: context.host, createdAt, lastSeenAt: now } 311 ); 312 313 const session = await insertSession(tx, { 314 groupId, 315 userId, 316 scope, 317 scopeHost: context.host, 318 ttl, 319 userAgent, 320 ip, 321 now, 322 }); 323 324 await tx.run( 325 'UPDATE session_groups SET active_session_id = {sessionId} WHERE id = {id}', 326 { sessionId: session.id, id: groupId } 327 ); 328 329 return { 330 token: nextToken, 331 group: { 332 id: groupId, 333 host: context.host, 334 active_session_id: session.id, 335 created_at: createdAt, 336 last_seen_at: now, 337 }, 338 session, 339 }; 340 }); 341 } 342 343 export async function listSessionAccounts(systemDb, group, now = Date.now()) { 344 if (!group) return []; 345 346 const rows = await systemDb.all( 347 `SELECT s.id, s.public_id, s.user_id, s.created_at, s.expires_at, 348 u.username, u.display_name 349 FROM sessions s 350 JOIN users u ON u.id = s.user_id 351 WHERE s.group_id = {groupId} 352 AND s.expires_at > {now} 353 AND u.status = 'active' 354 ORDER BY s.created_at ASC`, 355 { groupId: group.id, now } 356 ); 357 358 return rows.map((row) => ({ 359 public_id: row.public_id, 360 active: row.id === group.active_session_id, 361 user: { 362 id: row.user_id, 363 username: row.username, 364 display_name: row.display_name, 365 }, 366 created_at: row.created_at, 367 expires_at: row.expires_at, 368 })); 369 } 370 371 export async function selectSessionAccount( 372 systemDb, 373 { token, context, publicId }, 374 now = Date.now() 375 ) { 376 if (!token || !publicId) return null; 377 378 return systemDb.transaction(async (tx) => { 379 const group = await findGroupByToken(tx, token); 380 if (!group || group.host !== context.host) return null; 381 382 const session = await tx.get( 383 `SELECT id, public_id, group_id, user_id, scope_type, scope_host, realm_id, 384 client_id, ttl, auth_time, created_at, expires_at, last_seen_at, 385 user_agent, ip 386 FROM sessions 387 WHERE group_id = {groupId} AND public_id = {publicId}`, 388 { groupId: group.id, publicId } 389 ); 390 if (!session || session.expires_at <= now || !scopeMatches(session, context)) { 391 return null; 392 } 393 394 const user = await findUserById(tx, session.user_id); 395 if (!user || user.status !== 'active') return null; 396 397 const expiresAt = now + session.ttl * 1000; 398 await tx.run( 399 `UPDATE sessions 400 SET last_seen_at = {now}, expires_at = {expiresAt} 401 WHERE id = {id}`, 402 { now, expiresAt, id: session.id } 403 ); 404 await tx.run( 405 `UPDATE session_groups 406 SET active_session_id = {sessionId}, last_seen_at = {now} 407 WHERE id = {id}`, 408 { sessionId: session.id, now, id: group.id } 409 ); 410 411 return { 412 group: { ...group, active_session_id: session.id, last_seen_at: now }, 413 session: { ...session, last_seen_at: now, expires_at: expiresAt }, 414 user, 415 }; 416 }); 417 } 418 419 export async function authenticateRequest({ 420 systemDb, 421 realmDb = null, 422 req, 423 res, 424 context, 425 now = Date.now(), 426 }) { 427 const token = requestToken(req); 428 if (!token) return null; 429 430 const group = await findGroupByToken(systemDb, token); 431 if (!group || group.host !== context.host) { 432 clearSessionCookie(res, req); 433 return null; 434 } 435 436 const session = await findActiveSession(systemDb, group); 437 if (!session || session.expires_at <= now) { 438 if (session) { 439 await systemDb.run('DELETE FROM sessions WHERE id = {id}', { id: session.id }); 440 } 441 const next = await nextActiveSession(systemDb, group.id, now); 442 if (next) { 443 await systemDb.run( 444 'UPDATE session_groups SET active_session_id = {sessionId} WHERE id = {id}', 445 { sessionId: next.id, id: group.id } 446 ); 447 } else { 448 await deleteGroup(systemDb, group.id); 449 clearSessionCookie(res, req); 450 } 451 return null; 452 } 453 454 if (!scopeMatches(session, context)) { 455 clearSessionCookie(res, req); 456 return null; 457 } 458 459 const user = await findUserById(systemDb, session.user_id); 460 if (!user || user.status !== 'active') { 461 const groupDeleted = await systemDb.transaction(async (tx) => { 462 await tx.run('DELETE FROM sessions WHERE id = {id}', { id: session.id }); 463 const next = await nextActiveSession(tx, group.id, now); 464 if (next) { 465 await tx.run( 466 'UPDATE session_groups SET active_session_id = {sessionId} WHERE id = {id}', 467 { sessionId: next.id, id: group.id } 468 ); 469 return false; 470 } 471 await deleteGroup(tx, group.id); 472 return true; 473 }); 474 if (groupDeleted) clearSessionCookie(res, req); 475 return null; 476 } 477 478 const membership = realmDb ? await getMembership(realmDb, user.id) : null; 479 const roles = realmDb ? await getUserRoles(realmDb, user.id) : []; 480 481 if (now - session.last_seen_at >= TOUCH_INTERVAL_MS) { 482 const expiresAt = now + session.ttl * 1000; 483 await systemDb.transaction(async (tx) => { 484 await tx.run( 485 `UPDATE sessions 486 SET last_seen_at = {now}, expires_at = {expiresAt} 487 WHERE id = {id}`, 488 { now, expiresAt, id: session.id } 489 ); 490 await tx.run( 491 'UPDATE session_groups SET last_seen_at = {now} WHERE id = {id}', 492 { now, id: group.id } 493 ); 494 }); 495 session.last_seen_at = now; 496 session.expires_at = expiresAt; 497 setSessionCookie(res, req, token, session.ttl); 498 } 499 500 return { group, session, user, membership, roles }; 501 } 502 503 export async function destroySession(systemDb, token) { 504 if (!token) return { groupDeleted: true }; 505 506 return systemDb.transaction(async (tx) => { 507 const group = await findGroupByToken(tx, token); 508 if (!group) return { groupDeleted: true }; 509 510 const session = await findActiveSession(tx, group); 511 if (session) { 512 await tx.run('DELETE FROM sessions WHERE id = {id}', { id: session.id }); 513 } 514 515 const next = await nextActiveSession(tx, group.id, Date.now()); 516 if (next) { 517 await tx.run( 518 'UPDATE session_groups SET active_session_id = {sessionId} WHERE id = {id}', 519 { sessionId: next.id, id: group.id } 520 ); 521 return { groupDeleted: false }; 522 } 523 524 await deleteGroup(tx, group.id); 525 return { groupDeleted: true }; 526 }); 527 } 528 529 export async function bindSessionRealm(systemDb, sessionId, realmId, now = Date.now()) { 530 const existing = await systemDb.get( 531 `SELECT session_id, realm_id, revoked_at 532 FROM session_realms 533 WHERE session_id = {sessionId} AND realm_id = {realmId}`, 534 { sessionId, realmId } 535 ); 536 537 if (existing) { 538 if (!existing.revoked_at) { 539 await systemDb.run( 540 `UPDATE session_realms 541 SET last_seen_at = {now} 542 WHERE session_id = {sessionId} AND realm_id = {realmId}`, 543 { now, sessionId, realmId } 544 ); 545 } 546 return; 547 } 548 549 await systemDb.run( 550 `INSERT INTO session_realms 551 (session_id, realm_id, created_at, last_seen_at, revoked_at, revoked_by) 552 VALUES 553 ({sessionId}, {realmId}, {now}, {now}, NULL, NULL)`, 554 { sessionId, realmId, now } 555 ); 556 }