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 }