// services/auth/src/ak.js // ------------------------------------------------------------ // Cliente para la API Admin de Authentik (v3) // - Sin dependencias externas (usa fetch nativo de Node >=18) // - ESM compatible // - Timeouts, reintentos opcionales y mensajes de error claros // - Compatible con services/auth/src/index.js actual // ------------------------------------------------------------ // Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno. // Devuelve la URL base y el Token que se leyó desde .env /** * @typedef {Object} AkCfg * @property {string} BASE // p.ej. "https://idp.example.com" * @property {string} TOKEN // bearer */ /** * @typedef {Object} AkOpts * @property {Record>} [qs] * @property {any} [body] * @property {number} [timeoutMs=10000] * @property {number} [retries=0] * @property {Record} [headers] */ function getConfig() { const BASE = (process.env.AUTHENTIK_BASE_URL || "").trim().replace(/\/+$/, ""); const TOKEN = process.env.AUTHENTIK_TOKEN || ''; if (!BASE) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL'); if (!TOKEN) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN'); return { BASE, TOKEN }; } // ------------------------------------------------------------ // Utilidades // ------------------------------------------------------------ // Espera const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // ------------------------------------------------------------ // Helpers de sincronización // ------------------------------------------------------------ export function createAkClient(cfg = getConfig()) { return { request: (method, path, opts = {}) => request(method, path, opts, cfg), akGET: (path, opts) => request("GET", path, opts, cfg), akPOST: (path, opts) => request("POST", path, opts, cfg), akPUT: (path, opts) => request("PUT", path, opts, cfg), akPATCH: (path, opts) => request("PATCH", path, opts, cfg), akDELETE:(path, opts) => request("DELETE", path, opts, cfg), }; } // Listar grupos con búsqueda por nombre/slug export async function akListGroups(search = "") { const { akGET } = createAkClient(); const term = String(search ?? "").trim(); const data = await akGET("/core/groups/", { qs: term ? { search: term } : undefined, }); if (Array.isArray(data)) return data; if (data && Array.isArray(data.results)) return data.results; return []; } export async function akPatchUserAttributes(userPk, partialAttrs = {}) { const id = String(userPk ?? "").trim(); if (!id) throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `userPk` is required"); if (partialAttrs == null || typeof partialAttrs !== "object" || Array.isArray(partialAttrs)) { throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` must be a plain object"); } // Remove undefineds to avoid unintentionally nulling keys server-side const cleaned = Object.fromEntries( Object.entries(partialAttrs).filter(([, v]) => v !== undefined) ); if (Object.keys(cleaned).length === 0) { throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` is required"); } // NOTE: pass path WITHOUT /api/v3; the client prefixes it return akPATCH(`/core/users/${encodeURIComponent(id)}/`, { body: { attributes: cleaned }, }); } export async function akEnsureGroupForTenant(tenantHex) { const { akGET, akPOST } = createAkClient(); const hex = String(tenantHex ?? "").trim(); if (!hex) throw new TypeError("akEnsureGroupForTenant: `tenantHex` is required"); const groupName = `tenant_${hex}`; // 1) Buscar existente (normaliza {results:[]}/[]) const data = await akGET("/core/groups/", { qs: { search: groupName } }); const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []); const existing = list.find(g => g?.name === groupName); if (existing?.pk ?? existing?.id) return existing.pk ?? existing.id; // 2) Crear si no existe try { const created = await akPOST("/core/groups/", { body: { name: groupName, attributes: { tenant_uuid: hex } }, }); return created?.pk ?? created?.id; } catch (e) { // 3) Condición de carrera (otro proceso lo creó): reconsulta y devuelve const msg = String(e?.message || ""); if (/already exists|unique|duplicate|409/i.test(msg)) { const data2 = await akGET("/core/groups/", { qs: { search: groupName } }); const list2 = Array.isArray(data2) ? data2 : (Array.isArray(data2?.results) ? data2.results : []); const found = list2.find(g => g?.name === groupName); if (found?.pk ?? found?.id) return found.pk ?? found.id; } throw e; } } export async function akAddUserToGroup(userPk, groupPk) { const { akPOST } = createAkClient(); const user = String(userPk ?? "").trim(); const group = String(groupPk ?? "").trim(); if (!user) throw new TypeError("akAddUserToGroup: `userPk` is required"); if (!group) throw new TypeError("akAddUserToGroup: `groupPk` is required"); // API reciente: POST /core/users//groups/ { group: } const path = `/core/users/${encodeURIComponent(user)}/groups/`; try { return await akPOST(path, { body: { group } }); } catch (e) { const msg = String(e?.message || ""); // Si ya es miembro, tratamos como éxito idempotente if (/already.*member|exists|duplicate|409/i.test(msg)) { return { ok: true, alreadyMember: true, userPk: user, groupPk: group }; } // Fallback para instancias viejas: /core/group_memberships/ { user, group } if (/404|not\s*found/i.test(msg)) { return await akPOST("/core/group_memberships/", { body: { user, group } }); } throw e; } } /** * Llamada HTTP genérica con fetch + timeout + manejo de errores. * @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method * @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3". * @param {AkOpts} [opts] * @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig() * @returns {Promise} */ export async function request(method, path, opts = {}, cfg) { const { BASE, TOKEN } = cfg ?? getConfig(); const { qs, body, timeoutMs = 10_000, retries = 0, headers = {}, } = opts; // Construcción segura de URL + QS const base = BASE.endsWith("/") ? BASE : `${BASE}/`; let p = /^https?:\/\//i.test(path) ? path : (path.startsWith("/") ? path : `/${path}`); if (!/^https?:\/\//i.test(p) && !p.startsWith("/api/")) p = `/api/v3${p}`; const url = new URL(p, base); if (qs && typeof qs === "object") { for (const [k, v] of Object.entries(qs)) { if (v == null) continue; if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k, String(x))); else url.searchParams.set(k, String(v)); } } // Reintentos + timeout const maxAttempts = Math.max(1, retries + 1); let lastErr; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(new Error("AK_TIMEOUT")), timeoutMs); try { const init = { method, signal: ctrl.signal, headers: { Authorization: `Bearer ${TOKEN}`, Accept: "application/json", ...headers, }, }; if (body !== undefined) { // Sólo forzar JSON si es objeto plano const isPlainObj = body && typeof body === "object" && !(body instanceof FormData) && !(body instanceof URLSearchParams) && !(typeof Blob !== "undefined" && body instanceof Blob); if (isPlainObj) { init.headers["Content-Type"] = init.headers["Content-Type"] || "application/json"; init.body = JSON.stringify(body); } else { init.body = body; // deja que fetch maneje el Content-Type } } const res = await fetch(url, init); clearTimeout(t); if (res.status === 204 || res.status === 205) return null; const ctype = res.headers.get("content-type") || ""; const payload = /\bapplication\/json\b/i.test(ctype) ? await res.json().catch(() => ({})) : await res.text(); if (!res.ok) { const detail = typeof payload === "string" ? payload : payload?.detail || payload?.error || JSON.stringify(payload); const err = new Error(`AK ${method} ${url.pathname}${url.search} → ${res.status}: ${detail}`); err.status = res.status; // @ts-ignore // Reintenta 5xx y 429 if ((res.status >= 500 && res.status <= 599) || res.status === 429) { lastErr = err; if (attempt < maxAttempts) { let delay = 500 * 2 ** (attempt - 1); const ra = parseInt(res.headers.get("retry-after") || "", 10); if (!Number.isNaN(ra)) delay = Math.max(delay, ra * 1000); await new Promise(r => setTimeout(r, delay)); continue; } } throw err; } return payload; } catch (e) { clearTimeout(t); lastErr = e; const msg = String(e?.message || ""); const retriable = msg.includes("AK_TIMEOUT") || msg.includes("ECONNREFUSED") || msg.includes("fetch failed"); if (!retriable || attempt >= maxAttempts) throw e; await new Promise(r => setTimeout(r, 500 * 2 ** (attempt - 1))); } } throw lastErr; } // ------------------------------------------------------------ // Funciones públicas // ------------------------------------------------------------ export async function akFindUserByEmail(email) { const { akGET } = createAkClient(); const needle = String(email ?? "").trim().toLowerCase(); if (!needle) throw new TypeError("akFindUserByEmail: `email` is required"); const PAGE_SIZE = 100; let page = 1; const MAX_PAGES = 10; while (page <= MAX_PAGES) { const data = await akGET("/core/users/", { qs: { search: needle, page_size: PAGE_SIZE, page }, retries: 2, }); const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []); const found = list.find(u => String(u?.email || "").toLowerCase() === needle); if (found) return found || null; // Continuar paginando sólo si hay más resultados const hasNext = Array.isArray(data) ? list.length === PAGE_SIZE // array plano: inferimos por tamaño : Boolean(data?.next); // DRF: link "next" if (!hasNext) break; page += 1; } return null; } export async function akCreateUser(p = {}) { const { akPOST } = createAkClient(); const email = String(p.email ?? "").trim().toLowerCase(); if (!email) throw new TypeError("akCreateUser: `email` is required"); const name = String(p.displayName ?? email).trim() || email; const tenantUuid = String(p.tenantUuid ?? "").replace(/-/g, "").trim(); const isActive = p.isActive ?? true; const body = { username: email, name, email, is_active: !!isActive, attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {}, }; let user; try { user = await akPOST("/core/users/", { body, retries: 2 }); } catch (e) { const msg = String(e?.message || ""); if (/409|already\s*exists|unique|duplicate/i.test(msg)) { // Idempotencia: si ya existe, lo buscamos por email y lo devolvemos const existing = await akFindUserByEmail(email); if (existing) return existing; } throw e; } // Agregar a grupo (opcional, no rompe el flujo si falla) const groupId = p.addToGroupId != null ? String(p.addToGroupId).trim() : ""; if (groupId) { try { const userPk = encodeURIComponent(user.pk ?? user.id); await akPOST(`/core/users/${userPk}/groups/`, { body: { group: groupId }, retries: 2, }); } catch (err) { console.warn( `akCreateUser: could not add user ${user.pk ?? user.id} to group ${groupId}:`, err?.message || err ); } } return user; } export async function akSetPassword(userPk, password, requireChange = true) { const { akPOST } = createAkClient(); const id = String(userPk ?? "").trim(); if (!id) throw new TypeError("akSetPassword: `userPk` is required"); const pwd = String(password ?? ""); if (!pwd) throw new TypeError("akSetPassword: `password` is required"); try { await akPOST(`/core/users/${encodeURIComponent(id)}/set_password/`, { body: { password: pwd, require_change: !!requireChange }, retries: 1, timeoutMs: 15_000, }); return true; } catch (e) { const status = e?.status ? `HTTP ${e.status}: ` : ""; const err = new Error(`akSetPassword: failed to set password (${status}${e?.message || e})`); err.cause = e; throw err; } } export async function akResolveGroupIdByName(name) { const term = String(name ?? "").trim(); if (!term) throw new TypeError("akResolveGroupIdByName: `name` is required"); const needle = term.toLowerCase(); const groups = await akListGroups(term); if (!Array.isArray(groups) || groups.length === 0) return null; // Prefer exact slug match, then exact name match const bySlug = groups.find(g => String(g?.slug ?? "").toLowerCase() === needle); if (bySlug) return bySlug.pk ?? bySlug.id ?? null; const byName = groups.find(g => String(g?.name ?? "").toLowerCase() === needle); return byName?.pk ?? byName?.id ?? null; } export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) { const toPk = (v) => { if (v == null || v === "") return null; const n = Number(v); return Number.isFinite(n) ? n : String(v); }; // 1) Direct pk/id const direct = pk ?? id; const directPk = toPk(direct); if (directPk != null) return directPk; const { akGET } = createAkClient(); // 2) By UUID (detail endpoint) const uuidStr = String(uuid ?? "").trim(); if (uuidStr) { try { const g = await akGET(`/core/groups/${encodeURIComponent(uuidStr)}/`, { retries: 1 }); const fromDetail = toPk(g?.pk ?? g?.id); if (fromDetail != null) return fromDetail; } catch { /* continue with name/slug */ } } // 3) By exact name/slug const needle = String(name ?? slug ?? "").trim(); if (needle) { const lower = needle.toLowerCase(); const list = await akListGroups(needle); // expects [] or {results:[]}, handled in akListGroups const found = list.find(g => String(g?.slug ?? "").toLowerCase() === lower) || list.find(g => String(g?.name ?? "").toLowerCase() === lower); const fromList = toPk(found?.pk ?? found?.id); if (fromList != null) return fromList; } return null; } // ------------------------------------------------------------ // Exportación de constantes // ------------------------------------------------------------ export const akGET = (path, opts) => request("GET", path, opts); export const akPOST = (path, opts) => request("POST", path, opts); export const akPUT = (path, opts) => request("PUT", path, opts); export const akPATCH = (path, opts) => request("PATCH", path, opts); export const akDELETE = (path, opts) => request("DELETE", path, opts);