ADR-004: SSO entre la app principal y el portal ReDoc (Referencia de API)
Date: 2026-07-28 Status: Accepted
Context
El portal de Referencia de API (ReDoc, doc/site/) y la app operativa principal (frontend/) usan el mismo proyecto de Firebase — mismas credenciales funcionan en los dos — pero cada uno guarda su sesión en el browser storage de su propio origen: son dominios distintos (docs.api.soterex.* vs. el dominio de la app), y Firebase Auth (SDK cliente) no comparte estado entre orígenes. El cliente pidió explícitamente que loguearse en la app no exija loguearse de nuevo en el portal de docs.
El portal ReDoc es 100% estático (nginx sirviendo HTML/JS, sin backend propio) — no puede verificar una cookie de sesión server-side ni participar de un flujo OAuth completo. La app principal, en cambio, ya tiene un backend (Laravel) con el SDK Admin de Firebase (kreait/laravel-firebase) disponible.
Decision
Intercambio de custom token vía backend, transportado en el fragment de la URL. Un usuario ya autenticado en la app hace click en "Referencia de API" (nuevo ítem del sidebar) → la app pide un custom token de corta duración a un endpoint nuevo (POST /api/sso/docs-api-token, backend) → abre el portal ReDoc en pestaña nueva con el token en #token= → el portal lo canjea con signInWithCustomToken() (SDK cliente) y queda autenticado, sin pedir usuario/contraseña.
Si el usuario llega al portal directo (bookmark, URL tipeada), no hay SSO que aplicar — ve el login manual de siempre. El SSO es un atajo agregado desde la app, no un gate obligatorio en el portal.
Detalles de la decisión
createCustomToken()del SDK Admin (Kreait\Firebase\Contract\Auth, ya disponible víakreait/laravel-firebase, sin dependencia nueva) — firma el JWT localmente con la clave RSA de la service account, sin llamar a Google. Funciona igual en dev (credencial fake con clave RSA real, generada porstart-dev.sh) y en staging/producción (credencial real), sin la bifurcación real/emulador que sí necesitancreateUser/updateUser(FirebaseAuthService).- TTL de 60 segundos — el token solo necesita sobrevivir un redirect inmediato. Acota la ventana para canjear el token, no la duración de la sesión resultante (
signInWithCustomTokendevuelve una sesión Firebase normal, ~1h autorefrescable). - Sin single-use/revocación server-side — los custom tokens de Firebase no tienen tracking nativo; agregarlo requeriría una tabla/Redis de nonces. Riesgo aceptado a propósito: el portal es documentación de solo lectura (bundle ReDoc vendoreado, sin consola "Try it" contra APIs reales) y el token solo reafirma la identidad que el usuario ya tiene — no escala privilegios.
- Fragment (
#token=), no query string — la query string queda en el access log default de nginx y viaja en el headerRefererde la request same-origin aassets/styles.css; el fragment nunca se transmite al servidor, en ningún hosting (dev con nginx o Firebase Hosting en staging/producción). SsoControllerdedicado (no sumado aMeController) — emitir una credencial para otro sistema es un rol distinto de "leer mi propio perfil/auditar mi login", y deja lugar natural si en el futuro hay más de un relying party.throttle:20,1en la ruta — es la única ruta del proyecto que emite credenciales.- Auditado (
SSO_DOCS_API_TOKEN_ISSUED), mismo mecanismo que audita cualquier acción significativa del proyecto.
Alternatives Considered
- Cookie de sesión compartida (
Auth::createSessionCookie(), dominio padre común) — descartada por ahora: requiere que ambos sitios compartan dominio padre (la app principal todavía no tiene dominio decidido) y que el relying party tenga algo server-side para verificar la cookie — el portal ReDoc es 100% estático hoy. Queda como alternativa futura si el portal deja de serlo. - Solo documentar "mismas credenciales, login manual en cada uno" — más simple, cero código nuevo, pero no es lo que pidió el cliente (login automático, no solo mismo usuario/contraseña).
- Custom token en query string en vez de fragment — descartado por el leak hacia logs/Referer/proxies (ver arriba); el fragment resuelve esto sin configuración adicional en ningún hosting.
Consequences
- Positivo: cero infraestructura nueva de sesión (no hay que tocar el modelo de auth de la app principal, que sigue siendo 100% client-side Firebase, sin cookies).
- Positivo: el mismo mecanismo escala a un futuro segundo relying party (
SsoControllercon prefijo/api/sso/*) sin rediseño. - Negativo, aceptado: un token interceptado dentro de la ventana de 60s se puede canjear (sin single-use) y da una sesión completa (~1h) en el portal — blast radius bajo porque el portal es de solo lectura.
- Neutral: el sitio VitePress (
doc/.vitepress/, sin auth propia) no participa del SSO — su link a Referencia de API sigue siendo un link directo, sin ticket.
Next Steps
- Cuando la app principal tenga dominio propio decidido (staging/producción), confirmar que
VITE_DOCS_API_URLapunte al dominio correcto de cada ambiente (verdoc/environments/{staging,production}.md) — el diseño en sí no cambia. doc/site/public/index.html/docs.htmltienen la config de Firebase hardcodeada a"REEMPLAZAR"para cualquier host que no sea.localhost— bloqueante independiente de este ADR para que el SSO (y el login manual) funcionen en staging/producción, ver checklist endoc/environments/staging.md/production.md.

