session.test.js (14276B)
1 import test from 'node:test'; 2 import assert from 'node:assert/strict'; 3 import crypto from 'node:crypto'; 4 import fs from 'node:fs/promises'; 5 import os from 'node:os'; 6 import path from 'node:path'; 7 import { 8 closeRealms, 9 createRealm, 10 getSystemDatabase, 11 initRealms, 12 openRealmDatabase, 13 } from '../src/lib/realm.js'; 14 import { 15 SESSION_COOKIE, 16 SHORT_SESSION_TTL, 17 authenticateRequest, 18 bindSessionRealm, 19 createHostSession, 20 destroySession, 21 getSessionByToken, 22 listSessionAccounts, 23 selectSessionAccount, 24 } from '../src/lib/session.js'; 25 import { createUser, ensureMembership } from '../src/lib/users.js'; 26 27 function realmContext(realm, host = 'acme.localhost') { 28 return { mode: 'realm', host, realm }; 29 } 30 31 function clientContext(realm, host = 'app.acme.localhost', clientId = 'internal-app') { 32 return { mode: 'client', host, realm, clientId }; 33 } 34 35 function canonicalContext(host = 'identity.test') { 36 return { mode: 'canonical', host }; 37 } 38 39 async function setup() { 40 const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-session-')); 41 const cfg = { 42 database: { 43 dialect: 'sqlite', 44 path: path.join(dir, 'system.sqlite'), 45 realms: path.join(dir, 'realms'), 46 }, 47 }; 48 await initRealms(cfg); 49 const realm = await createRealm({ slug: 'acme', displayName: 'Acme' }); 50 const realmDb = await openRealmDatabase(realm); 51 const systemDb = getSystemDatabase(); 52 const userId = await createUser(systemDb, { username: 'alice', displayName: 'Alice' }); 53 await ensureMembership(realmDb, userId, 'active'); 54 return { dir, realm, realmDb, systemDb, userId }; 55 } 56 57 function mockRequest(cookie = '') { 58 return { 59 headers: { cookie }, 60 socket: { remoteAddress: '127.0.0.1' }, 61 }; 62 } 63 64 function mockResponse() { 65 const headers = {}; 66 return { 67 headers, 68 getHeader(name) { 69 return headers[name.toLowerCase()]; 70 }, 71 setHeader(name, value) { 72 headers[name.toLowerCase()] = value; 73 }, 74 }; 75 } 76 77 test('sessions store only the group token hash and authenticate the user', async () => { 78 const { dir, realm, realmDb, systemDb, userId } = await setup(); 79 try { 80 const context = realmContext(realm); 81 const { token, group, session } = await createHostSession(systemDb, { 82 context, 83 userId, 84 ttl: SHORT_SESSION_TTL, 85 userAgent: 'test', 86 ip: '127.0.0.1', 87 }); 88 const id = crypto.createHash('sha256').update(token).digest('hex'); 89 90 assert.equal(group.id, id); 91 assert.notEqual(session.public_id, token); 92 93 const found = await getSessionByToken(systemDb, token); 94 assert.equal(found.session.id, session.id); 95 96 const req = mockRequest(`${SESSION_COOKIE}=${token}`); 97 const res = mockResponse(); 98 const auth = await authenticateRequest({ 99 systemDb, 100 realmDb, 101 req, 102 res, 103 context, 104 }); 105 assert.equal(auth.user.id, userId); 106 assert.equal(auth.membership.status, 'active'); 107 } finally { 108 await closeRealms(); 109 await fs.rm(dir, { recursive: true, force: true }); 110 } 111 }); 112 113 test('host scopes are stored from the request context', async () => { 114 const { dir, realm, systemDb, userId } = await setup(); 115 try { 116 const global = await createHostSession(systemDb, { 117 context: canonicalContext(), 118 userId, 119 ttl: SHORT_SESSION_TTL, 120 }); 121 assert.equal(global.session.scope_type, 'global'); 122 assert.equal(global.session.scope_host, 'identity.test'); 123 assert.equal(global.session.realm_id, null); 124 125 const client = await createHostSession(systemDb, { 126 context: clientContext(realm), 127 userId, 128 ttl: SHORT_SESSION_TTL, 129 }); 130 assert.equal(client.session.scope_type, 'client'); 131 assert.equal(client.session.scope_host, 'app.acme.localhost'); 132 assert.equal(client.session.realm_id, realm.id); 133 assert.equal(client.session.client_id, 'internal-app'); 134 } finally { 135 await closeRealms(); 136 await fs.rm(dir, { recursive: true, force: true }); 137 } 138 }); 139 140 test('sliding sessions extend expiry and reissue the cookie', async () => { 141 const { dir, realm, realmDb, systemDb, userId } = await setup(); 142 try { 143 const context = realmContext(realm); 144 const { token, session } = await createHostSession( 145 systemDb, 146 { context, userId, ttl: SHORT_SESSION_TTL }, 147 1000 148 ); 149 const req = mockRequest(`${SESSION_COOKIE}=${token}`); 150 const res = mockResponse(); 151 152 await authenticateRequest({ 153 systemDb, 154 realmDb, 155 req, 156 res, 157 context, 158 now: 1000 + 61_000, 159 }); 160 161 const updated = await systemDb.get( 162 'SELECT expires_at, last_seen_at FROM sessions WHERE id = {id}', 163 { id: session.id } 164 ); 165 assert.equal(updated.expires_at, 1000 + 61_000 + SHORT_SESSION_TTL * 1000); 166 assert.ok(res.getHeader('set-cookie')); 167 } finally { 168 await closeRealms(); 169 await fs.rm(dir, { recursive: true, force: true }); 170 } 171 }); 172 173 test('expired sessions are rejected and their group is removed', async () => { 174 const { dir, realm, realmDb, systemDb, userId } = await setup(); 175 try { 176 const context = realmContext(realm); 177 const { token, group } = await createHostSession( 178 systemDb, 179 { context, userId, ttl: 1 }, 180 1000 181 ); 182 183 assert.equal(await getSessionByToken(systemDb, token, 3000), null); 184 await authenticateRequest({ 185 systemDb, 186 realmDb, 187 req: mockRequest(`${SESSION_COOKIE}=${token}`), 188 res: mockResponse(), 189 context, 190 now: 3000, 191 }); 192 const row = await systemDb.get('SELECT id FROM session_groups WHERE id = {id}', { 193 id: group.id, 194 }); 195 assert.equal(row, undefined); 196 } finally { 197 await closeRealms(); 198 await fs.rm(dir, { recursive: true, force: true }); 199 } 200 }); 201 202 test('disabled users lose access and their session group is removed', async () => { 203 const { dir, realm, realmDb, systemDb, userId } = await setup(); 204 try { 205 const context = realmContext(realm); 206 const { token, group } = await createHostSession(systemDb, { 207 context, 208 userId, 209 ttl: SHORT_SESSION_TTL, 210 }); 211 212 await systemDb.run('UPDATE users SET status = {status} WHERE id = {id}', { 213 status: 'disabled', 214 id: userId, 215 }); 216 assert.equal( 217 await authenticateRequest({ 218 systemDb, 219 realmDb, 220 req: mockRequest(`${SESSION_COOKIE}=${token}`), 221 res: mockResponse(), 222 context, 223 }), 224 null 225 ); 226 227 const row = await systemDb.get('SELECT id FROM session_groups WHERE id = {id}', { 228 id: group.id, 229 }); 230 assert.equal(row, undefined); 231 } finally { 232 await closeRealms(); 233 await fs.rm(dir, { recursive: true, force: true }); 234 } 235 }); 236 237 test('disabling the active account keeps other accounts in the group', async () => { 238 const { dir, realm, systemDb, userId } = await setup(); 239 try { 240 const context = realmContext(realm); 241 const first = await createHostSession(systemDb, { 242 context, 243 userId, 244 ttl: SHORT_SESSION_TTL, 245 }); 246 const otherId = await createUser(systemDb, { username: 'bob', displayName: 'Bob' }); 247 const second = await createHostSession(systemDb, { 248 context, 249 token: first.token, 250 userId: otherId, 251 ttl: SHORT_SESSION_TTL, 252 }); 253 254 await systemDb.run('UPDATE users SET status = {status} WHERE id = {id}', { 255 status: 'disabled', 256 id: otherId, 257 }); 258 259 const res = mockResponse(); 260 assert.equal( 261 await authenticateRequest({ 262 systemDb, 263 realmDb: null, 264 req: mockRequest(`${SESSION_COOKIE}=${first.token}`), 265 res, 266 context, 267 }), 268 null 269 ); 270 271 const found = await getSessionByToken(systemDb, first.token); 272 assert.equal(found.session.user_id, userId); 273 274 const accounts = await listSessionAccounts(systemDb, second.group); 275 assert.equal(accounts.length, 1); 276 assert.equal(accounts[0].user.id, userId); 277 } finally { 278 await closeRealms(); 279 await fs.rm(dir, { recursive: true, force: true }); 280 } 281 }); 282 283 test('a session is rejected on a different host scope', async () => { 284 const { dir, realm, realmDb, systemDb, userId } = await setup(); 285 try { 286 const context = realmContext(realm); 287 const { token, group } = await createHostSession(systemDb, { 288 context, 289 userId, 290 ttl: SHORT_SESSION_TTL, 291 }); 292 293 assert.equal( 294 await authenticateRequest({ 295 systemDb, 296 realmDb, 297 req: mockRequest(`${SESSION_COOKIE}=${token}`), 298 res: mockResponse(), 299 context: clientContext(realm), 300 }), 301 null 302 ); 303 304 const row = await systemDb.get('SELECT id FROM session_groups WHERE id = {id}', { 305 id: group.id, 306 }); 307 assert.equal(row.id, group.id); 308 } finally { 309 await closeRealms(); 310 await fs.rm(dir, { recursive: true, force: true }); 311 } 312 }); 313 314 test('the same user reuses a session and a different user adds an account', async () => { 315 const { dir, realm, systemDb, userId } = await setup(); 316 try { 317 const context = realmContext(realm); 318 const first = await createHostSession(systemDb, { 319 context, 320 userId, 321 ttl: SHORT_SESSION_TTL, 322 }); 323 const second = await createHostSession(systemDb, { 324 context, 325 token: first.token, 326 userId, 327 ttl: SHORT_SESSION_TTL, 328 }); 329 assert.equal(second.group.id, first.group.id); 330 assert.equal(second.token, first.token); 331 assert.equal(second.session.id, first.session.id); 332 333 const otherId = await createUser(systemDb, { username: 'bob', displayName: 'Bob' }); 334 const third = await createHostSession(systemDb, { 335 context, 336 token: first.token, 337 userId: otherId, 338 ttl: SHORT_SESSION_TTL, 339 }); 340 assert.equal(third.group.id, first.group.id); 341 assert.equal(third.token, first.token); 342 assert.equal(third.session.user_id, otherId); 343 344 const old = await systemDb.get('SELECT id FROM sessions WHERE id = {id}', { 345 id: first.session.id, 346 }); 347 assert.equal(old.id, first.session.id); 348 349 const accounts = await listSessionAccounts(systemDb, third.group); 350 assert.deepEqual( 351 accounts.map((account) => account.user.username), 352 ['alice', 'bob'] 353 ); 354 assert.equal(accounts.find((account) => account.user.id === otherId).active, true); 355 } finally { 356 await closeRealms(); 357 await fs.rm(dir, { recursive: true, force: true }); 358 } 359 }); 360 361 test('selecting an account updates the active session', async () => { 362 const { dir, realm, systemDb, userId } = await setup(); 363 try { 364 const context = realmContext(realm); 365 const first = await createHostSession(systemDb, { 366 context, 367 userId, 368 ttl: SHORT_SESSION_TTL, 369 }); 370 const otherId = await createUser(systemDb, { username: 'bob', displayName: 'Bob' }); 371 const second = await createHostSession(systemDb, { 372 context, 373 token: first.token, 374 userId: otherId, 375 ttl: SHORT_SESSION_TTL, 376 }); 377 378 const selected = await selectSessionAccount(systemDb, { 379 token: first.token, 380 context, 381 publicId: first.session.public_id, 382 }); 383 assert.equal(selected.user.id, userId); 384 assert.equal(selected.session.id, first.session.id); 385 386 const found = await getSessionByToken(systemDb, first.token); 387 assert.equal(found.session.id, first.session.id); 388 389 const rejectedHost = await selectSessionAccount(systemDb, { 390 token: first.token, 391 context: clientContext(realm), 392 publicId: second.session.public_id, 393 }); 394 assert.equal(rejectedHost, null); 395 396 const rejectedAccount = await selectSessionAccount(systemDb, { 397 token: first.token, 398 context, 399 publicId: 'missing', 400 }); 401 assert.equal(rejectedAccount, null); 402 } finally { 403 await closeRealms(); 404 await fs.rm(dir, { recursive: true, force: true }); 405 } 406 }); 407 408 test('logout deletes the active session and group', async () => { 409 const { dir, realm, systemDb, userId } = await setup(); 410 try { 411 const { token, group } = await createHostSession(systemDb, { 412 context: realmContext(realm), 413 userId, 414 ttl: SHORT_SESSION_TTL, 415 }); 416 417 const result = await destroySession(systemDb, token); 418 assert.equal(result.groupDeleted, true); 419 assert.equal(await getSessionByToken(systemDb, token), null); 420 421 const row = await systemDb.get('SELECT id FROM session_groups WHERE id = {id}', { 422 id: group.id, 423 }); 424 assert.equal(row, undefined); 425 } finally { 426 await closeRealms(); 427 await fs.rm(dir, { recursive: true, force: true }); 428 } 429 }); 430 431 test('logout keeps the group when another account remains', async () => { 432 const { dir, realm, systemDb, userId } = await setup(); 433 try { 434 const context = realmContext(realm); 435 const first = await createHostSession(systemDb, { 436 context, 437 userId, 438 ttl: SHORT_SESSION_TTL, 439 }); 440 const otherId = await createUser(systemDb, { username: 'bob', displayName: 'Bob' }); 441 const second = await createHostSession(systemDb, { 442 context, 443 token: first.token, 444 userId: otherId, 445 ttl: SHORT_SESSION_TTL, 446 }); 447 448 const result = await destroySession(systemDb, first.token); 449 assert.equal(result.groupDeleted, false); 450 451 const found = await getSessionByToken(systemDb, first.token); 452 assert.equal(found.session.id, first.session.id); 453 454 const accounts = await listSessionAccounts(systemDb, second.group); 455 assert.equal(accounts.length, 1); 456 assert.equal(accounts[0].user.id, userId); 457 } finally { 458 await closeRealms(); 459 await fs.rm(dir, { recursive: true, force: true }); 460 } 461 }); 462 463 test('realm bindings are inserted once and updated on repeat', async () => { 464 const { dir, realm, systemDb, userId } = await setup(); 465 try { 466 const { session } = await createHostSession(systemDb, { 467 context: canonicalContext(), 468 userId, 469 ttl: SHORT_SESSION_TTL, 470 }); 471 472 await bindSessionRealm(systemDb, session.id, realm.id, 1000); 473 await bindSessionRealm(systemDb, session.id, realm.id, 2000); 474 475 const rows = await systemDb.all( 476 `SELECT session_id, realm_id, last_seen_at 477 FROM session_realms 478 WHERE session_id = {sessionId}`, 479 { sessionId: session.id } 480 ); 481 assert.equal(rows.length, 1); 482 assert.equal(rows[0].last_seen_at, 2000); 483 } finally { 484 await closeRealms(); 485 await fs.rm(dir, { recursive: true, force: true }); 486 } 487 });