conductor

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

index.js (3104B)


      1 // src/lib/auth/index.js - request authentication
      2 //
      3 // One provider at a time: the external one when auth.oidc.discovery_url is
      4 // configured, the built-in one otherwise. Access tokens arrive as bearer
      5 // credentials and nothing is stored between requests.
      6 
      7 import { signToken, verifyToken } from './token.js';
      8 import { createOidc } from './oidc.js';
      9 
     10 export { signToken, verifyToken } from './token.js';
     11 export { hashPassword, verifyPassword, generatePassword, validatePassword } from './password.js';
     12 
     13 // The built-in provider has no registration step, so the app is named here.
     14 export const LOCAL_CLIENT_ID = 'conductor';
     15 
     16 export function createAuth({ cfg, users, logger = console }) {
     17   const oidc = cfg.auth.mode === 'oidc' ? createOidc(cfg) : null;
     18   const localLogin = !oidc;
     19   const issuer = cfg.server.public_url.replace(/\/+$/, '');
     20   const clientId = oidc ? cfg.auth.oidc.client_id : LOCAL_CLIENT_ID;
     21 
     22   // A worker token is 64 hex characters and never parses as a JWT, so the
     23   // two meanings of this header cannot be confused.
     24   function bearerFrom(req) {
     25     const header = req.headers.authorization || '';
     26     return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
     27   }
     28 
     29   async function verifyAccessToken(token) {
     30     if (oidc) {
     31       const profile = await oidc.verify(token);
     32 
     33       // The provider vouched for them, but they still need a local row to
     34       // own anything, since projects and worker tokens reference users(id).
     35       const account = await users.upsertExternal({
     36         externalId: `${profile.issuer}|${profile.subject}`,
     37         username: profile.username,
     38         role: profile.role,
     39       });
     40       if (account.disabled === 1) return null;
     41       return { id: account.id, username: account.username, role: account.role, source: 'oidc' };
     42     }
     43 
     44     const claims = verifyToken(cfg.auth.session_secret, token, {
     45       use: 'access',
     46       iss: issuer,
     47       aud: clientId,
     48     });
     49     if (!claims) return null;
     50 
     51     // Re-read the account, so disabling a user takes effect immediately
     52     // rather than when their token happens to expire.
     53     const user = await users.get(claims.sub);
     54     if (!user || user.disabled === 1) return null;
     55 
     56     return { id: user.id, username: user.username, role: user.role, source: 'local' };
     57   }
     58 
     59   return {
     60     mode: cfg.auth.mode,
     61     localLogin,
     62     oidc,
     63     issuer,
     64     clientId,
     65 
     66     accessToken(user, { now = Date.now() } = {}) {
     67       return signToken(cfg.auth.session_secret, {
     68         use: 'access',
     69         iss: issuer,
     70         aud: clientId,
     71         sub: user.id,
     72         name: user.username,
     73         role: user.role,
     74         ttl: cfg.auth.access_ttl,
     75         now,
     76       });
     77     },
     78 
     79     verifyAccessToken,
     80 
     81     // Resolves a request to a user, or null. Never throws: an unparseable
     82     // credential is simply not authenticated.
     83     async identify(req) {
     84       const token = bearerFrom(req);
     85       if (!token) return null;
     86       try {
     87         return await verifyAccessToken(token);
     88       } catch (e) {
     89         logger.debug?.(`access token rejected: ${e.message}`);
     90         return null;
     91       }
     92     },
     93   };
     94 }