diff --git a/.env.example b/.env.example index 7ac9e1f2..921d708c 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,28 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:3000 NODE_ENV=development VITE_API_URL=http://localhost:8000/api INTERNAL_API_URL=http://backend:8000/api + +# ⚠️ ADVERTENCIA PRODUCCIÓN — ORIGIN, VITE_HUB_URL y APP_PUBLIC_URL +# ───────────────────────────────────────────────────────────────────── +# Docker Compose carga ESTE archivo (.env raíz) automáticamente cuando +# se ejecuta: docker compose -f docker-compose.prod.yml up +# +# Si estas variables tienen valores localhost aquí, PISARÁN los defaults +# de docker-compose.prod.yml y causarán que el login falle en producción +# (redirect_uri y KC URL apuntarán a localhost). +# +# Para dev local (docker-compose.yml): dejar localhost. +# Para producción (docker-compose.prod.yml): asegurarse que el servidor +# NO tenga este .env raíz con valores localhost, O usar: +# docker compose --env-file .env.prod -f docker-compose.prod.yml up +# +# Variables críticas para producción: +# ORIGIN=https://anexo76-dev.aduanasoft.com ← determina url.origin en SvelteKit +# VITE_HUB_URL=https://workspace.aduanasoft.com +# APP_PUBLIC_URL=https://anexo76-dev.aduanasoft.com +ORIGIN=http://localhost:5173 +VITE_HUB_URL=http://localhost:3001 +APP_PUBLIC_URL=http://localhost:5173 VITE_KEYCLOAK_REALM=master VITE_KEYCLOAK_URL=http://localhost:8080/kcauth VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend diff --git a/Jenkinsfile b/Jenkinsfile index 3b559ac9..1bab0053 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -273,7 +273,7 @@ pipeline { docker build --progress=plain \ --build-arg VITE_API_URL=\${A76_URL}/api/ \ --build-arg VITE_KEYCLOAK_URL=\${A76_URL}/kcauth/ \ - --build-arg VITE_KEYCLOAK_CLIENT_ID=hub-frontend \ + --build-arg VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend \ --build-arg INTERNAL_API_URL=http://backend:3467/api/ \ -t ${IMAGE_FRONTEND}:${env.APP_VERSION} \ -t ${IMAGE_FRONTEND}:latest \ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index facaae19..e3444170 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -240,6 +240,10 @@ services: - INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/} - INTERNAL_HUB_URL=https://workspace.aduanasoft.com - HUB_URL=https://workspace.aduanasoft.com + # VITE_HUB_URL: URL pública del Hub para el browser (bakeada en build, pero también se lee + # en runtime por $env/dynamic/private en workspace-auth.ts). El root .env puede tener + # localhost; isDevOnlyUrl() lo descarta y cae a HUB_URL (correcto arriba). + - VITE_HUB_URL=https://workspace.aduanasoft.com - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-https://anexo76-dev.aduanasoft.com/kcauth/} - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend} @@ -247,8 +251,14 @@ services: - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - # SvelteKit ORIGIN — evita que request.url.origin use la IP/puerto interno del contenedor + # SvelteKit ORIGIN — determina url.origin en load functions y el flag secure de cookies. + # ⚠️ Docker Compose carga el .env raíz automáticamente. Si ese .env tiene + # ORIGIN=http://localhost:5173 (valor dev), sobreescribe el default de abajo. + # SITE_URL es el fallback que usa el código cuando url.origin es localhost. - ORIGIN=${ORIGIN:-https://anexo76-dev.aduanasoft.com} + # SITE_URL: hardcoded — no depende del .env raíz. + # resolveSystemBaseUrl() lo usa cuando ORIGIN tiene localhost (root .env de dev en prod). + - SITE_URL=https://anexo76-dev.aduanasoft.com ports: - "5111:5173" depends_on: diff --git a/frontend/.env.example b/frontend/.env.example index fbc32cfa..28f46d99 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,5 @@ # ─── Copia este archivo a .env y ajusta los valores para dev local ───────────── +# ─── Para PRODUCCIÓN ver el bloque al final de este archivo ────────────────── # API de Anexo76 (frontend y SSR) VITE_API_URL=http://localhost:8000/api/ @@ -9,14 +10,30 @@ VITE_HUB_URL=http://localhost:3001 HUB_URL=http://localhost:3001 INTERNAL_HUB_URL=http://localhost:8001 -# Keycloak +# Keycloak — URL pública que el BROWSER usará (se bakea en el build) VITE_KEYCLOAK_URL=http://localhost:8085/kcauth VITE_KEYCLOAK_REALM=master -VITE_KEYCLOAK_CLIENT_ID=hub-frontend +VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend +# Keycloak — URL interna que el SERVIDOR usará (no va al browser) KEYCLOAK_URL=http://localhost:8085/kcauth KEYCLOAK_REALM=master -KEYCLOAK_CLIENT_ID=hub-frontend +KEYCLOAK_CLIENT_ID=anexo76-frontend # KEYCLOAK_CLIENT_SECRET= # solo si el cliente KC no es público -# SvelteKit — necesario para cookies secure y URLs SSR correctas +# SvelteKit — necesario para cookies secure y URLs SSR correctas. +# ⚠️ En producción DEBE apuntar al dominio público real, no a localhost. +# Si este valor es localhost, url.origin en los load functions será localhost +# y los redirect_uri de Keycloak apuntarán a localhost (bug de login). ORIGIN=http://localhost:5173 + +# ─── PRODUCCIÓN: vars adicionales críticas ──────────────────────────────────── +# SITE_URL es el fallback de seguridad cuando ORIGIN no se pudo corregir a tiempo. +# El código lo usa para construir redirect_uri cuando url.origin es localhost. +# Recomendado: definir TANTO ORIGIN como SITE_URL con el mismo valor en prod. +# +# SITE_URL=https://anexo76-dev.aduanasoft.com +# ORIGIN=https://anexo76-dev.aduanasoft.com +# VITE_HUB_URL=https://hub-dev.aduanasoft.com (o la URL del Hub en prod) +# HUB_URL=https://hub-dev.aduanasoft.com +# VITE_KEYCLOAK_URL= # vacío → se deriva del hostname del browser automáticamente +# KEYCLOAK_URL=http://keycloak:8080 # URL interna del contenedor KC (si en Docker) diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index 5d684093..336991d1 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -22,9 +22,16 @@ ENV VITE_API_URL=${VITE_API_URL} ARG VITE_KEYCLOAK_URL ENV VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL} -ARG VITE_KEYCLOAK_CLIENT_ID=hub-frontend +ARG VITE_KEYCLOAK_REALM=master +ENV VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM} + +ARG VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend ENV VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID} +# URL pública del Hub Workspace (bakeada en build para el browser) +ARG VITE_HUB_URL=https://workspace.aduanasoft.com +ENV VITE_HUB_URL=${VITE_HUB_URL} + ARG INTERNAL_API_URL ENV INTERNAL_API_URL=${INTERNAL_API_URL} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 31f2f55c..77d6acc5 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -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 }; diff --git a/frontend/src/lib/server/workspace-auth.ts b/frontend/src/lib/server/workspace-auth.ts index d6415f03..34004463 100644 --- a/frontend/src/lib/server/workspace-auth.ts +++ b/frontend/src/lib/server/workspace-auth.ts @@ -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); } @@ -93,7 +148,7 @@ export function getKeycloakRealm(): string { } export function getKeycloakClientId(): string { - return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'hub-frontend').trim(); + return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'anexo76-frontend').trim(); } export function getCleanReturnPath(url: URL): string { @@ -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(), diff --git a/frontend/src/routes/auth/callback/+page.server.ts b/frontend/src/routes/auth/callback/+page.server.ts index 899a7ae1..428d8770 100644 --- a/frontend/src/routes/auth/callback/+page.server.ts +++ b/frontend/src/routes/auth/callback/+page.server.ts @@ -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`;