msaldain 69f5860b7f Inclusión del dominio suitecoffee.uy al NPM.
Se ajustaron los problemas de renderizado y redirección mayores de https://suitecoffee.uy/
Se re-creó el archivo ~/SuiteCoffee/services/app/src/index.js para mantener un orden adecuado
Las rutas exigen una cookie de seción para cargarse, de o contrario redireccionan a  https://suitecoffee.uy/auth/login para iniciar o crear sesión de usuario, excepto https://suitecoffee.uy/inicio que se mantene de esta manera con motivos de desarrollo
2025-09-09 14:20:05 +00:00

1041 lines
36 KiB
JavaScript

// 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';
// -----------------------------------------------------------------------------
// Importaciones desde archivos
// -----------------------------------------------------------------------------
// Helpers de Authentik (admin API)
import { akFindUserByEmail, akCreateUser,
akSetPassword, akResolveGroupId } from './ak.js';
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
// 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_:$-]*$/;
// --- 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 pool.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();
}
}
// async function ensureTenant({ tenant_uuid }) {
// const client = await tenantsPool.connect();
// try {
// await client.query('BEGIN');
// // Si no vino UUID, generamos uno
// let uuid = (tenant_uuid || crypto.randomUUID()).toLowerCase();
// const uuidNoHyphen = uuid.replace(/-/g, '');
// const schema = `schema_tenant_${uuidNoHyphen}`;
// const role = `tenant_${uuidNoHyphen}`;
// const pwd = crypto.randomBytes(18).toString('base64url'); // password del rol
// if (!VALID_IDENT.test(schema) || !VALID_IDENT.test(role)) {
// throw new Error('Identificador de schema/rol inválido');
// }
// // 1) Crear ROL si no existe
// const { rowCount: hasRole } = await client.query(
// 'SELECT 1 FROM pg_roles WHERE rolname=$1',
// [role]
// );
// if (!hasRole) {
// // Para el identificador usamos qi(); el password sí va parametrizado
// await client.query(`CREATE ROLE ${qi(role)} LOGIN PASSWORD ${qs(pwd)}`);
// }
// // 2) Crear SCHEMA si no existe y asignar owner al rol del tenant
// const { rowCount: hasSchema } = await client.query(
// 'SELECT 1 FROM information_schema.schemata WHERE schema_name=$1',
// [schema]
// );
// if (!hasSchema) {
// await client.query(`CREATE SCHEMA ${qi(schema)} AUTHORIZATION ${qi(role)}`);
// }
// // 3) Permisos mínimos para el rol del tenant en su schema
// await client.query(`GRANT USAGE ON SCHEMA ${qi(schema)} TO ${qi(role)}`);
// await client.query(
// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
// GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${qi(role)}`
// );
// await client.query(
// `ALTER DEFAULT PRIVILEGES IN SCHEMA ${qi(schema)}
// GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${qi(role)}`
// );
// await client.query('COMMIT');
// // 4) Inicialización del esquema con 01_init.sql (solo si está vacío)
// try {
// await initializeTenantSchemaIfEmpty(client, schema);
// } catch (e) {
// // Podés decidir si esto es fatal o "best-effort".
// // Si querés cortar el alta cuando falla la init, usa: throw e;
// console.warn(`[TENANT INIT] Falló inicialización de ${schema}:`, e?.message || e);
// }
// return { tenant_uuid: uuid, schema, role, role_password: pwd };
// } catch (e) {
// try { await client.query('ROLLBACK'); } catch {}
// throw e;
// } finally {
// client.release();
// }
// }
// Carga el 01_init.sql del disco, elimina BEGIN/COMMIT y sustituye el schema.
let _cachedInitSql = null;
async function loadInitSql() {
if (_cachedInitSql !== null) return _cachedInitSql;
const candidates = [
process.env.TENANT_INIT_SQL, // opcional
path.resolve(__dirname, 'db', 'initTenant.sql'),
path.resolve(__dirname, '..', 'src', 'db', 'initTenant.sql'),
].filter(Boolean);
for (const p of candidates) {
try {
await access(p, fsConstants.R_OK);
const txt = await readFile(p, 'utf8');
_cachedInitSql = String(txt || '');
console.log(`[TENANT INIT] initTenant.sql: ${p} (${_cachedInitSql.length} bytes)`);
return _cachedInitSql;
} catch {}
}
console.warn('[TENANT INIT] initTenant.sql no encontrado (se omitirá).');
_cachedInitSql = '';
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_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 pool = 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,
});
async function verificarConexion() {
try {
const client = await pool.connect();
const { rows } = await client.query('SELECT NOW() AS ahora');
console.log(`\nConexión con ${chalk.green(process.env.DB_NAME)} OK. Hora DB:`, rows[0].ahora);
client.release();
} catch (error) {
console.error('Error al conectar con la base de datos al iniciar:', error.message);
console.error('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 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;
}
let oidcClient;
(async () => {
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('⚠ No se pudo inicializar OIDC aún. Seguirá reintentando cada 10s en background.');
// reintento en background cada 10s sin tumbar el proceso
(async function loop() {
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');
} catch {
setTimeout(loop, 10000);
}
})();
}
})();
// -----------------------------------------------------------------------------
// 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 pool.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 pool.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)
// -----------------------------------------------------------------------------
// Helpers defensivos (si ya los tenés, podés omitir estas definiciones)
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));
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) => {
// 0) input
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);
}
// 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 pool.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 pool.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('❌ Error:', err);
if (res.headersSent) return;
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) });
});
// -----------------------------------------------------------------------------
// Arranque
// -----------------------------------------------------------------------------
const PORT = Number(process.env.PORT || 4040);
(async () => {
const env = (process.env.NODE_ENV || 'development').toUpperCase();
console.log(`Activando entorno de -> ${env === 'PRODUCTION' ? chalk.red('PRODUCTION') : chalk.green('DEVELOPMENT')}`);
await verificarConexion();
app.listen(PORT, () => {
console.log(`[AUTH] SuiteCoffee corriendo en http://localhost:${PORT}`);
});
})();
export default app;