Solucionar errores de CORS manteniendo seguridad de Authentik
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 46s

PROBLEMA:
- Frontend hacía fetch a APIs protegidas por Authentik
- Cuando sesión expiraba, Authentik redirigía causando error de CORS
- TypeError: Failed to fetch

SOLUCIÓN:
1. Backend inyecta window.__AUTHENTIK_USER__ en HTML inicial (app.js)
   - Servidor lee headers de Authentik y los pasa al frontend
   - Evita fetch innecesario en carga inicial

2. Frontend usa window.__AUTHENTIK_USER__ como fuente principal (useAuthentik.js)
   - Solo hace fetch cuando se fuerza refresh
   - Detecta errores de CORS como señal de sesión expirada
   - Muestra mensaje claro al usuario

3. App.vue detecta errores de autenticación en APIs
   - Cuando fetch falla con CORS, recarga página automáticamente
   - Authentik manejará la re-autenticación

SEGURIDAD:
- Todos los endpoints /api/* siguen protegidos por Authentik
- No se exponen APIs sin autenticación
- Headers de Authentik solo presentes con sesión válida
This commit is contained in:
2025-10-27 15:15:44 -06:00
parent ab0f79e103
commit 1ea50f0aa5
3 changed files with 136 additions and 11 deletions

View File

@@ -188,6 +188,17 @@ const userExpanded = reactive({});
const deviceExpanded = reactive({}); const deviceExpanded = reactive({});
// formulario inline removido: se usa modal con UserForm // formulario inline removido: se usa modal con UserForm
// Helper para detectar errores de autenticación
function isAuthError(error) {
// Si es un TypeError de fetch, probablemente es CORS (redirección de Authentik)
return error instanceof TypeError && error.message.includes('fetch');
}
function handleAuthError() {
console.warn('Sesión expirada o error de autenticación, redirigiendo...');
window.location.reload();
}
const showEventFilters = ref(false); const showEventFilters = ref(false);
const showUserFilters = ref(false); const showUserFilters = ref(false);
const eventFilters = reactive({ text: '', type: '' }); const eventFilters = reactive({ text: '', type: '' });
@@ -203,8 +214,17 @@ async function fetchUsers() {
loading.users = true; loading.users = true;
try { try {
const res = await fetch('/api/users'); const res = await fetch('/api/users');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json(); const data = await res.json();
users.value = data.items || []; users.value = data.items || [];
} catch (error) {
if (isAuthError(error)) {
handleAuthError();
} else {
console.error('Error fetching users:', error);
}
} finally { loading.users = false; } } finally { loading.users = false; }
} }
@@ -212,17 +232,35 @@ async function fetchRequests() {
loading.requests = true; loading.requests = true;
try { try {
const res = await fetch('/api/requests'); const res = await fetch('/api/requests');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json(); const data = await res.json();
requests.value = data.items || []; requests.value = data.items || [];
} catch (error) {
if (isAuthError(error)) {
handleAuthError();
} else {
console.error('Error fetching requests:', error);
}
} finally { loading.requests = false; } } finally { loading.requests = false; }
} }
async function fetchDevices() { async function fetchDevices() {
try { try {
const res = await fetch('/api/devices'); const res = await fetch('/api/devices');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json(); const data = await res.json();
devices.value = data.items || []; devices.value = data.items || [];
} catch {} } catch (error) {
if (isAuthError(error)) {
handleAuthError();
} else {
console.error('Error fetching devices:', error);
}
}
} }
async function toggleDisable(u) { async function toggleDisable(u) {

View File

@@ -11,17 +11,53 @@ import { useToast } from './useToast.js';
const authentikUser = ref(null); const authentikUser = ref(null);
const isLoading = ref(false); const isLoading = ref(false);
// Flag para indicar si ya se intentó cargar desde window
let initialLoadAttempted = false;
export function useAuthentik() { export function useAuthentik() {
const { toast } = useToast(); const { toast } = useToast();
/** /**
* Obtener datos del usuario desde el backend * Obtener datos del usuario desde el backend o desde window.__AUTHENTIK_USER__
* El backend lee los headers inyectados por Authentik Proxy Outpost * El backend inyecta esta información en el HTML al cargarlo
*/ */
const fetchUserData = async () => { const fetchUserData = async (forceRefresh = false) => {
// Primero intentar cargar desde window.__AUTHENTIK_USER__ (inyectado por el servidor)
if (!initialLoadAttempted && typeof window !== 'undefined' && window.__AUTHENTIK_USER__) {
initialLoadAttempted = true;
const data = window.__AUTHENTIK_USER__;
if (data.authenticated && data.user) {
// Generar avatar URL usando UI Avatars
const avatarName = encodeURIComponent(data.user.name || data.user.username);
data.user.avatar = `${AVATAR_CONFIG.provider}?name=${avatarName}&background=${AVATAR_CONFIG.background}&size=${AVATAR_CONFIG.defaultSize}`;
authentikUser.value = data.user;
} else {
authentikUser.value = null;
}
// Si window.__AUTHENTIK_USER__ indica que no hay autenticación, no hacer fetch adicional
// a menos que se fuerce el refresh
if (!forceRefresh && !data.authenticated) {
return;
}
}
// Solo hacer fetch si se fuerza o si no hay datos iniciales
if (!forceRefresh && initialLoadAttempted && authentikUser.value === null) {
return;
}
// Si no hay datos inyectados o se fuerza el refresh, hacer fetch al backend
isLoading.value = true; isLoading.value = true;
try { try {
const response = await fetch('/api/auth/status'); const response = await fetch('/api/auth/status');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json(); const data = await response.json();
if (data.authenticated && data.user) { if (data.authenticated && data.user) {
@@ -75,6 +111,11 @@ export function useAuthentik() {
try { try {
const response = await fetch('/api/auth/status'); const response = await fetch('/api/auth/status');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json(); const data = await response.json();
if (data.authenticated && data.user) { if (data.authenticated && data.user) {
@@ -97,12 +138,21 @@ export function useAuthentik() {
return; return;
} }
// Error de red o servidor // Error de fetch generalmente indica CORS (redirección de Authentik)
// Esto significa que la sesión expiró o no existe
if (error instanceof TypeError && error.message.includes('fetch')) {
toast.warning('Sesión Expirada', {
description: 'Tu sesión ha expirado. Recarga la página para autenticarte nuevamente.',
});
console.warn('Session expired or CORS error:', error);
} else {
// Otro tipo de error
toast.error('Error', { toast.error('Error', {
description: 'No se pudo verificar el estado de la sesión', description: 'No se pudo verificar el estado de la sesión',
}); });
console.error('Error checking session status:', error); console.error('Error checking session status:', error);
} }
}
}; };
/** /**

View File

@@ -2,6 +2,7 @@ import express from 'express';
import morgan from 'morgan'; import morgan from 'morgan';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { readFileSync } from 'fs';
import apiRouter from './routes/api.js'; import apiRouter from './routes/api.js';
import radiusRouter from './routes/radius.js'; import radiusRouter from './routes/radius.js';
import authRouter from './routes/auth.js'; import authRouter from './routes/auth.js';
@@ -27,8 +28,44 @@ export function createApp() {
// Simple health endpoint for reverse proxies / checks // Simple health endpoint for reverse proxies / checks
app.get('/healthz', (_req, res) => res.json({ ok: true })); app.get('/healthz', (_req, res) => res.json({ ok: true }));
app.get('/', (_req, res) => { // Servir index.html con información de usuario inyectada
res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); app.get('/', (req, res) => {
const indexPath = path.join(__dirname, '..', 'public', 'index.html');
let html = readFileSync(indexPath, 'utf-8');
// Leer headers de Authentik
const username = req.headers['x-authentik-username'];
const email = req.headers['x-authentik-email'];
const name = req.headers['x-authentik-name'];
const groups = req.headers['x-authentik-groups'];
const uid = req.headers['x-authentik-uid'];
// Crear objeto de usuario
const userData = username ? {
authenticated: true,
user: {
username,
email,
name,
groups: groups ? groups.split('|').filter(g => g.trim()) : [],
uid
}
} : {
authenticated: false,
user: null
};
// Inyectar en el HTML antes de </head>
const injection = `
<script>
window.__AUTHENTIK_USER__ = ${JSON.stringify(userData)};
</script>
</head>`;
html = html.replace('</head>', injection);
res.setHeader('Content-Type', 'text/html');
res.send(html);
}); });
return app; return app;