// Password/app-password logins are listed only while their refresh token is // still valid. listLiveSessions drops the expired ones and prunes them from // storage, so a client that re-logs in rather than refreshing cannot pile up // dead records that overstate who has access. import { describe, expect, it } from 'vitest'; import { createRefreshJwt } from '../packages/core/src/auth.js'; import { PersonalDataServer } from '../packages/core/src/pds.js'; const HOUR = 60 * 60 * 1000; const DAY = 24 * HOUR; /** * A mock session store that records which jtis were deleted. * @param {any[]} sessions */ function makeStore(sessions) { const rows = new Map(sessions.map((/** @type {any} */ s) => [s.jti, s])); /** @type {string[]} */ const deleted = []; return { deleted, storage: { putSession: async (/** @type {any} */ s) => void rows.set(s.jti, s), getSession: async (/** @type {any} */ jti) => rows.get(jti) || null, listSessions: async () => [...rows.values()], deleteSession: async (/** @type {any} */ jti) => { deleted.push(jti); rows.delete(jti); }, }, }; } /** @param {any} storage */ function makeServer(storage) { return new PersonalDataServer({ actorStorage: /** @type {any} */ ({}), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', hostname: 'pds.example.com', sharedStorage: /** @type {any} */ (storage), }); } describe('listLiveSessions', () => { it('keeps live logins and prunes expired ones', async () => { const now = Date.now(); const nowSec = Math.floor(now / 1000); const { storage, deleted } = makeStore([ // Live: stored expiry is in the future, and it wins over the legacy // window even though the row was created days ago. { jti: 'live-token', did: 'did:plc:x', createdAt: new Date(now - 5 * DAY).toISOString(), refreshedAt: null, expiresAt: nowSec + 3600, }, // Expired: stored expiry has passed, even though it was just created. { jti: 'dead-token', did: 'did:plc:x', createdAt: new Date(now).toISOString(), refreshedAt: null, expiresAt: nowSec - 100, }, // Legacy row with no expiresAt, inside the 24h window -> kept. { jti: 'legacy-fresh', did: 'did:plc:x', createdAt: new Date(now - 1 * HOUR).toISOString(), refreshedAt: null, }, // Legacy row with no expiresAt, past the 24h window -> pruned. { jti: 'legacy-stale', did: 'did:plc:x', createdAt: new Date(now - 2 * DAY).toISOString(), refreshedAt: null, }, ]); const pds = makeServer(storage); const live = await pds._sessions.listLiveSessions('did:plc:x'); expect(live.map((s) => s.jti).sort()).toEqual([ 'legacy-fresh', 'live-token', ]); expect(deleted.sort()).toEqual(['dead-token', 'legacy-stale']); }); it('is empty and prunes nothing when no session store is configured', async () => { const pds = makeServer({}); expect(await pds._sessions.listLiveSessions('did:plc:x')).toEqual([]); }); }); describe('recordSession dedup', () => { const DID = 'did:plc:x'; const req = (/** @type {string} */ ua) => new Request('https://pds.example.com/', { headers: { 'user-agent': ua }, }); const login = async ( /** @type {any} */ pds, /** @type {string} */ label, /** @type {string} */ ua, ) => { const jwt = await createRefreshJwt(DID, 'test-secret', 3600); await pds._sessions.recordSession(jwt, { did: DID, label, scope: 'atproto', request: req(ua), // Collapsing a client's prior session belongs to a login, not a rotation. dedupe: true, }); }; it('collapses re-logins from the same client but keeps distinct devices', async () => { const { storage } = makeStore([]); const pds = makeServer(storage); // Same app password, same device, three sign-ins -> one live session. await login(pds, 'App password: bot', 'bot/1.0'); await login(pds, 'App password: bot', 'bot/1.0'); await login(pds, 'App password: bot', 'bot/1.0'); expect(await pds._sessions.listLiveSessions(DID)).toHaveLength(1); // Same credential from a different device is a separate session. await login(pds, 'App password: bot', 'other-device/2.0'); expect(await pds._sessions.listLiveSessions(DID)).toHaveLength(2); // A different credential is also its own session. await login(pds, 'Account password', 'bot/1.0'); expect(await pds._sessions.listLiveSessions(DID)).toHaveLength(3); }); });