conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

token.js (2986B)


      1 // src/lib/auth/token.js - session tokens
      2 //
      3 // Compact HS256 tokens in the JWT shape, so they can be inspected with any
      4 // ordinary tool, signed with node:crypto rather than a library. The jose
      5 // dependency exists for OIDC, where the token is issued elsewhere and the
      6 // verification rules genuinely are complicated. Signing our own does not
      7 // need it.
      8 //
      9 // A token carries only the subject, name and role. Anything more would go
     10 // stale, since it is not re-read from the database on every request.
     11 
     12 import crypto from 'node:crypto';
     13 
     14 const HEADER = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
     15 
     16 function sign(secret, data) {
     17   return crypto.createHmac('sha256', secret).update(data).digest('base64url');
     18 }
     19 
     20 export function issueToken(secret, { sub, name, role }, { ttl = 43200, now = Date.now() } = {}) {
     21   const issued = Math.floor(now / 1000);
     22   const payload = Buffer.from(JSON.stringify({
     23     sub,
     24     name,
     25     role,
     26     iat: issued,
     27     exp: issued + ttl,
     28   })).toString('base64url');
     29 
     30   const body = `${HEADER}.${payload}`;
     31   return `${body}.${sign(secret, body)}`;
     32 }
     33 
     34 // Returns the claims, or null. Never throws, so a malformed token from a
     35 // browser is an ordinary 401 rather than a 500.
     36 export function verifyToken(secret, token, { now = Date.now() } = {}) {
     37   if (typeof token !== 'string') return null;
     38 
     39   const parts = token.split('.');
     40   if (parts.length !== 3) return null;
     41   const [header, payload, signature] = parts;
     42 
     43   const expected = sign(secret, `${header}.${payload}`);
     44   const given = Buffer.from(signature);
     45   const want = Buffer.from(expected);
     46   if (given.length !== want.length || !crypto.timingSafeEqual(given, want)) return null;
     47 
     48   let claims;
     49   try {
     50     claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
     51   } catch {
     52     return null;
     53   }
     54 
     55   if (typeof claims !== 'object' || claims === null) return null;
     56   if (typeof claims.exp !== 'number' || claims.exp * 1000 <= now) return null;
     57 
     58   return claims;
     59 }
     60 
     61 export const SESSION_COOKIE = 'conductor_session';
     62 
     63 // Minimal cookie parsing, to avoid a dependency for one header.
     64 export function parseCookies(header) {
     65   const out = {};
     66   if (typeof header !== 'string') return out;
     67   for (const part of header.split(';')) {
     68     const index = part.indexOf('=');
     69     if (index === -1) continue;
     70     const key = part.slice(0, index).trim();
     71     if (!key) continue;
     72     try {
     73       out[key] = decodeURIComponent(part.slice(index + 1).trim());
     74     } catch {
     75       out[key] = part.slice(index + 1).trim();
     76     }
     77   }
     78   return out;
     79 }
     80 
     81 export function sessionCookie(token, { ttl = 43200, secure = false } = {}) {
     82   const attributes = [
     83     `${SESSION_COOKIE}=${encodeURIComponent(token)}`,
     84     'Path=/',
     85     'HttpOnly',
     86     'SameSite=Lax',
     87     `Max-Age=${ttl}`,
     88   ];
     89   if (secure) attributes.push('Secure');
     90   return attributes.join('; ');
     91 }
     92 
     93 export function clearedCookie() {
     94   return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
     95 }