ADR-010 — OAuth 2.0 + PKCE + Keycloak + PyJWT¶
| Campo | Valor |
|---|---|
| Estado | Aceptado |
| Fecha | 12 de febrero de 2026 |
| Decisores | Equipo Custodiam |
Contexto¶
Custodiam requiere autenticación con tres clientes simultáneos: aplicación móvil Android, aplicación móvil iOS y aplicación web servida como SPA (Single Page Application). El sistema debe permitir login y logout, sesión persistente mediante refresh tokens, autorización por roles del servidor (RBAC) y la posibilidad de añadir segundo factor en fases posteriores. Las decisiones a tomar son tres acopladas:
- Identity Provider (IdP): ¿implementación propia o servicio externo? ¿open-source autoalojable o SaaS?
- Flujo OAuth: con tres clientes públicos (móvil + SPA), no se puede almacenar un client_secret de forma segura → flujo Authorization Code con PKCE (Proof Key for Code Exchange, RFC 7636).
- Validación del JWT: el backend recibe un bearer token en cada petición. ¿Lo valida remotamente contra el IdP en cada llamada o lo verifica localmente con la clave pública?
Decisión¶
- IdP: Keycloak v26+ autoalojado como contenedor Docker dentro del stack.
- Flujo de autorización: OAuth 2.0 Authorization Code con PKCE (RFC 7636) para los tres clientes. Los dos clientes móviles usan deep links (
es.custodiam://callback); la SPA web usa redirección HTTP estándar (https://app.custodiam.es/callback). - Cliente Dart: paquete oficial
oauth2depub.dev(noflutter_appauth, ver ADR-004). - Validación JWT en backend: local con
PyJWT[crypto]y verificación de firma RS256 usando la JWKS pública de Keycloak (cacheada cinco minutos en memoria del proceso). - Defensa en profundidad: validación adicional del claim
azp(Authorized Party) según RFC 9068 contrasettings.keycloak_authorized_party(configurable, por defectocustodiam-app).
Justificación¶
-
Keycloak es el estándar abierto del espacio IdP autoalojable. Soporta OIDC + SAML, tiene admin UI, gestión de usuarios, segundo factor, federación, plantillas de email, importación de realms desde JSON. Comunidad activa, releases regulares, documentación oficial extensa.
-
PKCE es obligatorio para clientes públicos. Sin PKCE, un atacante que interceptase el código de autorización en la redirección podría intercambiarlo por tokens. PKCE genera un
code_verifieraleatorio por sesión y uncode_challenge = SHA-256(code_verifier)que solo el cliente original conoce. El servidor de tokens exige elcode_verifieroriginal al intercambiar el código, cerrando el vector de ataque (RFC 7636 §1). -
Validación local elimina latencia y acoplamiento. Cada petición a la API verifica el JWT en microsegundos contra la JWKS cacheada, sin viaje de red al IdP. Si Keycloak cae temporalmente, los tokens emitidos siguen siendo válidos hasta su
exp; las sesiones activas no se rompen. Esto materializa el principio de resiliencia documentado en la arquitectura. -
azpcheck es defensa en profundidad sobreverify_aud=False. Keycloak emiteaud=accountpor defecto, lo que obligaría a configurar un audience-resolve mapper específico si se activaseverify_aud=True. La verificación delazp(que Keycloak rellena con elclient_idque solicitó el token) es funcionalmente equivalente y mucho más simple. Sin esta verificación, cualquier futuro cliente OAuth del mismo realm podría obtener tokens válidos para esta API. Cubierto por cinco tests específicos entest_jwt_azp.py. -
El paquete
oauth2de Dart mantiene unClientenvuelto sobrehttp, gestiona el refresh automático del access token cuando expira, y persiste credenciales conCredentials.toJson(). La alternativaflutter_appauthera una opción inicial pero ataba a un fork no oficial cuyos breaking changes complicaban el upgrade del cliente.
Alternativas evaluadas y descartadas¶
A. Auth0 / Okta / Cognito (SaaS)¶
- Pros: cero mantenimiento, escalabilidad, integración rápida.
- Contras: coste recurrente (Auth0 free tier es muy limitado), dependencia de proveedor externo en flujo crítico, no se puede autoalojar.
- Descartado por: el modelo del proyecto exige autoalojable y gratuito (ADR-001 y el modelo de sostenibilidad).
B. Authentik / Ory Hydra¶
- Pros: open-source autoalojable, alternativas modernas a Keycloak.
- Contras: comunidades más pequeñas, Authentik con menos años de producción real, Ory Hydra solo OAuth (sin gestión de usuarios incluida, requiere Kratos como complemento).
- Descartado por: Keycloak tiene mayor cuota de uso institucional (incluyendo administraciones públicas españolas), facilita encontrar referencias y documentación en castellano.
C. Validación remota del JWT (introspection)¶
- Pros: revocación inmediata desde Keycloak.
- Contras: cada petición a la API añade un viaje de red al IdP; si Keycloak está saturado o caído, toda la API se cae con él; latencia añadida.
- Descartado por: rompe el principio de resiliencia operativa; la revocación inmediata se simula con
expcorto (15 minutos) + refresh token rotation.
D. Authorization Code sin PKCE + client_secret¶
- Pros: flujo más simple, soportado por todos los IdPs.
- Contras: imposible almacenar
client_secretde forma segura en cliente público (móvil o SPA); decompilar el APK extrae el secret en segundos. - Descartado por: vector de ataque inaceptable para un cliente público.
E. Resource Owner Password Credentials Grant¶
- Pros: simple (el cliente envía usuario+password al IdP y recibe tokens).
- Contras: deprecado por OAuth 2.1; el cliente "ve" la contraseña; no permite segundo factor; rompe el modelo de delegación de OAuth.
- Descartado por: anti-patrón explícitamente desaconsejado por OAuth 2.0 BCP (RFC 9700).
Implicaciones operativas¶
- Dos clientes registrados en Keycloak:
custodiam-app(público, sin secret, con PKCE obligatorio, redirect URIs para los tres canales) ycustodiam-api(confidencial, con secret, usado solo por el backend para llamar a la Admin API). - Tiempos de sesión configurados: access token 15 minutos, SSO Idle 24 horas, SSO Max 24 horas, Client login timeout 5 minutos. El refresh token rotation activado.
- Validación JWT en backend: implementada en
app/core/security.pyconPyJWKClientcacheando claves cinco minutos. Errores tipados:ExpiredSignatureError,InvalidIssuerError,PyJWKClientError,InvalidTokenError→ HTTP 401. - Asimetría móvil/web del flujo OAuth: el cliente Flutter mantiene dos implementaciones de
AuthServiceseleccionadas porkIsWeb(ADR-023). La razón es que el navegador no soporta deep links del SO; la SPA redirige completamente al IdP y persiste elcode_verifierensessionStoragepara sobrevivir el viaje de ida y vuelta. - RBAC en lockstep: los roles del realm Keycloak se traducen a una matriz rol→permisos definida en
app/core/permissions.pyy espejada enlib/infrastructure/auth/permissions.dart(ADR-013).