chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
275
frontend/src/lib/server/workspace-auth.ts
Normal file
275
frontend/src/lib/server/workspace-auth.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { redirect, type Cookies } from '@sveltejs/kit';
|
||||
|
||||
const DEFAULT_WORKSPACE_BASE_URL = 'https://workspace.aduanasoft.com';
|
||||
const RETURN_PATH_COOKIE = 'workspace_return_path';
|
||||
|
||||
/**
|
||||
* Returns true only when the public-facing URL uses HTTPS.
|
||||
* Use this for cookie `secure` flag instead of NODE_ENV so that
|
||||
* cookies work on HTTP LAN dev environments (e.g. 192.168.x.x).
|
||||
*/
|
||||
export function isSecureContext(): boolean {
|
||||
const origin = (env.ORIGIN || process.env.ORIGIN || '').trim();
|
||||
if (origin) return origin.startsWith('https://');
|
||||
return process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
function stripTrailingSlashes(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si una URL apunta a un host que solo es accesible localmente:
|
||||
* localhost, 127.0.0.1, IPs de red LAN/privada y hostnames internos de Docker.
|
||||
* Estas URLs no son válidas como redirect_uri ni como KC public URL en producción.
|
||||
*/
|
||||
function isDevOnlyUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return (
|
||||
host === 'localhost' ||
|
||||
host === '127.0.0.1' ||
|
||||
host === 'host.docker.internal' ||
|
||||
host === 'backend' ||
|
||||
host === 'hub-keycloak' ||
|
||||
/^192\.168\./.test(host) ||
|
||||
/^10\./.test(host) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(host)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceBaseUrl(): string {
|
||||
const candidates = [
|
||||
(env.VITE_HUB_URL || '').trim(),
|
||||
(env.HUB_URL || '').trim(),
|
||||
DEFAULT_WORKSPACE_BASE_URL
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!isDevOnlyUrl(candidate)) {
|
||||
return stripTrailingSlashes(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_WORKSPACE_BASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri seguros.
|
||||
*
|
||||
* Problema habitual en producción: SvelteKit deriva `url.origin` de la variable de entorno
|
||||
* `ORIGIN`. Si el contenedor se despliega con `ORIGIN=http://localhost:5173` (valor del .env
|
||||
* de dev), todos los redirect_uri generados por el servidor apuntan a localhost.
|
||||
*
|
||||
* Esta función:
|
||||
* 1. Usa `requestOrigin` si ya es una URL pública (no dev-only).
|
||||
* 2. Si es localhost, busca `SITE_URL` (env var de producción recomendada) como fallback.
|
||||
* 3. Como último recurso devuelve requestOrigin tal cual (entorno dev genuino).
|
||||
*
|
||||
* Var de entorno recomendada en producción:
|
||||
* SITE_URL=https://mi-app.dominio.com (además de arreglar ORIGIN)
|
||||
*/
|
||||
export function resolveSystemBaseUrl(requestOrigin: string): string {
|
||||
if (!isDevOnlyUrl(requestOrigin)) {
|
||||
return stripTrailingSlashes(requestOrigin);
|
||||
}
|
||||
|
||||
// requestOrigin es dev-only → ORIGIN env var apunta a localhost en producción.
|
||||
// Buscar URL pública en env vars adicionales.
|
||||
const candidates = [
|
||||
(env.SITE_URL || '').trim(),
|
||||
(env.APP_URL || '').trim(),
|
||||
(env.PUBLIC_URL || '').trim(),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && !isDevOnlyUrl(candidate)) {
|
||||
return stripTrailingSlashes(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Entorno dev genuino: devolver requestOrigin tal cual
|
||||
return stripTrailingSlashes(requestOrigin);
|
||||
}
|
||||
|
||||
export type WorkspaceLoginUrlOptions = {
|
||||
/**
|
||||
* URL del login del Hub sin `return_to`. Usar en `post_logout_redirect_uri` para que,
|
||||
* tras logout en KC, el Hub aplique myApps() (launcher si el usuario tiene varias apps).
|
||||
* Con `return_to` a Mi Aplicación, el re-login siempre rebotaba a esa app aunque hubiera más.
|
||||
*/
|
||||
forPostLogout?: boolean;
|
||||
};
|
||||
|
||||
export function getWorkspaceLoginUrl(
|
||||
systemBaseUrl: string,
|
||||
options?: WorkspaceLoginUrlOptions
|
||||
): string {
|
||||
const workspaceBaseUrl = getWorkspaceBaseUrl();
|
||||
if (options?.forPostLogout) {
|
||||
return `${workspaceBaseUrl}/login`;
|
||||
}
|
||||
// return_to includes sso_verified=1 so the workspace preserves it when redirecting
|
||||
// back, regardless of what additional params the workspace appends.
|
||||
const loginUrl = `${systemBaseUrl}/login?sso_verified=1`;
|
||||
return `${workspaceBaseUrl}/login?return_to=${encodeURIComponent(loginUrl)}`;
|
||||
}
|
||||
|
||||
export function storeReturnPath(cookies: Cookies, path: string): void {
|
||||
if (!path || !path.startsWith('/')) return;
|
||||
cookies.set(RETURN_PATH_COOKIE, path, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isSecureContext(),
|
||||
maxAge: 60 * 10
|
||||
});
|
||||
}
|
||||
|
||||
export function getPublicKeycloakBaseUrl(): string {
|
||||
const configuredKeycloakUrl = (env.VITE_KEYCLOAK_URL || '').trim();
|
||||
// Si VITE_KEYCLOAK_URL apunta a un host dev-only (localhost, IP LAN, Docker service),
|
||||
// ignorarlo y derivar la URL del hostname público del Workspace.
|
||||
// Esto protege contra builds donde el .env de dev llega a producción por error.
|
||||
if (configuredKeycloakUrl && !isDevOnlyUrl(configuredKeycloakUrl)) {
|
||||
return stripTrailingSlashes(configuredKeycloakUrl);
|
||||
}
|
||||
|
||||
return `${getWorkspaceBaseUrl()}/kcauth`;
|
||||
}
|
||||
|
||||
export function getKeycloakRealm(): string {
|
||||
return (env.KEYCLOAK_REALM || env.VITE_KEYCLOAK_REALM || 'master').trim();
|
||||
}
|
||||
|
||||
export function getKeycloakClientId(): string {
|
||||
return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'app-frontend').trim();
|
||||
}
|
||||
|
||||
export function getCleanReturnPath(url: URL): string {
|
||||
const cleanParams = new URLSearchParams(url.searchParams);
|
||||
cleanParams.delete('sso_verified');
|
||||
|
||||
const queryString = cleanParams.toString();
|
||||
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
|
||||
}
|
||||
|
||||
export function storeWorkspaceReturnPath(cookies: Cookies, url: URL): string {
|
||||
const returnPath = getCleanReturnPath(url);
|
||||
|
||||
cookies.set(RETURN_PATH_COOKIE, returnPath, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isSecureContext(),
|
||||
maxAge: 60 * 10
|
||||
});
|
||||
|
||||
return returnPath;
|
||||
}
|
||||
|
||||
export function readWorkspaceReturnPath(cookies: Cookies, fallbackPath: string): string {
|
||||
const storedReturnPath = cookies.get(RETURN_PATH_COOKIE);
|
||||
if (storedReturnPath && storedReturnPath.startsWith('/')) {
|
||||
return storedReturnPath;
|
||||
}
|
||||
|
||||
return fallbackPath;
|
||||
}
|
||||
|
||||
export function clearWorkspaceReturnPath(cookies: Cookies): void {
|
||||
cookies.delete(RETURN_PATH_COOKIE, { path: '/' });
|
||||
}
|
||||
|
||||
export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPath: string): string {
|
||||
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado
|
||||
const publicBase = resolveSystemBaseUrl(systemBaseUrl);
|
||||
const redirectUri = `${publicBase}/auth/callback`;
|
||||
const state = JSON.stringify({ redirect_url: redirectPath });
|
||||
const params = new URLSearchParams({
|
||||
client_id: getKeycloakClientId(),
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
prompt: 'none',
|
||||
state
|
||||
});
|
||||
|
||||
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye URL de login directo en KC sin prompt=none.
|
||||
* Usa la sesión KC existente si la hay; si no, muestra el form de login.
|
||||
* Usar cuando se recibe ?redirect= del Hub (rompe el loop Hub↔login).
|
||||
*/
|
||||
export function buildKeycloakLoginUrl(systemBaseUrl: string, redirectPath: string): string {
|
||||
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado
|
||||
const publicBase = resolveSystemBaseUrl(systemBaseUrl);
|
||||
const redirectUri = `${publicBase}/auth/callback`;
|
||||
const state = JSON.stringify({ redirect_url: redirectPath });
|
||||
const params = new URLSearchParams({
|
||||
client_id: getKeycloakClientId(),
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
state
|
||||
});
|
||||
|
||||
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never {
|
||||
// Modo local: nunca salir al workspace, mostrar el login local.
|
||||
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
storeWorkspaceReturnPath(cookies, url);
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
}
|
||||
|
||||
export function redirectToKeycloakAuthorization(systemBaseUrl: string, redirectPath: string): never {
|
||||
throw redirect(303, buildKeycloakAuthorizationUrl(systemBaseUrl, redirectPath));
|
||||
}
|
||||
|
||||
export function redirectToKeycloakLogin(systemBaseUrl: string, redirectPath: string): never {
|
||||
throw redirect(303, buildKeycloakLoginUrl(systemBaseUrl, redirectPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* URL del Hub FastAPI para llamadas server-to-server (ej. sso-exchange).
|
||||
* No aplica isDevOnlyUrl: las URLs internas de Docker son válidas aquí.
|
||||
* Lee HUB_BACKEND_URL (override explícito) → INTERNAL_HUB_URL (ya en docker-compose)
|
||||
* → fallback a URL pública del workspace (vía proxy SvelteKit del Hub).
|
||||
*/
|
||||
export function getHubBackendUrl(): string {
|
||||
const direct =
|
||||
(env.HUB_BACKEND_URL || '').trim() ||
|
||||
(env.INTERNAL_HUB_URL || '').trim();
|
||||
if (direct) return stripTrailingSlashes(direct);
|
||||
return getWorkspaceBaseUrl();
|
||||
}
|
||||
|
||||
export function buildKeycloakLogoutUrl(systemBaseUrl: string, idTokenHint?: string): string {
|
||||
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||
const postLogoutRedirectUri = `${systemBaseUrl}/auth/post-logout`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: getKeycloakClientId(),
|
||||
post_logout_redirect_uri: postLogoutRedirectUri
|
||||
});
|
||||
|
||||
// Con id_token_hint KC acepta cualquier post_logout_redirect_uri sin necesidad
|
||||
// de que esté registrado explícitamente en el cliente.
|
||||
if (idTokenHint) {
|
||||
params.set('id_token_hint', idTokenHint);
|
||||
}
|
||||
|
||||
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/logout?${params.toString()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user