fix(auth): corregir redirect_uri y KC URL con localhost en producción

- workspace-auth.ts: reemplaza isInternalOnlyHost por isDevOnlyUrl que
  incluye localhost/127.0.0.1 e IPs LAN además de hostnames Docker.
  getWorkspaceBaseUrl() y getPublicKeycloakBaseUrl() ya ignoran valores
  dev-only; no usan VITE_KEYCLOAK_URL=http://localhost:... en prod.

- Nuevo resolveSystemBaseUrl(): cuando url.origin es localhost porque
  ORIGIN env var apunta a localhost en producción, busca SITE_URL/APP_URL
  como fallback para construir redirect_uri seguros.

- buildKeycloakAuthorizationUrl() y buildKeycloakLoginUrl() usan
  resolveSystemBaseUrl() para el redirect_uri (evita localhost:5173).

- auth/callback/+page.server.ts: idem para el redirectUri del token exchange.

- auth.ts (cliente): resolveKeycloakUrl() detecta en runtime si
  VITE_KEYCLOAK_URL bakeado apunta a localhost pero el browser NO está
  en localhost → deriva la URL del hostname real del browser.

- .env.example: documenta vars de producción recomendadas (SITE_URL,
  ORIGIN, VITE_HUB_URL) con advertencias sobre el efecto de ORIGIN.

Root cause: el .env de dev (ORIGIN=http://localhost:5173,
VITE_KEYCLOAK_URL=http://localhost:8085/kcauth) se usaba en producción
sin sobrescribir estas variables, causando que KC generara URLs con
localhost como redirect_uri y KC URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 08:49:39 -05:00
parent 99cc0bc7ef
commit 74282d446f
4 changed files with 131 additions and 11 deletions

View File

@@ -50,8 +50,49 @@ export interface AuthState {
// Configuración de Keycloak
// ─────────────────────────────────────────────────────────
/**
* Devuelve la URL pública de Keycloak correcta para el browser.
* Si VITE_KEYCLOAK_URL está bakeado con localhost/127.0.0.1 pero el browser
* no está en localhost (producción), se ignora el valor bakeado y se deriva
* del hostname real del browser. Protege contra builds con .env de dev en prod.
*/
function resolveKeycloakUrl(): string {
const configured = (import.meta.env.VITE_KEYCLOAK_URL || '').replace(/\/+$/, '');
if (typeof window === 'undefined') {
// SSR: usar el valor configurado tal cual (el server tiene las vars correctas)
return configured || 'http://localhost:8085/kcauth';
}
const browserHostname = window.location.hostname;
const isLocalBrowser = browserHostname === 'localhost' || browserHostname === '127.0.0.1';
if (configured) {
try {
const parsed = new URL(configured);
const configuredHost = parsed.hostname;
const isLocalConfigured = configuredHost === 'localhost' || configuredHost === '127.0.0.1';
// Si el build fue con localhost pero el browser NO está en localhost → derivar del hostname real
if (isLocalConfigured && !isLocalBrowser) {
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
return `${protocol}//${browserHostname}/kcauth`;
}
} catch {
// URL malformada — caer al fallback
}
return configured;
}
if (isLocalBrowser) {
return 'http://localhost:8085/kcauth';
}
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
return `${protocol}//${browserHostname}/kcauth`;
}
const keycloakConfig = {
url: import.meta.env.VITE_KEYCLOAK_URL,
url: resolveKeycloakUrl(),
realm: import.meta.env.VITE_KEYCLOAK_REALM,
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID
};

View File

@@ -19,11 +19,25 @@ function stripTrailingSlashes(value: string): string {
return value.replace(/\/+$/, '');
}
function isInternalOnlyHost(rawUrl: string): boolean {
/**
* 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 === 'host.docker.internal' || host === 'backend' || host === 'hub-keycloak';
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;
}
@@ -37,7 +51,7 @@ export function getWorkspaceBaseUrl(): string {
].filter(Boolean);
for (const candidate of candidates) {
if (!isInternalOnlyHost(candidate)) {
if (!isDevOnlyUrl(candidate)) {
return stripTrailingSlashes(candidate);
}
}
@@ -45,6 +59,44 @@ export function getWorkspaceBaseUrl(): string {
return DEFAULT_WORKSPACE_BASE_URL;
}
/**
* Normaliza la URL base del sistema (Anexo76) 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://anexo76-dev.aduanasoft.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,
@@ -81,7 +133,10 @@ export function storeReturnPath(cookies: Cookies, path: string): void {
export function getPublicKeycloakBaseUrl(): string {
const configuredKeycloakUrl = (env.VITE_KEYCLOAK_URL || '').trim();
if (configuredKeycloakUrl) {
// 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);
}
@@ -133,7 +188,9 @@ export function clearWorkspaceReturnPath(cookies: Cookies): void {
export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPath: string): string {
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
const redirectUri = `${systemBaseUrl}/auth/callback`;
// 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(),
@@ -154,7 +211,9 @@ export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPat
*/
export function buildKeycloakLoginUrl(systemBaseUrl: string, redirectPath: string): string {
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
const redirectUri = `${systemBaseUrl}/auth/callback`;
// 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(),

View File

@@ -45,8 +45,11 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'anexo76-backend';
const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || '';
// La redirect_uri debe coincidir exactamente con la registrada en Keycloak
const redirectUri = `${url.origin}/auth/callback`;
// La redirect_uri debe coincidir exactamente con la registrada en Keycloak.
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost porque
// ORIGIN env var apunta a localhost en producción (usa SITE_URL como fallback).
const { resolveSystemBaseUrl } = await import('$lib/server/workspace-auth');
const redirectUri = `${resolveSystemBaseUrl(url.origin)}/auth/callback`;
const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`;