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 }