conductor

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

state.js (1986B)


      1 // src/lib/auth/state.js - the OIDC login state
      2 //
      3 // A short signed value that travels to the provider as the state parameter
      4 // and comes back on the callback. It carries when the login started and,
      5 // when there is somewhere to return to, that path. It is verified on the
      6 // way back, so nothing has to be stored between the two requests.
      7 //
      8 //   body = base64url(JSON.stringify({ iat, url }))
      9 //   sign = base64url(hmacSha256(secret, body))
     10 //   state = body + "." + sign
     11 
     12 import crypto from 'node:crypto';
     13 
     14 const MAX_AGE = 300;
     15 
     16 function sign(secret, body) {
     17   return crypto.createHmac('sha256', secret).update(body).digest('base64url');
     18 }
     19 
     20 export function createState(secret, url, { now = Date.now() } = {}) {
     21   const claims = { iat: Math.floor(now / 1000) };
     22   if (url) claims.url = url;
     23   const body = Buffer.from(JSON.stringify(claims)).toString('base64url');
     24   return `${body}.${sign(secret, body)}`;
     25 }
     26 
     27 // Returns { iat, url }, or null when the value is malformed, forged or
     28 // older than five minutes. A missing url means the root.
     29 export function verifyState(secret, state, { now = Date.now() } = {}) {
     30   if (typeof state !== 'string') return null;
     31 
     32   const [body, given, ...rest] = state.split('.');
     33   if (!body || !given || rest.length > 0) return null;
     34 
     35   const expected = sign(secret, body);
     36   const a = Buffer.from(given);
     37   const b = Buffer.from(expected);
     38   if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
     39 
     40   let claims;
     41   try {
     42     claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
     43   } catch {
     44     return null;
     45   }
     46 
     47   const seconds = Math.floor(now / 1000);
     48   if (!Number.isInteger(claims?.iat) || claims.iat > seconds || claims.iat < seconds - MAX_AGE) return null;
     49 
     50   const url = claims.url === undefined
     51     ? '/'
     52     : typeof claims.url === 'string' && claims.url.startsWith('/') && !claims.url.startsWith('//')
     53       ? claims.url
     54       : null;
     55   if (url === null) return null;
     56 
     57   return { iat: claims.iat, url };
     58 }