OAuth2 con Passport para web, móvil y TV: una identidad, ningún secreto en el cliente
Una misma persona entra en ChronoFlow desde el dashboard web en el portátil, desde la app en el teléfono y, a veces, desde una tele. Es una sola cuenta, pero son clientes muy distintos: un navegador, una app nativa que se puede descompilar y un paquete de tele que se instala en aparatos ajenos. Este artículo cuenta cómo los atendemos con un único servidor OAuth2 y por qué ninguno de ellos lleva un secreto dentro.
Por qué Passport
La API es Laravel, que trae Sanctum de serie: tokens simples, pensados sobre todo para una SPA en el mismo dominio. Desde el principio iba a haber app móvil y teles, así que en uno de los primeros issues del proyecto lo sustituimos por Passport, el servidor OAuth2 oficial de Laravel. Nos da el password grant para las sesiones de usuario, renovación con refresh token, clientes diferenciados y tokens de acceso personal para casos que no son un inicio de sesión.
Un cliente por canal, con el secreto en el servidor
En OAuth2, un cliente confidencial se identifica con un id y un secreto. El problema es que un secreto dentro de una app móvil o de un paquete de tele deja de ser secreto: basta con descomprimir el paquete para leerlo. Así que ninguno de nuestros clientes lo lleva.
Tenemos tres clientes de password grant, uno por canal: web para el dashboard, mobile para la app en teléfonos y tablets Android e iOS, y tv para Android TV y las apps de Samsung y LG. Las apps envían a la API correo, contraseña y el nombre de su canal, nada más. La API elige el cliente que corresponde, añade su secreto y pide el token a Passport internamente. El secreto nunca sale del servidor.
Separar por canal no es solo orden. Nos permite rotar el secreto de un canal sin tocar los otros, y cada token queda asociado al cliente que lo emitió, así que se sabe de qué tipo de aparato viene cada sesión.
Cuánto dura una sesión
Para las sesiones de usuario, el token de acceso dura un día y el de renovación, 30 días. El primer diseño usaba una hora de acceso y más tarde lo alargamos a un día. Es un equilibrio: un token corto obliga a renovar a cada rato, y uno largo alarga lo que dura un token robado. La renovación de 30 días marca cuándo hay que volver a escribir la contraseña si la app no se ha usado.
Cerrar sesión revoca solo la sesión actual. Si alguien sale en el portátil, su teléfono sigue conectado, y las pantallas siguen reproduciendo.
Lo que está apagado
Passport trae de serie la pantalla de autorización para el flujo de código, pensada para que terceros pidan acceso a una cuenta. ChronoFlow no tiene integraciones de terceros, así que esa pantalla está desactivada: la API es solo API y no sirve páginas. Todo lo que no usamos, apagado; cada ruta encendida es algo más que mantener y vigilar.
Google y Microsoft, sin fusionar cuentas a ciegas
El inicio de sesión social usa los SDK nativos de cada proveedor en el dispositivo. La app obtiene un token del proveedor y se lo envía a la API, que lo valida contra Google o Microsoft y recupera la identidad. Si es nueva, se crea la cuenta; si ya está vinculada, se inicia sesión.
El caso delicado es cuando el correo del proveedor ya existe en ChronoFlow como cuenta con contraseña. No las fusionamos automáticamente: la API responde con un conflicto y la app pide la contraseña de ChronoFlow para confirmar que la misma persona controla las dos. Varios intentos fallidos bloquean esa confirmación durante un rato. Vincular por correo sin preguntar es cómodo, pero convierte cualquier descuido en el proveedor en acceso a la cuenta.
Las pantallas no inician sesión
Una pantalla nunca ve la contraseña de nadie. Se une a la cuenta con un código corto que alguien reclama desde el Controller y recibe su propia credencial; lo contamos en emparejar pantallas con un código y un QR.
Renovar sin carreras en la app
En la app Flutter, los tokens viven en el almacenamiento seguro del sistema (Keychain en iOS, Keystore en Android), nunca en preferencias. Un interceptor del cliente HTTP detecta la respuesta 401, renueva y repite la petición una sola vez. Si varias peticiones fallan a la vez, comparten una única renovación en lugar de lanzar cinco, porque con tokens de renovación de un solo uso la segunda renovación invalidaría la primera.
Protecciones alrededor
Los inicios de sesión tienen límites de frecuencia por IP y correo y, además, por cuenta, para que repartir los intentos entre muchas IP no sirva de nada. El registro y las demás rutas de autenticación tienen los suyos. Y la consola interna de operaciones no usa Passport: sus credenciales son otras, así que una cuenta de cliente nunca puede convertirse en una de administración.
Lo que descartamos
- Un solo cliente para todo. Más simple, pero sin forma de rotar un secreto sin cerrar todas las sesiones ni de saber de qué tipo de aparato viene cada una.
- Poner el secreto en las apps. Cualquiera con el paquete puede leerlo. Si el secreto está en el cliente, no protege nada.
- Tokens a medida. Firmar y validar nuestros propios tokens es fácil de empezar y difícil de hacer bien. Passport ya resuelve caducidad, revocación y renovación.
- Fusionar cuentas por correo automáticamente. Cómodo para quien se registra, peligroso para quien ya tenía cuenta.
Lo que haríamos distinto
El password grant funciona y lo tenemos acotado, pero las recomendaciones actuales de OAuth lo desaconsejan para clientes nuevos en favor del flujo de código con PKCE. Si empezáramos hoy, lo evaluaríamos desde el principio para la web y el móvil, aunque suponga pantallas de inicio de sesión alojadas por la API.
Para seguir leyendo
Cómo una misma app hace de Controller y de Player: Controller y Player en una sola app. Cómo se une una pantalla: emparejar pantallas con un código y un QR. El producto: ChronoFlow. Si estás diseñando la autenticación de tu producto: Servicios.