auth

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

realm.js (12608B)


      1 import fs from 'node:fs';
      2 import path from 'node:path';
      3 import crypto from 'node:crypto';
      4 import { openDatabase, runMigrations } from './db/index.js';
      5 import { MIGRATIONS_ROOT } from './db/migrate.js';
      6 import { HttpError } from './http/http-error.js';
      7 
      8 let systemDb = null;
      9 let dialect = 'sqlite';
     10 let realmsPath = null;
     11 const realmDbs = new Map();
     12 
     13 function migrationDir(dbDialect, type) {
     14   return path.join(MIGRATIONS_ROOT, dbDialect, type);
     15 }
     16 
     17 async function migrateRealm(db, label) {
     18   return runMigrations(db, {
     19     dir: migrationDir(db.dialect, 'realm'),
     20     namespace: 'realm',
     21     label,
     22   });
     23 }
     24 
     25 async function migrateSystem(db) {
     26   return runMigrations(db, {
     27     dir: migrationDir(db.dialect, 'system'),
     28     namespace: 'system',
     29     label: 'system',
     30   });
     31 }
     32 
     33 function parseBranding(raw) {
     34   try {
     35     const parsed = JSON.parse(raw || '{}');
     36     return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
     37   } catch {
     38     return {};
     39   }
     40 }
     41 
     42 async function ensureRole(db, slug, name, now) {
     43   const existing = await db.get('SELECT id FROM roles WHERE slug = {slug}', { slug });
     44   if (existing) return existing.id;
     45   const id = crypto.randomUUID();
     46   await db.run(
     47     'INSERT INTO roles (id, slug, name, created_at) VALUES ({id}, {slug}, {name}, {createdAt})',
     48     { id, slug, name, createdAt: now }
     49   );
     50   return id;
     51 }
     52 
     53 async function seedRealmDefaults(db) {
     54   const now = Date.now();
     55   await ensureRole(db, 'realm-admin', 'Realm administrator', now);
     56 }
     57 
     58 async function writeRealmSettings(db, settings) {
     59   const now = Date.now();
     60   await db.run(
     61     `INSERT INTO realm_settings
     62       (id, slug, display_name, kind, status, branding, allow_session_duration_choice, created_at, updated_at)
     63      VALUES
     64       (1, {slug}, {displayName}, {kind}, 'active', {branding}, {allowChoice}, {createdAt}, {updatedAt})`,
     65     {
     66       slug: settings.slug,
     67       displayName: settings.displayName,
     68       kind: settings.kind || 'realm',
     69       branding: JSON.stringify(settings.branding || {}),
     70       allowChoice: settings.allowSessionDurationChoice === false ? 0 : 1,
     71       createdAt: now,
     72       updatedAt: now,
     73     }
     74   );
     75 }
     76 
     77 export function normalizeHost(host) {
     78   let value = String(host || '').trim().toLowerCase();
     79   if (!value) return '';
     80   if (value.startsWith('[')) {
     81     const end = value.indexOf(']');
     82     if (end !== -1) value = value.slice(0, end + 1);
     83   } else {
     84     const colon = value.indexOf(':');
     85     if (colon !== -1) value = value.slice(0, colon);
     86   }
     87   return value.replace(/\.$/, '');
     88 }
     89 
     90 export function normalizeEmailDomain(domain) {
     91   const value = String(domain || '').trim().toLowerCase().replace(/\.$/, '');
     92   if (!value || value.includes('@')) {
     93     throw new Error(`invalid email domain: ${JSON.stringify(domain)}`);
     94   }
     95   return value;
     96 }
     97 
     98 export async function initRealms(cfg) {
     99   await closeRealms();
    100 
    101   dialect = cfg.database.dialect;
    102   realmsPath = path.resolve(cfg.database.realms);
    103   const systemPath = path.resolve(cfg.database.path);
    104 
    105   fs.mkdirSync(realmsPath, { recursive: true });
    106   fs.mkdirSync(path.dirname(systemPath), { recursive: true });
    107 
    108   systemDb = await openDatabase({ dialect, path: systemPath });
    109   await migrateSystem(systemDb);
    110 }
    111 
    112 export async function closeRealms() {
    113   for (const db of realmDbs.values()) {
    114     await db.close();
    115   }
    116   realmDbs.clear();
    117 
    118   if (systemDb) {
    119     await systemDb.close();
    120     systemDb = null;
    121   }
    122 }
    123 
    124 export function getSystemDatabase() {
    125   if (!systemDb) throw new Error('realm registry is not initialized');
    126   return systemDb;
    127 }
    128 
    129 export async function openRealmDatabase(realm) {
    130   const cached = realmDbs.get(realm.id);
    131   if (cached) return cached;
    132 
    133   const fullPath = path.resolve(realmsPath, realm.db_file);
    134   if (path.dirname(fullPath) !== realmsPath) {
    135     throw new HttpError(404, 'unknown_realm', 'Unknown realm');
    136   }
    137   if (!fs.existsSync(fullPath)) {
    138     throw new HttpError(404, 'unknown_realm', 'Unknown realm');
    139   }
    140 
    141   const db = await openDatabase({ dialect, path: fullPath });
    142   await migrateRealm(db, `realm ${realm.slug}`);
    143   await seedRealmDefaults(db);
    144   realmDbs.set(realm.id, db);
    145   return db;
    146 }
    147 
    148 export async function realmMetadata(realm) {
    149   const db = await openRealmDatabase(realm);
    150   const settings = await db.get(
    151     `SELECT slug, display_name, status, branding, allow_session_duration_choice
    152        FROM realm_settings
    153       WHERE id = 1`
    154   );
    155 
    156   if (!settings || settings.status !== 'active') {
    157     throw new HttpError(404, 'unknown_realm', 'Unknown realm');
    158   }
    159 
    160   return {
    161     slug: settings.slug,
    162     display_name: settings.display_name,
    163     branding: parseBranding(settings.branding),
    164     allow_session_duration_choice: Boolean(settings.allow_session_duration_choice),
    165   };
    166 }
    167 
    168 export async function resolveRealm({ host, slug } = {}) {
    169   if (!systemDb) throw new Error('realm registry is not initialized');
    170 
    171   const normalizedHost = normalizeHost(host);
    172   if (normalizedHost) {
    173     const byClientHost = await systemDb.get(
    174       `SELECT r.id, r.slug, r.db_file, r.owner_user_id
    175          FROM oidc_client_hosts h
    176          JOIN client_realms cr ON cr.client_id = h.client_id
    177          JOIN realms r ON r.id = cr.realm_id
    178         WHERE h.host = {host}`,
    179       { host: normalizedHost }
    180     );
    181     if (byClientHost) {
    182       if (slug && slug !== byClientHost.slug) {
    183         throw new HttpError(404, 'unknown_realm', 'Unknown realm');
    184       }
    185       return byClientHost;
    186     }
    187 
    188     const byHost = await systemDb.get(
    189       `SELECT r.id, r.slug, r.db_file, r.owner_user_id
    190          FROM realm_hosts h
    191          JOIN realms r ON r.id = h.realm_id
    192         WHERE h.host = {host}`,
    193       { host: normalizedHost }
    194     );
    195     if (byHost) {
    196       if (slug && slug !== byHost.slug) {
    197         throw new HttpError(404, 'unknown_realm', 'Unknown realm');
    198       }
    199       return byHost;
    200     }
    201   }
    202 
    203   if (slug) {
    204     const bySlug = await systemDb.get(
    205       'SELECT id, slug, db_file, owner_user_id FROM realms WHERE slug = {slug}',
    206       { slug }
    207     );
    208     if (bySlug) return bySlug;
    209   }
    210 
    211   throw new HttpError(404, 'unknown_realm', 'Unknown realm');
    212 }
    213 
    214 export async function createRealm({
    215   slug,
    216   displayName,
    217   branding = {},
    218   hosts = [],
    219   ownerUserId = null,
    220   allowSessionDurationChoice = true,
    221 }) {
    222   if (!systemDb) throw new Error('realm registry is not initialized');
    223   if (!/^[a-z0-9][a-z0-9-]*$/.test(slug || '')) {
    224     throw new Error(`invalid realm slug: ${JSON.stringify(slug)}`);
    225   }
    226 
    227   const existing = await systemDb.get('SELECT id FROM realms WHERE slug = {slug}', { slug });
    228   if (existing) throw new Error(`realm already exists: ${slug}`);
    229 
    230   const id = crypto.randomUUID();
    231   const dbFile = `${id}.sqlite`;
    232   const fullPath = path.join(realmsPath, dbFile);
    233 
    234   const db = await openDatabase({ dialect, path: fullPath });
    235   try {
    236     await migrateRealm(db, `realm ${slug}`);
    237     await writeRealmSettings(db, {
    238       slug,
    239       displayName,
    240       kind: 'realm',
    241       branding,
    242       allowSessionDurationChoice,
    243     });
    244     await seedRealmDefaults(db);
    245   } catch (e) {
    246     await db.close();
    247     fs.rmSync(fullPath, { force: true });
    248     throw e;
    249   }
    250 
    251   try {
    252     await systemDb.run(
    253       `INSERT INTO realms (id, slug, db_file, owner_user_id, created_at)
    254        VALUES ({id}, {slug}, {dbFile}, {ownerUserId}, {createdAt})`,
    255       { id, slug, dbFile, ownerUserId, createdAt: Date.now() }
    256     );
    257 
    258     for (const host of hosts) {
    259       await addRealmHost(id, host);
    260     }
    261   } catch (e) {
    262     await systemDb.run('DELETE FROM realms WHERE id = {id}', { id });
    263     await db.close();
    264     fs.rmSync(fullPath, { force: true });
    265     throw e;
    266   }
    267 
    268   realmDbs.set(id, db);
    269   return { id, slug, db_file: dbFile, owner_user_id: ownerUserId };
    270 }
    271 
    272 export async function addRealmHost(realmId, host) {
    273   if (!systemDb) throw new Error('realm registry is not initialized');
    274   const normalized = normalizeHost(host);
    275   if (!normalized || normalized !== String(host).trim().toLowerCase()) {
    276     throw new Error(`invalid realm hostname: ${JSON.stringify(host)}`);
    277   }
    278   await systemDb.run(
    279     'INSERT INTO realm_hosts (host, realm_id, created_at) VALUES ({host}, {realmId}, {createdAt})',
    280     { host: normalized, realmId, createdAt: Date.now() }
    281   );
    282 }
    283 
    284 export async function addClientRealm(clientId, realmId) {
    285   if (!systemDb) throw new Error('realm registry is not initialized');
    286   const value = String(clientId || '').trim();
    287   if (!/^[a-z0-9][a-z0-9-]*$/.test(value)) {
    288     throw new Error(`invalid client id: ${JSON.stringify(clientId)}`);
    289   }
    290   const existing = await findClientRealm(value);
    291   if (existing) {
    292     if (existing.realm_id === realmId) return existing;
    293     throw new Error(`client id is already assigned: ${value}`);
    294   }
    295   await systemDb.run(
    296     'INSERT INTO client_realms (client_id, realm_id, created_at) VALUES ({clientId}, {realmId}, {createdAt})',
    297     { clientId: value, realmId, createdAt: Date.now() }
    298   );
    299   return findClientRealm(value);
    300 }
    301 
    302 export async function findClientRealm(clientId) {
    303   if (!systemDb) throw new Error('realm registry is not initialized');
    304   return systemDb.get(
    305     `SELECT cr.client_id, cr.realm_id, r.slug, r.db_file, r.owner_user_id
    306        FROM client_realms cr
    307        LEFT JOIN realms r ON r.id = cr.realm_id
    308       WHERE cr.client_id = {clientId}`,
    309     { clientId }
    310   );
    311 }
    312 
    313 export async function addOidcClientHost(clientId, host) {
    314   if (!systemDb) throw new Error('realm registry is not initialized');
    315   const normalized = normalizeHost(host);
    316   if (!normalized || normalized !== String(host).trim().toLowerCase()) {
    317     throw new Error(`invalid client hostname: ${JSON.stringify(host)}`);
    318   }
    319   const mapping = await findClientRealm(clientId);
    320   if (!mapping || !mapping.realm_id) {
    321     throw new Error(`unknown client: ${clientId}`);
    322   }
    323   await systemDb.run(
    324     `INSERT INTO oidc_client_hosts (host, client_id, created_at)
    325      VALUES ({host}, {clientId}, {createdAt})`,
    326     { host: normalized, clientId, createdAt: Date.now() }
    327   );
    328 }
    329 
    330 export async function findOidcClientHost(host) {
    331   if (!systemDb) throw new Error('realm registry is not initialized');
    332   const normalized = normalizeHost(host);
    333   if (!normalized) return null;
    334   return systemDb.get(
    335     `SELECT h.host, h.client_id, cr.realm_id, r.slug, r.db_file, r.owner_user_id
    336        FROM oidc_client_hosts h
    337        JOIN client_realms cr ON cr.client_id = h.client_id
    338        JOIN realms r ON r.id = cr.realm_id
    339       WHERE h.host = {host}`,
    340     { host: normalized }
    341   );
    342 }
    343 
    344 export async function findRealmHost(host) {
    345   if (!systemDb) throw new Error('realm registry is not initialized');
    346   const normalized = normalizeHost(host);
    347   if (!normalized) return null;
    348   return systemDb.get(
    349     `SELECT r.id, r.slug, r.db_file, r.owner_user_id
    350        FROM realm_hosts h
    351        JOIN realms r ON r.id = h.realm_id
    352       WHERE h.host = {host}`,
    353     { host: normalized }
    354   );
    355 }
    356 
    357 export async function addRealmEmailDomain(realmId, domain, { verified = true } = {}) {
    358   if (!systemDb) throw new Error('realm registry is not initialized');
    359   const normalized = normalizeEmailDomain(domain);
    360   const existing = await systemDb.get(
    361     'SELECT domain FROM realm_email_domains WHERE domain = {domain}',
    362     { domain: normalized }
    363   );
    364   if (existing) throw new Error(`email domain is already assigned: ${normalized}`);
    365   const now = Date.now();
    366   await systemDb.run(
    367     `INSERT INTO realm_email_domains
    368       (domain, realm_id, verified, verification_token, verified_at, created_at)
    369      VALUES
    370       ({domain}, {realmId}, {verified}, {token}, {verifiedAt}, {createdAt})`,
    371     {
    372       domain: normalized,
    373       realmId,
    374       verified: verified ? 1 : 0,
    375       token: verified ? null : crypto.randomBytes(24).toString('base64url'),
    376       verifiedAt: verified ? now : null,
    377       createdAt: now,
    378     }
    379   );
    380 }
    381 
    382 export async function findRealmByEmailDomain(domain) {
    383   if (!systemDb) throw new Error('realm registry is not initialized');
    384   const normalized = normalizeEmailDomain(domain);
    385   return systemDb.get(
    386     `SELECT r.id, r.slug, r.db_file, r.owner_user_id
    387        FROM realm_email_domains d
    388        JOIN realms r ON r.id = d.realm_id
    389       WHERE d.domain = {domain} AND d.verified = 1`,
    390     { domain: normalized }
    391   );
    392 }
    393 
    394 export async function findRealmBySlug(slug) {
    395   if (!systemDb) throw new Error('realm registry is not initialized');
    396   return systemDb.get(
    397     'SELECT id, slug, db_file, owner_user_id FROM realms WHERE slug = {slug}',
    398     { slug }
    399   );
    400 }
    401 
    402 export async function findRealmById(id) {
    403   if (!systemDb) throw new Error('realm registry is not initialized');
    404   return systemDb.get(
    405     'SELECT id, slug, db_file, owner_user_id FROM realms WHERE id = {id}',
    406     { id }
    407   );
    408 }