chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Endpoint server-side para el refresh silencioso del access token.
|
||||
*
|
||||
* Flujo de seguridad:
|
||||
* 1. El cliente llama a POST /api-sveltekit/auth/silent-refresh con credentials:'include'
|
||||
* (las cookies HttpOnly se envían automáticamente, sin que JS las lea).
|
||||
* 2. Este servidor lee el refresh_token de la cookie HttpOnly.
|
||||
* 3. Llama al backend FastAPI /v1/auth/refresh con el refresh_token.
|
||||
* 4. Si es exitoso, actualiza las cookies HttpOnly con los nuevos tokens.
|
||||
* 5. Retorna solo el access_token al cliente (el refresh_token permanece en HttpOnly).
|
||||
*
|
||||
* De este modo el refresh_token NUNCA toca el código JavaScript del cliente.
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { getServerApiUrl, setAuthTokens } from '$lib/server/api';
|
||||
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||
|
||||
export const POST = async ({ cookies, fetch }: RequestEvent) => {
|
||||
const refreshToken = cookies.get('refresh_token');
|
||||
|
||||
if (!refreshToken) {
|
||||
return json({ error: 'No refresh token available' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
|
||||
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// El refresh token expiró o fue invalidado por Keycloak (sesión terminada).
|
||||
// Limpiar las cookies para que el servidor redirigir al login en la siguiente carga.
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
clearAccessTokenCookies(cookies);
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
|
||||
const status = response.status === 401 ? 401 : 400;
|
||||
return json({ error: 'Refresh token expired or invalid' }, { status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
// Actualizar las cookies HttpOnly con los nuevos tokens
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
|
||||
// Devolver solo el access_token al cliente
|
||||
return json({ access_token: data.access_token });
|
||||
} catch (error) {
|
||||
console.error('[silent-refresh] Error inesperado:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente.
|
||||
*
|
||||
* Dos modos:
|
||||
* - { tenant_id } → flujo SSO relay: solo actualiza cookie sso_tenant_id (override de tenant)
|
||||
* - { tenant_slug } → flujo login clásico: re-emite tokens KC para el nuevo tenant
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
|
||||
|
||||
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
||||
const body = await request.json();
|
||||
const { tenant_id, tenant_slug } = body as { tenant_id?: number; tenant_slug?: string };
|
||||
|
||||
if (!tenant_id && !tenant_slug) {
|
||||
return json({ error: 'tenant_id or tenant_slug is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = getAuthTokens(cookies);
|
||||
|
||||
if (!accessToken) {
|
||||
return json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
|
||||
if (tenant_id) {
|
||||
try {
|
||||
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!tenantsRes.ok) {
|
||||
return json({ error: 'Could not validate tenant access' }, { status: 403 });
|
||||
}
|
||||
const tenants: { id: number }[] = await tenantsRes.json();
|
||||
const hasAccess = tenants.some((t) => t.id === tenant_id);
|
||||
if (!hasAccess) {
|
||||
return json({ error: 'Access denied to tenant' }, { status: 403 });
|
||||
}
|
||||
const { isSecureContext } = await import('$lib/server/workspace-auth');
|
||||
const isProduction = isSecureContext();
|
||||
cookies.set('sso_tenant_id', String(tenant_id), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
cookies.set('sso_tenant_pub', String(tenant_id), {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[switch-tenant] SSO mode error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Modo login clásico: re-emitir tokens KC para el nuevo tenant
|
||||
if (!refreshToken) {
|
||||
return json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
return json({ error: err.detail || 'Switch failed' }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
cookies.delete('sso_tenant_id', { path: '/' });
|
||||
cookies.delete('sso_tenant_pub', { path: '/' });
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[switch-tenant] Classic mode error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* API route proxy para obtener las compañías del usuario
|
||||
*/
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies, fetch }) => {
|
||||
const token = getAccessTokenFromCookies(cookies);
|
||||
|
||||
if (!token) {
|
||||
// Limpiar cualquier cookie de compañía si no hay autenticación
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
return json({ error: 'No authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Configurar la URL de la API
|
||||
let apiUrl = process.env.INTERNAL_API_URL;
|
||||
if (!apiUrl) {
|
||||
apiUrl = process.env.VITE_API_URL;
|
||||
// Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR)
|
||||
apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend');
|
||||
}
|
||||
|
||||
// Normalizar la URL
|
||||
const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}v1/auth/my-companies`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Si la autenticación falló, limpiar la cookie de compañía
|
||||
if (response.status === 401) {
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
}
|
||||
return json({ error: 'Failed to fetch companies' }, { status: response.status });
|
||||
}
|
||||
const companies = await response.json();
|
||||
return json(companies);
|
||||
} catch (error) {
|
||||
console.error('Error fetching companies:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* API route para establecer la compañía activa en una cookie
|
||||
*/
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
try {
|
||||
const { companyId } = await request.json();
|
||||
|
||||
if (!companyId || typeof companyId !== 'number') {
|
||||
return json({ error: 'Invalid company ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Establecer la cookie desde el servidor
|
||||
cookies.set('active_company_id', companyId.toString(), {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 días
|
||||
sameSite: 'lax',
|
||||
httpOnly: false, // Permitir acceso desde JavaScript
|
||||
secure: process.env.NODE_ENV === 'production'
|
||||
});
|
||||
|
||||
return json({ success: true, companyId });
|
||||
} catch (error) {
|
||||
console.error('Error setting active company:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
|
||||
import {
|
||||
extractAllowedSystemsFromToken,
|
||||
fetchAllowedSystems,
|
||||
isValidSystem,
|
||||
mergeTokenClaims,
|
||||
setActiveSystemCookie
|
||||
} from '$lib/server/system-gate';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
try {
|
||||
const { system } = await request.json();
|
||||
|
||||
if (!isValidSystem(system)) {
|
||||
return json({ error: 'Sistema inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const accessToken = getAccessTokenFromCookies(cookies);
|
||||
if (!accessToken) {
|
||||
return json({ error: 'No autenticado' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokenClaims = mergeTokenClaims(null, accessToken);
|
||||
let allowedSystems = extractAllowedSystemsFromToken(tokenClaims);
|
||||
|
||||
if (allowedSystems.length === 0) {
|
||||
const rawCompanyId = cookies.get('active_company_id');
|
||||
const companyId = rawCompanyId ? Number.parseInt(rawCompanyId, 10) : NaN;
|
||||
if (Number.isFinite(companyId)) {
|
||||
allowedSystems = await fetchAllowedSystems(cookies, fetch, companyId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowedSystems.includes(system)) {
|
||||
return json({ error: 'No tienes acceso a ese sistema' }, { status: 403 });
|
||||
}
|
||||
|
||||
setActiveSystemCookie(cookies, system);
|
||||
|
||||
return json({ success: true, system });
|
||||
} catch {
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user