// services/auth/src/index.js // ------------------------------------------------------------ // SuiteCoffee — Servicio de Autenticación (Express + OIDC) // - ESM compatible (Node >=18) // - Sesiones con Redis (compartibles con otros servicios) // - Vistas EJS (login) // - Rutas OIDC: /auth/login, /auth/callback, /auth/logout // - Registro de usuario: /auth/api/users/register (DB + Authentik) // ------------------------------------------------------------ import 'dotenv/config'; import chalk from 'chalk'; import express from 'express'; import cors from 'cors'; import path from 'node:path'; import { access, readFile } from 'node:fs/promises'; import { constants as fsConstants } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { Pool } from 'pg'; import session from 'express-session'; import { createClient } from 'redis'; import expressLayouts from 'express-ejs-layouts'; import { Issuer, generators } from 'openid-client'; import crypto from 'node:crypto'; // ----------------------------------------------------------------------------- // Variables globales // ----------------------------------------------------------------------------- let oidcClient; let _cachedInitSql = null; // ----------------------------------------------------------------------------- // Importaciones desde archivos // ----------------------------------------------------------------------------- // Helpers de Authentik (admin API) import { akFindUserByEmail, akCreateUser, akSetPassword, akResolveGroupId } from './ak.js'; // ----------------------------------------------------------------------------- // Utilidades / Helpers // ----------------------------------------------------------------------------- const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Normaliza UUID (acepta con/sin guiones) → "hex" sin guiones const cleanUuid = (u) => (u ? String(u).toLowerCase().replace(/[^0-9a-f]/g, '') : ''); // Nombre de schema/rol a partir de uuid limpio const schemaNameFor = (uuidHex) => `schema_tenant_${uuidHex}`; const roleNameFor = (uuidHex) => `tenant_${uuidHex}`; // Quoter seguro de identificadores SQL (roles, schemas, tablas) // Identificador SQL (schema, role, table, …) const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`; // Literal de texto SQL (valores: contraseñas, strings, …) const qs = (val) => `'${String(val).replace(/'/g, "''")}'`; const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_:$-]*$/; // Helpers defensivos const extractAkUserUuid = (u) => (u && (u.uuid || u?.user?.uuid || (Array.isArray(u.results) && u.results[0]?.uuid))) || null; const extractAkUserPk = (u) => (u && (u.pk ?? u?.user?.pk ?? null)); // --- Resolver y cachear el grupo por ID/UUID/NOMBRE una sola vez --- let DEFAULT_GROUP_ID = process.env.AUTHENTIK_DEFAULT_GROUP_ID ? Number(process.env.AUTHENTIK_DEFAULT_GROUP_ID) : null; if (!DEFAULT_GROUP_ID) { (async () => { try { // Si tenés akResolveGroupIdByName, usalo: // DEFAULT_GROUP_ID = await akResolveGroupIdByName(process.env.AUTHENTIK_DEFAULT_GROUP_NAME); // Con el helper genérico que te dejé en ak.js: DEFAULT_GROUP_ID = await akResolveGroupId({ uuid: process.env.AUTHENTIK_DEFAULT_GROUP_UUID, name: process.env.AUTHENTIK_DEFAULT_GROUP_NAME, }); console.log('[AK] DEFAULT_GROUP_ID resuelto:', DEFAULT_GROUP_ID); } catch (e) { console.warn('[AK] No se pudo resolver DEFAULT_GROUP_ID:', e?.message || e); } })(); } function nukeSession(req, res, redirectTo = '/auth/login', reason = 'reset') { try { // Destruye la sesión en el store (Redis) req.session?.destroy(() => { // Limpia la cookie en el navegador res.clearCookie(SESSION_COOKIE_NAME, { path: '/', httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', }); // Reinicia el flujo return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`); }); } catch { // Si algo falla, al menos intentamos redirigir return res.redirect(303, `${redirectTo}?reason=${encodeURIComponent(reason)}`); } } // Verificar existencia del tenant sin crear (en la DB de tenants) async function tenantExists(uuidHex) { if (!uuidHex) return false; const schema = schemaNameFor(uuidHex); const client = await tenantsPool.connect(); try { const q = await client.query( 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1', [schema] ); return q.rowCount > 0; } finally { client.release(); } } // Intenta obtener el tenant por orden: // 1) DB principal (app_user por email) // 2) Authentik (attributes.tenant_uuid del usuario) // 3) valor provisto en el request (si viene) async function resolveExistingTenantUuid({ email, requestedTenantUuid }) { const normEmail = String(email).trim().toLowerCase(); // 1) DB principal const dbRes = await mainPool.query( 'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', [normEmail] ); if (dbRes.rowCount) { const fromDb = cleanUuid(dbRes.rows[0].tenant_uuid); if (fromDb) return fromDb; } // 2) Authentik const akUser = await akFindUserByEmail(normEmail).catch(() => null); const fromAk = cleanUuid(akUser?.attributes?.tenant_uuid); if (fromAk) return fromAk; // 3) Pedido del request const fromReq = cleanUuid(requestedTenantUuid); if (fromReq) return fromReq; return null; // no hay tenant conocido } // Helper para crear tenant si falta async function ensureTenant({ tenant_uuid }) { const admin = await tenantsPool.connect(); try { await admin.query('BEGIN'); // uuid y nombres const uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase(); const hex = uuid.replace(/-/g, ''); if (!/^[a-f0-9]{32}$/.test(hex)) throw new Error('tenant_uuid inválido'); const schema = `schema_tenant_${hex}`; const role = `tenant_${hex}`; const pwd = crypto.randomBytes(18).toString('base64url'); if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) { throw new Error('Identificador de schema/rol inválido'); } // 1) Crear ROL si no existe (PASSWORD debe ser LITERAL, no parámetro) const r = await admin.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [role]); if (!r.rowCount) { await admin.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`); // Si quisieras rotarla luego: // await admin.query(`ALTER ROLE ${qi(role)} PASSWORD ${qs(pwd)}`); } // 2) Crear SCHEMA si no existe y asignar owner const s = await admin.query( 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1', [schema] ); if (!s.rowCount) { await admin.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`); } else { await admin.query(`ALTER SCHEMA ${qi(schema)} OWNER TO ${qi(role)}`); } // 3) Permisos por defecto await admin.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`); await admin.query( `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}` ); await admin.query( `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}` ); // 4) Aplicar 01_init.sql en la misma transacción const initSql = await loadInitSql(); // tu caché/loader actual if (initSql && initSql.trim()) { await admin.query(`SET LOCAL search_path TO ${qi(schema)}, public`); await admin.query(initSql); } await admin.query('COMMIT'); return { tenant_uuid: uuid, schema, role, role_password: pwd }; } catch (e) { try { await admin.query('ROLLBACK'); } catch {} throw e; } finally { admin.release(); } } // Carga el 01_init.sql del disco, elimina BEGIN/COMMIT y sustituye el schema. requiredEnv(['TENANT_INIT_SQL']); async function loadInitSql() { if (_cachedInitSql !== null) return _cachedInitSql; const raw = String(process.env.TENANT_INIT_SQL || '').trim(); if (!raw) return (_cachedInitSql = ''); if (raw.startsWith('base64:')) { _cachedInitSql = Buffer.from(raw.slice(7), 'base64').toString('utf8'); return _cachedInitSql; } const v = raw.startsWith('@') ? raw.slice(1) : raw; // absoluta o relativa al CWD (/app) const candidates = path.isAbsolute(v) ? [v] : [path.resolve(process.cwd(), v), path.resolve('.', v)]; for (const c of candidates) { try { await access(c, fsConstants.R_OK); _cachedInitSql = await readFile(c, 'utf8'); return _cachedInitSql; } catch {} } // Si no parecía ruta, trátalo como SQL inline _cachedInitSql = v; return _cachedInitSql; } async function isSchemaEmpty(client, schema) { const { rows } = await client.query( `SELECT COUNT(*)::int AS c FROM information_schema.tables WHERE table_schema = $1`, [schema] ); return rows[0].c === 0; } /** Ejecuta 01_init.sql para un tenant (solo si el esquema está vacío). */ async function initializeTenantSchemaIfEmpty(schema) { const sql = await loadInitSql(); if (!sql || !sql.trim()) { console.warn(`[TENANT INIT] Esquema ${schema}: 01_init.sql vacío/no disponible. Salteando.`); return; } const client = await tenantsPool.connect(); try { // No usamos LOCAL: queremos que el search_path persista en esta conexión mientras dura el script await client.query('BEGIN'); await client.query(`SET search_path TO ${qi(schema)}, public`); const empty = await isSchemaEmpty(client, schema); if (!empty) { await client.query('ROLLBACK'); console.log(`[TENANT INIT] Esquema ${schema}: ya tiene tablas. No se aplica 01_init.sql.`); return; } await client.query(sql); // acepta múltiples sentencias separadas por ';' await client.query('COMMIT'); console.log(`[TENANT INIT] Esquema ${schema}: 01_init.sql aplicado.`); } catch (e) { try { await client.query('ROLLBACK'); } catch {} console.error(`[TENANT INIT] Error aplicando 01_init.sql sobre ${schema}:`, e.message); throw e; } finally { client.release(); } } // ----------------------------------------------------------------------------- // Utilidades // ----------------------------------------------------------------------------- const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); function requiredEnv(keys) { const missing = keys.filter((k) => !process.env[k]); if (missing.length) { console.warn(chalk.yellow(`⚠ Falta configurar variables de entorno: ${missing.join(', ')}`)); } } function onFatal(err, msg = 'Error fatal') { console.error(chalk.red(`\n${msg}:`)); console.error(err); process.exit(1); } function genTempPassword(len = 12) { const base = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@$%'; let out = ''; for (let i = 0; i < len; i++) out += base[Math.floor(Math.random() * base.length)]; return out; } // ----------------------------------------------------------------------------- // Configuración Express // ----------------------------------------------------------------------------- const app = express(); app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2)); app.use(cors({ origin: true, credentials: true })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Vistas EJS app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); // Archivos estáticos opcionales (ajusta si tu estructura difiere) app.use(express.static(path.join(__dirname, 'public'))); app.use('/pages', express.static(path.join(__dirname, 'pages'))); // ----------------------------------------------------------------------------- // Sesión (Redis) — misma cookie que APP // ----------------------------------------------------------------------------- requiredEnv(['SESSION_SECRET', 'REDIS_URL']); const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "sc.sid"; const SESSION_SECRET = process.env.SESSION_SECRET || "pon-una-clave-larga-y-unica"; const REDIS_URL = process.env.REDIS_URL || "redis://authentik-redis:6379"; // 1) Redis client const redis = createClient({ url: REDIS_URL /*, legacyMode: true */ }); redis.on("error", (err) => console.error("[Redis] Client Error:", err)); await redis.connect(); console.log("[Redis] connected"); // 2) Resolver RedisStore (soporta: // - v5: factory CJS -> connectRedis(session) // - v6/v7: export { RedisStore } ó export default class RedisStore) async function resolveRedisStore(session) { const mod = await import("connect-redis"); // ESM/CJS agnóstico // named export (v6/v7) if (typeof mod.RedisStore === "function") return mod.RedisStore; // default export (class ó factory) if (typeof mod.default === "function") { // ¿es clase neweable? if (mod.default.prototype && (mod.default.prototype.get || mod.default.prototype.set)) { return mod.default; // class RedisStore } // si no, asumimos factory antigua const Store = mod.default(session); // connectRedis(session) if (typeof Store === "function") return Store; // class devuelta por factory } // algunos builds CJS exponen la factory bajo mod (poco común) if (typeof mod === "function") { const Store = mod(session); if (typeof Store === "function") return Store; } throw new Error("connect-redis: no se pudo resolver RedisStore (API desconocida)."); } const RedisStore = await resolveRedisStore(session); // 3) Session middleware app.use(session({ name: SESSION_COOKIE_NAME, secret: SESSION_SECRET, resave: false, saveUninitialized: false, store: new RedisStore({ client: redis, prefix: "sc:", // opcional }), proxy: true, cookie: { secure: "auto", httpOnly: true, sameSite: "lax", path: "/", // ¡crítico! visible en / y /auth/* }, })); // Exponer usuario a las vistas (no tocar req.session) app.use((req, res, next) => { res.locals.user = req.session?.user || null; next(); }); // ----------------------------------------------------------------------------- // PostgreSQL — DB tenants (usuarios de suitecoffee) // ----------------------------------------------------------------------------- const tenantsPool = new Pool({ host: process.env.TENANTS_HOST || 'dev-tenants', port: Number(process.env.TENANTS_PORT || 5432), user: process.env.TENANTS_USER || 'dev-user-postgres', password: process.env.TENANTS_PASS || 'dev-pass-postgres', database: process.env.TENANTS_DB || 'dev-postgres', max: 10, }); // ----------------------------------------------------------------------------- // PostgreSQL — DB principal (metadatos de negocio) // ----------------------------------------------------------------------------- requiredEnv(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']); const mainPool = new Pool({ host: process.env.DB_HOST || 'dev-db', port: Number(process.env.DB_PORT || 5432), user: process.env.DB_USER || 'dev-user-suitecoffee', password: process.env.DB_PASS || 'dev-pass-suitecoffee', database: process.env.DB_NAME || 'dev-suitecoffee', max: 10, idleTimeoutMillis: 30_000, }); // ---------------------------------------------------------- // Verificación de conexión // ---------------------------------------------------------- async function verificarConexion() { try { console.log(`[AUTH] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`); const client = await mainPool.connect(); const { rows } = await client.query('SELECT NOW() AS ahora'); console.log(`\n[AUTH] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora); client.release(); } catch (error) { console.error('[AUTH] Error al conectar con la base de datos al iniciar:', error.message); console.error('[AUTH] Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.'); } } // ----------------------------------------------------------------------------- // OIDC (Authentik) — discovery + cliente // ----------------------------------------------------------------------------- requiredEnv(['OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI']); async function OIDCdiscover(){ let RETRY_MS = 10_000; let _retryTimer = null; try { const issuer = await discoverOIDCWithRetry(process.env.OIDC_ISSUER, { retries: 60, delayMs: 2000 }); oidcClient = new issuer.Client({ client_id: process.env.OIDC_CLIENT_ID, client_secret: process.env.OIDC_CLIENT_SECRET, redirect_uris: [process.env.OIDC_REDIRECT_URI], response_types: ['code'], }); } catch (e) { console.error('[OIDC] No se pudo inicializar OIDC aún. Seguirá reintentando cada 10s en background.'); if (_retryTimer) return; // ya hay un reintento programado // Mensaje diferido (exactamente como pediste): setTimeout(() => { console.log('[OIDC] Inicializando en 10 segundos '); }, RETRY_MS); // Bucle de reintentos (primer intento dentro de 10s) const loop = async () => { try { const issuer = await Issuer.discover(process.env.OIDC_ISSUER); oidcClient = new issuer.Client({ client_id: process.env.OIDC_CLIENT_ID, client_secret: process.env.OIDC_CLIENT_SECRET, redirect_uris: [process.env.OIDC_REDIRECT_URI], response_types: ['code'], }); console.log('[OIDC] Inicializado correctamente en reintento tardío'); _retryTimer = null; // limpiar timer } catch { console.log(`[OIDC] Aún no disponible. Próximo intento en ${RETRY_MS / 1000}s.`); _retryTimer = setTimeout(loop, RETRY_MS); } }; _retryTimer = setTimeout(loop, RETRY_MS); } } async function discoverOIDCWithRetry(issuerUrl, { retries = 30, delayMs = 2000 } = {}) { let lastErr; for (let i = 1; i <= retries; i++) { try { const issuer = await Issuer.discover(issuerUrl); console.log(`[OIDC] Issuer OK en intento ${i}:`, issuer.metadata.issuer); return issuer; } catch (err) { lastErr = err; console.warn(`[OIDC] Intento ${i}/${retries} falló: ${err.code || err.message}`); await sleep(delayMs); } } // No abortamos el proceso; dejamos el servidor vivo y seguimos reintentando en background throw lastErr; } // ----------------------------------------------------------------------------- // Vistas // ----------------------------------------------------------------------------- // Página de login app.get("/auth/login", (_req, res) => { return res.render("login", { pageTitle: "Iniciar sesión" }); }); app.post("/auth/login", async (req, res, next) => { try { const email = String(req.body.email || "").trim().toLowerCase(); const password = String(req.body.password || ""); const remember = req.body.remember === "on" || req.body.remember === true; if (!email || !password) { return res.status(400).render("login", { pageTitle: "Iniciar sesión", error: "Completa email y contraseña." }); } // Tabla/columnas por defecto; ajustables por env si tu esquema difiere const USERS_TABLE = process.env.TENANTS_USERS_TABLE || "users"; const COL_ID = process.env.TENANTS_COL_ID || "id"; const COL_EMAIL = process.env.TENANTS_COL_EMAIL || "email"; const COL_HASH = process.env.TENANTS_COL_HASH || "password_hash"; const COL_ROLE = process.env.TENANTS_COL_ROLE || "role"; const COL_TENANT = process.env.TENANTS_COL_TENANT || "tenant_id"; const { rows } = await tenantsPool.query( `SELECT ${COL_ID} AS id, ${COL_EMAIL} AS email, ${COL_HASH} AS password_hash, ${COL_ROLE} AS role, ${COL_TENANT} AS tenant_id FROM ${USERS_TABLE} WHERE ${COL_EMAIL} = $1 LIMIT 1`, [email] ); if (rows.length === 0) { return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." }); } const user = rows[0]; const ok = await bcrypt.compare(password, user.password_hash || ""); if (!ok) { return res.status(401).render("login", { pageTitle: "Iniciar sesión", error: "Credenciales inválidas." }); } // (Opcional) registro de acceso en DB principal try { await mainPool.query( "INSERT INTO auth_audit_log(email, tenant_id, action, at) VALUES ($1, $2, $3, NOW())", [user.email, user.tenant_id, "login_success"] ); } catch { /* noop si no existe la tabla */ } // Sesión compartida req.session.regenerate((err) => { if (err) return next(err); req.session.user = { id: user.id, email: user.email, role: user.role, tenant_id: user.tenant_id, loggedAt: Date.now(), }; if (remember) { req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; } else { req.session.cookie.expires = false; } req.session.save((err2) => { if (err2) return next(err2); return res.redirect(303, "/"); // "/" → app decide /dashboard o /auth/login }); }); } catch (e) { next(e); } }); // ----------------------------------------------------------------------------- // Rutas OIDC // ----------------------------------------------------------------------------- app.get('/auth/login', (req, res, next) => { try { if (req.session?.oidc) { return nukeSession(req, res, '/auth/login', 'stale_oidc'); } const code_verifier = generators.codeVerifier(); const code_challenge = generators.codeChallenge(code_verifier); // Podés usar generators.state() y generators.nonce(); ambas son válidas const state = generators.state(); // crypto.randomBytes(24).toString('base64url') también sirve const nonce = generators.nonce(); // Guardamos TODO dentro de un objeto para evitar claves sueltas req.session.oidc = { code_verifier, state, nonce }; // Guardar sesión ANTES de redirigir req.session.save((err) => { if (err) return next(err); const url = oidcClient.authorizationUrl({ scope: 'openid profile email offline_access', code_challenge, code_challenge_method: 'S256', state, nonce, }); return res.redirect(url); // importantísimo: return }); } catch (e) { return next(e); } }); app.get('/auth/callback', async (req, res, next) => { try { // Log útil para debug console.log('[OIDC] cb sid=%s query=%j', req.sessionID, req.query); // Recuperar lo que guardamos en /auth/login const { oidc } = req.session || {}; const code_verifier = oidc?.code_verifier; const stateStored = oidc?.state; const nonceStored = oidc?.nonce; // Si por algún motivo no está la info (sesión perdida/expirada), reiniciamos el flujo if (!code_verifier || !stateStored) { console.warn('[OIDC] Falta code_verifier/state en sesión; reiniciando login'); return res.redirect(303, '/auth/login'); } const params = oidcClient.callbackParams(req); // openid-client validará que el "state" recibido coincida con el que pasamos aquí let tokenSet; try { tokenSet = await oidcClient.callback( process.env.OIDC_REDIRECT_URI, params, { code_verifier, state: stateStored, nonce: nonceStored } ); } catch (err) { console.warn('[OIDC] callback error, resetting session:', err?.message || err); return nukeSession(req, res, '/auth/login', 'callback_error'); } // Limpiar datos OIDC de la sesión delete req.session.oidc; const claims = tokenSet.claims(); const email = (claims.email || '').toLowerCase(); // tenant desde claim, Authentik o fallback a tu DB let tenantHex = cleanUuid(claims.tenant_uuid); if (!tenantHex) { const akUser = await akFindUserByEmail(email).catch(() => null); tenantHex = cleanUuid(akUser?.attributes?.tenant_uuid); if (!tenantHex) { const q = await mainPool.query( 'SELECT tenant_uuid FROM app_user WHERE LOWER(email)=LOWER($1) LIMIT 1', [email] ); tenantHex = cleanUuid(q.rows?.[0]?.tenant_uuid); } } // Regenerar sesión para evitar fijación y guardar el usuario req.session.regenerate((err) => { if (err) { if (!res.headersSent) res.status(500).send('No se pudo crear la sesión.'); return; } req.session.user = { sub: claims.sub, email, tenant_uuid: tenantHex || null, }; req.session.save((e2) => { if (e2) { if (!res.headersSent) res.status(500).send('No se pudo guardar la sesión.'); return; } if (!res.headersSent) return res.redirect('/'); }); }); return res.redirect('/'); } catch (e) { console.error('[OIDC] callback error:', e); if (!res.headersSent) return next(e); } }); app.post('/auth/logout', (req, res) => { req.session.destroy(() => { res.clearCookie(SESSION_COOKIE_NAME); res.status(204).end(); }); }); app.get('/auth/me', (req, res) => { if (!req.session?.user) return res.status(401).json({ error: 'no-auth' }); res.json({ user: req.session.user }); }); // ----------------------------------------------------------------------------- // Registro de usuario (DB principal + Authentik) // ----------------------------------------------------------------------------- async function akDeleteUser(pkOrUuid) { try { if (!pkOrUuid || !globalThis.fetch) return; const base = process.env.AUTHENTIK_BASE_URL?.replace(/\/+$/, '') || ''; const id = String(pkOrUuid); const url = `${base}/api/v3/core/users/${encodeURIComponent(id)}/`; await fetch(url, { method: 'DELETE', headers: { 'Authorization': `Bearer ${process.env.AUTHENTIK_TOKEN}` } }); } catch (e) { console.warn('[AK] No se pudo borrar usuario (compensación):', e?.message || e); } } // ============================== // POST /auth/api/users/register // ============================== app.post('/auth/api/users/register', async (req, res, next) => { const { email, display_name, role, tenant_uuid: requestedTenantUuid, // opcional } = req.body || {}; const normEmail = String(email || '').trim().toLowerCase(); if (!normEmail) return res.status(400).json({ error: 'Email requerido' }); // 1) Resolver tenant uuid (existente o nuevo) let tenantHex = null; try { if (typeof resolveExistingTenantUuid === 'function') { tenantHex = await resolveExistingTenantUuid({ email: normEmail, requestedTenantUuid, }); } else { tenantHex = cleanUuid(requestedTenantUuid); console.log("------ Bandera ------"); } // Crear/asegurar tenant en una transacción (ahí adentro corre 01_init.sql) if (tenantHex) { // si no existe, ensureTenant lo crea await ensureTenant({ tenant_uuid: tenantHex }); } else { const created = await ensureTenant({ tenant_uuid: null }); // genera uuid tenantHex = cleanUuid(created?.tenant_uuid); } } catch (e) { return next(new Error(`No se pudo preparar el tenant: ${e.message}`)); } // 2) Transacción DB principal + Authentik con compensación const client = await mainPool.connect(); let createdAkUser = null; // para compensación try { await client.query('BEGIN'); // Duplicados (ajusta a tu constraint real: por email o por (email,tenant)) const dup = await client.query( 'SELECT id FROM app_user WHERE LOWER(email)=LOWER($1)', [normEmail] ); if (dup.rowCount) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'user-exists', message: 'Ya existe un usuario con este email.', next: '/auth/login', }); } // Authentik: buscar o crear let akUser = await akFindUserByEmail(normEmail).catch(() => null); if (!akUser) { akUser = await akCreateUser({ email: normEmail, displayName: display_name || null, tenantUuid: tenantHex, // attributes.tenant_uuid addToGroupId: DEFAULT_GROUP_ID || null, isActive: true, }); createdAkUser = akUser; // marcar que lo creamos nosotros } // Asegurar uuid/pk let akUserUuid = extractAkUserUuid(akUser); let akUserPk = extractAkUserPk(akUser); if (!akUserUuid || akUserPk == null) { const ref = await akFindUserByEmail(normEmail).catch(() => null); akUserUuid = akUserUuid || extractAkUserUuid(ref); akUserPk = akUserPk ?? extractAkUserPk(ref); } if (!akUserUuid) throw new Error('No se pudo obtener uuid del usuario en Authentik'); // Insert en tu DB principal const finalRole = role || 'owner'; await client.query( `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role) VALUES ($1,$2,$3,$4,$5)`, [normEmail, display_name || null, tenantHex, akUserUuid, finalRole] ); await client.query('COMMIT'); // 3) Marcar sesión para set-password (si usás este flujo) req.session.pendingPassword = { email: normEmail, ak_user_uuid: akUserUuid, ak_user_pk: akUserPk, exp: Date.now() + 10 * 60 * 1000, }; return req.session.save(() => { const accept = String(req.headers['accept'] || ''); if (accept.includes('text/html')) { return res.redirect(303, '/set-password'); } return res.status(201).json({ message: 'Usuario registrado', email: normEmail, tenant_uuid: tenantHex, role: finalRole, authentik_user_uuid: akUserUuid, next: '/set-password', }); }); } catch (err) { // Rollbacks + Compensaciones try { await client.query('ROLLBACK'); } catch {} try { // Si creamos el usuario en Authentik y luego falló algo → borrar if (createdAkUser) { const id = extractAkUserPk(createdAkUser) ?? extractAkUserUuid(createdAkUser); if (id) await akDeleteUser(id); } } catch {} return next(err); } finally { client.release(); } }); // Definir contraseña app.post('/auth/password/set', async (req, res, next) => { try { const pp = req.session?.pendingPassword; if (!pp || (pp.exp && Date.now() > pp.exp)) { // token de sesión vencido o ausente if (!res.headersSent) return res.redirect(303, '/set-password'); return; } const { password, password2 } = req.body || {}; if (!password || password.length < 8 || password !== password2) { return res.status(400).send('Contraseña inválida o no coincide.'); } // Buscar el usuario en Authentik y setear la clave const u = await akFindUserByEmail(pp.email); if (!u) return res.status(404).send('No se encontró el usuario en Authentik.'); await akSetPassword(u.pk, password, true); // true = force change handled; ajusta a tu helper // Limpiar marcador y continuar al SSO delete req.session.pendingPassword; return req.session.save(() => res.redirect(303, '/auth/login')); } catch (e) { next(e); } }); // Espera: { email, display_name?, tenant_uuid } // app.post('/auth/auth/api/users/register', async (req, res, next) => { // const { email, display_name, tenant_uuid: rawTenant, role } = req.body || {}; // if (!email) return res.status(400).json({ error: 'email es obligatorio' }); // // Si no vino tenant: lo creamos // const { tenant_uuid, schema, role: dbRole } = await ensureTenant({ tenant_uuid: rawTenant }); // const client = await mainPool.connect(); // try { // await client.query('BEGIN'); // // ¿ya existe en tu DB? // const { rows: dup } = await client.query( // 'SELECT id FROM app_user WHERE email=$1 AND tenant_uuid=$2', // [email.toLowerCase(), tenant_uuid.replace(/-/g, '')] // ); // if (dup.length) { // await client.query('ROLLBACK'); // return res.status(409).json({ error: 'El usuario ya existe en SuiteCoffee' }); // } // // Authentik: crear si no existe // let akUser = await akFindUserByEmail(email); // if (!akUser) { // akUser = await akCreateUser({ // email, // displayName: display_name, // tenantUuid: tenant_uuid, // se normaliza dentro de ak.js // addToGroupId: DEFAULT_GROUP_ID || null, // isActive: true, // }); // // Si querés forzar clave inicial (opcional; depende de tus políticas): // // await akSetPassword(akUser.pk, 'ClaveTemporal123!', true); // } // const _role = role || 'owner'; // await client.query( // `INSERT INTO app_user (email, display_name, tenant_uuid, ak_user_uuid, role) // VALUES ($1,$2,$3,$4,$5)`, // [email.toLowerCase(), display_name || null, tenant_uuid.replace(/-/g, ''), akUser.uuid, _role] // ); // await client.query('COMMIT'); // return res.status(201).json({ // message: 'Usuario registrado', // email, // tenant_uuid, // role: _role, // authentik_user_uuid: akUser.uuid, // next: '/auth/login' // }); // } catch (err) { // try { await client.query('ROLLBACK'); } catch {} // next(err); // } finally { // client.release(); // } // }); // ----------------------------------------------------------------------------- // Healthcheck // ----------------------------------------------------------------------------- app.get('/health', (_req, res) => res.status(200).json({ status: 'ok', service: 'auth' })); // ----------------------------------------------------------------------------- // 404 + Manejo de errores // ----------------------------------------------------------------------------- app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl })); app.use((err, _req, res, _next) => { console.error('[AUTH] ', err); if (res.headersSent) return; res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) }); }); /* ----------------------------------------------------------------------------- Exportación principal del módulo. Es típico exportar la instancia (app) y arrancarla en otro archivo. - Facilita tests (p.ej. con supertest: import app from './app.js') - Evita que el servidor se inicie al importar el módulo. # Default export default app; // importar: import app from './app.js' # Con nombre export const app = express(); // importar: import { app } from './app.js' ----------------------------------------------------------------------------- */ export default app; // ----------------------------------------------------------------------------- // Arranque // ----------------------------------------------------------------------------- const PORT = Number(process.env.PORT || 4040); app.listen(PORT, () => { console.log(`[AUTH] Servicio de autenticación escuchando en http://localhost:${PORT}`); verificarConexion(); OIDCdiscover(); });