auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

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 }