Las dos lecciones anteriores han dejado a Kilómetro Cero con tokens RS256 que todos los servicios verifican y con canales cifrados por los que viajan. Pero han dado por resuelto algo que no lo está: de dónde salen las identidades. servicios/identidad/tokens.py firma con una clave cuyo .pub se copió a mano a cada servicio; el login es un formulario propio con Argon2; los empleados de Kilómetro Cero tienen cuentas corporativas en otro sistema; los productores preferirían entrar con su cuenta de Google; la app de repartidores no debería mostrar nunca una contraseña de larga duración; y pedidos necesita un token de máquina para llamar a inventario cuando actúa por su cuenta (una saga de compensación de 03-05, por ejemplo) sin ningún usuario detrás. Resolver cada caso en cada servicio es inviable y, peor, inseguro: cada implementación propia de login es una oportunidad de error.

Esta lección centraliza la identidad en un proveedor de identidad (IdP) y explica los protocolos que permiten que una identidad establecida allí valga en toda la plataforma: LDAP como directorio de empleados, SAML 2.0 para el inicio de sesión único empresarial, y OAuth 2.0 con OpenID Connect para aplicaciones web, móviles y máquina-a-máquina. Veremos la federación con identidades sociales, el ciclo de vida de una identidad (alta, cambios de rol, baja), la autenticación multifactor y la recuperación de cuentas. Y lo pondremos en marcha: Keycloak como IdP en docker-compose.yml con un realm km0, tres clientes, roles y grupos por productor, y OpenLDAP para los empleados; un cliente OIDC en Python con PKCE, la obtención de un token de máquina para pedidos, y una búsqueda LDAP. La identidad de los servicios por certificado (mTLS) y la custodia de secretos son 06-04; el borde que valida todo esto de forma centralizada, 06-05.

Contenido

  1. Por qué centralizar la identidad
  2. Proveedor de identidad frente a aplicaciones
  3. Directorios LDAP: DIT, dn, ou, búsquedas y bind
  4. SAML 2.0: inicio de sesión único empresarial
  5. OAuth 2.0: delegación de acceso
  6. OpenID Connect: identidad sobre OAuth 2.0
  7. LDAP, SAML y OAuth2/OIDC comparados
  8. Federación e identidades sociales
  9. Ciclo de vida, MFA y recuperación
  10. Kilómetro Cero: Keycloak como IdP y OpenLDAP para empleados
  11. Código: cliente OIDC con PKCE, client credentials y búsqueda LDAP
  12. Errores Comunes y Consejos
  13. Ejercicios
  14. Conclusión

  1. Por qué centralizar la identidad

Kilómetro Cero tiene cuatro poblaciones con necesidades distintas:

Población Ejemplos Cómo se autentica hoy Qué necesita
Clientes Ana, Marc, Lucía Formulario propio, Argon2 Registro fácil, login con Google/Apple, recuperación de contraseña, MFA opcional
Productores Huerta La Vega, Quesería Montblanc, Bodega Roble Alto Mismo formulario Varias personas por productor con permisos distintos (ABAC de 06-01)
Empleados y operadores Atención al cliente, operaciones, finanzas Cuentas corporativas (correo, wiki, nóminas) Una sola cuenta para todo (SSO), baja inmediata al dejar la empresa, MFA obligatorio
Servicios y trabajos pedidos, analitica, el DAG km0_ventas_diarias, el consumidor de Flink Nada, o una contraseña en variable de entorno Credenciales de máquina, de corta vida, sin humano de por medio

Si cada servicio gestiona sus usuarios, aparecen los problemas conocidos: contraseñas distintas en cada sitio (y reutilizadas), un empleado que deja la empresa sigue teniendo acceso a analitica porque nadie lo borró de allí, MFA en la web pero no en el panel de operadores, y cuatro implementaciones de recuperación de contraseña con cuatro conjuntos de errores. La centralización resuelve: una fuente de verdad de quién existe y qué atributos tiene, un punto donde aplicar MFA y políticas de contraseña, una baja que cierra todas las puertas, y aplicaciones que dejan de tocar credenciales.

  1. Proveedor de identidad frente a aplicaciones

El proveedor de identidad (IdP) es el sistema que autentica a los sujetos y emite pruebas de identidad (assertions SAML, tokens OIDC) que las aplicaciones consumen. Las aplicaciones pasan a ser partes confiantes (relying parties, o service providers en SAML): nunca ven la contraseña, solo la prueba firmada.

flowchart LR
    subgraph IdP["Proveedor de identidad (Keycloak, realm km0)"]
        U[(Usuarios, roles, grupos)]
        L[(OpenLDAP: empleados)]
        G[Google / Apple]
        U --- L
        U --- G
    end
    W[web-km0] -->|OIDC| IdP
    R[app-repartidores] -->|OIDC + PKCE| IdP
    P[pedidos] -->|client credentials| IdP
    O[Panel operadores] -->|OIDC| IdP
    IdP -->|tokens RS256 + JWKS| S[Servicios: verifican como en 06-01]

Keycloak, el IdP que usaremos, cubre todo el cuadro: base de usuarios propia, federación con LDAP y con proveedores sociales, emisión de tokens OIDC y assertions SAML, MFA, y una consola de administración. Alternativas con el mismo papel: Auth0/Okta, Azure AD (Entra ID), AWS Cognito, Ory, Authentik. Los protocolos son estándar; cambiar de IdP no debería cambiar el código de las aplicaciones.

  1. Directorios LDAP: DIT, dn, ou, búsquedas y bind

LDAP (Lightweight Directory Access Protocol) es el protocolo de consulta de directorios: bases de datos jerárquicas optimizadas para lecturas, que almacenan personas, grupos, equipos y sus atributos. Active Directory de Microsoft es la implementación más extendida en empresas (con Kerberos para la autenticación en Windows); OpenLDAP y 389 Directory Server son las de código abierto. En Kilómetro Cero el directorio guarda a los empleados, que es lo que suele haber ya en cualquier empresa antes de que exista la plataforma.

El directorio es un árbol, el DIT (Directory Information Tree). Cada entrada tiene un DN (distinguished name) único, que es la ruta desde la raíz:

dc=km0,dc=internal                          ← raíz (domain components)
├── ou=personas                             ← organizational unit
│   ├── uid=jsala,ou=personas,dc=km0,dc=internal
│   │     cn: Jordi Sala · mail: [email protected] · title: Operaciones
│   │     memberOf: cn=operadores,ou=grupos,dc=km0,dc=internal
│   └── uid=mpuig,ou=personas,dc=km0,dc=internal
│         cn: Marta Puig · mail: [email protected] · title: Atención al cliente
├── ou=grupos
│   ├── cn=operadores,ou=grupos,...          member: uid=jsala,...  member: uid=mpuig,...
│   └── cn=finanzas,ou=grupos,...            member: uid=jsala,...
└── cn=keycloak-bind,dc=km0,dc=internal     ← cuenta técnica de solo lectura

Conceptos:

  • Entrada y atributos: cada nodo tiene atributos con tipos definidos por clases de objeto (inetOrgPerson aporta cn, sn, mail, uid; groupOfNames aporta member). El esquema es estricto: no se puede inventar un atributo sin declararlo.
  • dn, ou, cn, uid, dc: distinguished name, organizational unit, common name, user id, domain component. El dn es la clave primaria; el resto son atributos con convenios.
  • Búsqueda: se indica una base (ou=personas,dc=km0,dc=internal), un alcance (base, one, subtree) y un filtro con sintaxis de prefijo: (&(objectClass=inetOrgPerson)(memberOf=cn=operadores,ou=grupos,dc=km0,dc=internal)) devuelve los operadores; (|(mail=j*)(uid=j*)) los que empiezan por j.
  • Bind: la autenticación LDAP. Un simple bind envía dn y contraseña; el servidor los verifica contra el hash almacenado (SSHA, o mejor, Argon2 en OpenLDAP moderno). Autenticar a un usuario "contra LDAP" es: (1) con la cuenta técnica, buscar el dn cuyo uid o mail coincide; (2) intentar un bind con ese dn y la contraseña que el usuario tecleó. Si el bind tiene éxito, la contraseña es correcta.
  • LDAPS / StartTLS: LDAP en claro envía contraseñas en claro; siempre por TLS (puerto 636) o con StartTLS sobre el 389.

LDAP no es un protocolo de SSO: cada aplicación que autentica contra LDAP ve la contraseña del usuario. Por eso el papel de LDAP en una plataforma moderna es ser la fuente de usuarios y grupos que el IdP consulta, y no algo que las aplicaciones toquen directamente. Solo el IdP hace bind.

  1. SAML 2.0: inicio de sesión único empresarial

SAML (Security Assertion Markup Language, 2005) es el protocolo clásico de SSO entre empresas: un empleado entra una vez en el IdP corporativo y accede a decenas de aplicaciones de terceros (correo, nóminas, CRM) sin volver a autenticarse. Es XML firmado, transportado por redirecciones del navegador.

Actores y piezas:

  • IdP: autentica y emite assertions: documentos XML firmados que afirman "el sujeto [email protected] se autenticó a las 10:02 con contraseña y MFA, y tiene estos atributos (grupos, departamento)".
  • SP (service provider): la aplicación, que confía en el IdP porque tiene su certificado de firma, y que consume la assertion.
  • Metadatos: cada parte publica un XML con sus URLs y certificados; se intercambian una vez al configurar la confianza.
  • Bindings: cómo viaja el mensaje: HTTP-Redirect (en la URL), HTTP-POST (formulario autoenviado). Todo pasa por el navegador; IdP y SP no se hablan directamente.

El flujo SP-initiated (el usuario empieza en la aplicación):

sequenceDiagram
    participant N as Navegador (Jordi)
    participant SP as Panel de operadores (SP)
    participant IdP as IdP corporativo (SAML)
    N->>SP: GET /panel (sin sesión)
    SP-->>N: 302 al IdP con AuthnRequest (firmado, comprimido en la URL)
    N->>IdP: GET /sso?SAMLRequest=...
    IdP->>N: Formulario de login (+ MFA)
    N->>IdP: Credenciales
    IdP-->>N: Página con formulario oculto: SAMLResponse (assertion XML firmada)
    N->>SP: POST /saml/acs (Assertion Consumer Service) con SAMLResponse
    SP->>SP: Verifica firma con el certificado del IdP, audiencia, caducidad, InResponseTo
    SP-->>N: Cookie de sesión + 302 /panel

El flujo IdP-initiated empieza en un portal del IdP con iconos de aplicaciones; es menos seguro (no hay InResponseTo que ate la respuesta a una petición) y se desaconseja salvo necesidad. SAML sigue siendo obligatorio cuando el IdP de un cliente corporativo solo habla SAML; para aplicaciones nuevas, OIDC es más simple y mejor adaptado a APIs y móviles. Keycloak habla ambos, así que el panel de operadores de Kilómetro Cero podría usar SAML si mañana se integra con un IdP corporativo externo, y OIDC hoy.

  1. OAuth 2.0: delegación de acceso

OAuth 2.0 (RFC 6749, 2012) nació para un problema distinto de la autenticación: delegar acceso. Ana quiere que una app de terceros lea sus pedidos de Kilómetro Cero sin darle su contraseña. OAuth define cómo esa app obtiene un token de acceso limitado (a ciertos scopes, durante cierto tiempo) con el consentimiento de Ana. Que después se use como base de la autenticación es obra de OpenID Connect (apartado 6).

Roles:

Rol Quién es En Kilómetro Cero
Resource owner El dueño de los datos Ana
Client La aplicación que quiere acceder web-km0, app-repartidores, pedidos
Authorization server Emite tokens tras autenticar y pedir consentimiento Keycloak, realm km0
Resource server La API que acepta tokens pedidos, inventario, catalogo (verifican como en 06-01)

Los scopes son cadenas que acotan el permiso: pedidos:leer, pedidos:crear, reparto:posicion. El token lleva los scopes concedidos y el resource server los comprueba. Los tipos de cliente: confidencial (puede guardar un secreto: un servicio de backend) y público (no puede: una SPA en el navegador, una app móvil; cualquier secreto embebido es extraíble).

Los flujos (grants)

Flujo Para quién Cómo Estado
Authorization code Aplicaciones web con backend El navegador va al IdP, vuelve con un código de un solo uso; el backend lo canjea por tokens con su secreto Recomendado
Authorization code + PKCE Clientes públicos (SPA, móvil) Igual, pero sin secreto: el cliente genera un code_verifier aleatorio, envía su hash (code_challenge) al empezar, y el verificador al canjear; quien intercepte el código no puede canjearlo Recomendado; obligatorio en OAuth 2.1 para todos
Client credentials Máquina a máquina, sin usuario El cliente presenta su client_id y client_secret (o un certificado/JWT firmado) y recibe un token con sub = el propio cliente Recomendado
Refresh token Cualquier cliente con sesión larga Canjea un refresh token por un access token nuevo (06-01, apartado 6); con rotación en clientes públicos Recomendado
Device code Dispositivos sin teclado (TV, terminal) El dispositivo muestra un código; el usuario lo introduce en otro dispositivo Nicho
Implicit SPAs (histórico) El token volvía directamente en el fragmento de la URL Desaconsejado: token en historial, sin PKCE, sin refresh
Resource owner password Apps "de confianza" La app recoge usuario y contraseña y los envía al IdP Desaconsejado: anula el propósito de OAuth, sin MFA, entrena a los usuarios a dar la contraseña a apps

La razón de PKCE merece detenerse. En un móvil, el IdP devuelve el código a la app mediante una URL personalizada (km0://callback?code=...), y otra app maliciosa podría registrar el mismo esquema y capturarlo. Con PKCE, el código solo sirve junto al code_verifier original, que nunca salió de la app legítima. En una SPA, protege igual contra un código robado por un script o por los logs de un proxy.

  1. OpenID Connect: identidad sobre OAuth 2.0

OpenID Connect (OIDC, 2014) añade a OAuth 2.0 lo que le faltaba para autenticar: un ID token (un JWT, exactamente los de 06-01, firmado por el IdP) con claims sobre quién es el usuario y cómo se autenticó, un endpoint userinfo para obtener más atributos, y descubrimiento automático de la configuración.

  • ID token: claims iss, sub, aud (= client_id), exp, iat, más auth_time, nonce (anti-repetición, generado por el cliente), amr (métodos: pwd, otp), y de perfil (email, name, preferred_username) según los scopes openid profile email. Va dirigido al cliente: le dice quién ha entrado.
  • Access token: sigue siendo el de OAuth, dirigido a los resource servers (aud: [pedidos, inventario]). Keycloak lo emite también como JWT RS256 con los roles del realm, que es lo que AuthInterceptor de 06-01 verifica. No se debe usar el ID token para llamar a las APIs, ni el access token para saber quién está en la web.
  • Discovery: GET https://identidad.km0/realms/km0/.well-known/openid-configuration devuelve un JSON con todos los endpoints (authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, end_session_endpoint) y los algoritmos soportados.
  • JWKS (JSON Web Key Set): jwks_uri publica las claves públicas de firma del IdP, cada una con su kid. Esto sustituye al fichero .pub copiado a mano de 06-01: los servicios descargan las claves, las cachean, y cuando ven un kid desconocido las refrescan. La rotación de claves de firma pasa a ser automática.

El flujo completo con authorization code + PKCE, en Kilómetro Cero:

sequenceDiagram
    participant N as Navegador (Ana)
    participant W as web-km0 (backend)
    participant K as Keycloak (realm km0)
    participant P as pedidos
    N->>W: GET /login
    W->>W: genera state, nonce, code_verifier; challenge = SHA256(verifier)
    W-->>N: 302 K/authorize?client_id=web-km0&response_type=code&scope=openid profile<br/>&redirect_uri=...&state&nonce&code_challenge&code_challenge_method=S256
    N->>K: GET /authorize
    K->>N: Login (contraseña + OTP si está activado)
    N->>K: credenciales
    K-->>N: 302 redirect_uri?code=abc&state=...
    N->>W: GET /callback?code=abc&state=...
    W->>W: comprueba state
    W->>K: POST /token (code, code_verifier, client_id, client_secret)
    K-->>W: {id_token, access_token, refresh_token, expires_in}
    W->>K: GET /jwks (cacheado)
    W->>W: verifica id_token: firma (kid), iss, aud=web-km0, exp, nonce
    W-->>N: cookie de sesión HttpOnly
    N->>W: GET /mis-pedidos
    W->>P: GET /pedidos  Authorization: Bearer access_token
    P->>P: verifica con JWKS, aud=pedidos, roles (06-01)
    P-->>W: pedidos de u-ana

Fíjate en que web-km0 es un cliente confidencial (tiene backend y secreto) y aun así usa PKCE: no cuesta nada y OAuth 2.1 lo exige. app-repartidores es público y usa solo PKCE.

  1. LDAP, SAML y OAuth2/OIDC comparados

LDAP SAML 2.0 OAuth 2.0 / OIDC
Qué es Protocolo de directorio (consulta y bind) Protocolo de SSO con assertions XML Marco de delegación (OAuth) + capa de identidad (OIDC) con JWT
Año 1993 (v3: 1997) 2005 2012 / 2014
Formato Binario (ASN.1/BER) XML firmado (XML-DSig) JSON, JWT
Transporte TCP 389/636, conexión directa app → directorio Navegador (redirecciones, POST) HTTPS, navegador para los flujos de usuario, directo para máquina
¿La app ve la contraseña? Sí No No
Móviles y APIs Mal Mal (XML pesado, sin tokens para APIs) Diseñado para ello
Máquina a máquina Bind de cuenta técnica No Client credentials
Atributos del usuario Todos los del directorio En la assertion Claims del ID token y userinfo
Papel en Kilómetro Cero Fuente de empleados y grupos para el IdP Integración con IdPs corporativos externos Todo lo demás: web, app, servicios

No compiten: el patrón habitual es LDAP detrás del IdP, SAML u OIDC delante, y OIDC para lo nuevo.

  1. Federación e identidades sociales

Federar es aceptar identidades de otro IdP. Keycloak actúa como broker: la Quesería Montblanc pulsa "Entrar con Google", Keycloak redirige a Google (que es a su vez un IdP OIDC), recibe el ID token de Google, lo verifica, y crea o enlaza una cuenta local en el realm km0 con sub propio. Las aplicaciones de Kilómetro Cero solo ven tokens de Keycloak: no saben ni les importa si el usuario entró con contraseña, con Google o con Apple.

Ventajas: menos contraseñas que gestionar, registro con un clic, MFA delegada en el proveedor social. Precauciones: el email no verificado de algunos proveedores no debe usarse para enlazar automáticamente con una cuenta existente (un atacante registra en el proveedor X un email ajeno y hereda la cuenta); Keycloak lo obliga a verificar. Y las cuentas de empleados nunca se federan con proveedores sociales: vienen del LDAP corporativo, con MFA obligatorio.

  1. Ciclo de vida, MFA y recuperación

Una identidad no es un registro estático:

  • Alta (onboarding): los clientes se autorregistran (con verificación de email); los productores son aprobados por un operador (rol productor y grupo del productor, apartado 10); los empleados aparecen en LDAP desde RR. HH. y Keycloak los sincroniza. SCIM (System for Cross-domain Identity Management) es el estándar REST para que un sistema de RR. HH. o un IdP cree, modifique y elimine usuarios en otras aplicaciones automáticamente; es lo que evita el "alguien tiene que darlo de alta a mano en cinco sitios".
  • Cambios de rol: Marta pasa de atención al cliente a operaciones: cambia su grupo en LDAP, Keycloak lo refleja en la siguiente sincronización o en el siguiente login, y su próximo token lleva los roles nuevos. Con tokens de 15 minutos (06-01), la latencia máxima es esa.
  • Baja (offboarding): desactivar en LDAP o en Keycloak, revocar sesiones y refresh tokens. Los access tokens vivos caducan en minutos. Es la razón por la que las bajas se hacen en el IdP y nunca aplicación por aplicación.
  • Revisión periódica (access review): listar quién tiene operador y confirmar que sigue siendo cierto. Los permisos solo crecen si nadie los revisa.
  • MFA: obligatoria para operadores y productores (TOTP con una app, o WebAuthn/passkeys); opcional pero fomentada para clientes. Se configura una vez en el IdP y aplica a todo. Keycloak la impone por flujo de autenticación y por rol.
  • Recuperación: el punto más atacado. Enlace de un solo uso por email con caducidad corta, nunca preguntas de seguridad, verificación adicional para cuentas con MFA (códigos de respaldo), y registro de cada recuperación en la auditoría de 06-05. Un flujo de recuperación débil anula la mejor MFA.

  1. Kilómetro Cero: Keycloak como IdP y OpenLDAP para empleados

Añadimos dos servicios a docker-compose.yml. Keycloak necesita su propia base de datos; OpenLDAP se inicializa con un LDIF con los empleados de ejemplo:

# km0/docker-compose.yml (fragmento). Contraseñas en claro solo para desarrollo: 06-04.
services:
  postgres-keycloak:
    image: postgres:16
    environment: { POSTGRES_USER: keycloak, POSTGRES_PASSWORD: keycloak, POSTGRES_DB: keycloak }
    volumes: [ "pg_keycloak:/var/lib/postgresql/data" ]

  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    command: ["start-dev", "--import-realm", "--hostname=http://localhost:8080"]
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
    volumes:
      - ./identidad/realm-km0.json:/opt/keycloak/data/import/realm-km0.json:ro
    ports: [ "8080:8080" ]
    depends_on: [ postgres-keycloak, openldap ]

  openldap:
    image: osixia/openldap:1.5.0
    environment:
      LDAP_ORGANISATION: "Kilometro Cero"
      LDAP_DOMAIN: "km0.internal"
      LDAP_ADMIN_PASSWORD: "admin"
      LDAP_READONLY_USER: "true"
      LDAP_READONLY_USER_USERNAME: "keycloak-bind"
      LDAP_READONLY_USER_PASSWORD: "solo-lectura"
      LDAP_TLS: "true"
    volumes:
      - ./identidad/empleados.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-empleados.ldif:ro
    ports: [ "636:636" ]

volumes:
  pg_keycloak:

start-dev y --hostname=http://localhost:8080 son de desarrollo: en producción, Keycloak va detrás de TLS con start y un hostname real. El fichero identidad/empleados.ldif crea el árbol del apartado 3:

dn: ou=personas,dc=km0,dc=internal
objectClass: organizationalUnit
ou: personas

dn: ou=grupos,dc=km0,dc=internal
objectClass: organizationalUnit
ou: grupos

dn: uid=jsala,ou=personas,dc=km0,dc=internal
objectClass: inetOrgPerson
uid: jsala
cn: Jordi Sala
sn: Sala
mail: [email protected]
title: Operaciones
userPassword: {SSHA}...   # generado con slappasswd; solo desarrollo

dn: uid=mpuig,ou=personas,dc=km0,dc=internal
objectClass: inetOrgPerson
uid: mpuig
cn: Marta Puig
sn: Puig
mail: [email protected]
title: Atencion al cliente
userPassword: {SSHA}...

dn: cn=operadores,ou=grupos,dc=km0,dc=internal
objectClass: groupOfNames
cn: operadores
member: uid=jsala,ou=personas,dc=km0,dc=internal
member: uid=mpuig,ou=personas,dc=km0,dc=internal

Y el realm, exportado a identidad/realm-km0.json (Keycloak lo importa al arrancar). Lo esencial, en forma de tabla porque el JSON completo ocupa cientos de líneas:

Elemento Configuración
Realm km0; tokens de acceso 15 min; refresh 30 días con rotación (revoke refresh token: ON); firma RS256 con rotación de claves gestionada por Keycloak (kid en el JWKS)
Roles de realm cliente, productor, repartidor, operador (los de 06-01)
Grupos /productores/huerta-la-vega, /productores/queseria-montblanc, /productores/bodega-roble-alto: cada grupo asigna el rol productor y el atributo productor_id con el slug; /repartidores/furgoneta-3 asigna repartidor
Cliente web-km0 Confidencial; Standard flow (authorization code) con PKCE S256 obligatorio; redirect_uris: ["https://km0.example/callback"]; scopes openid profile email + pedidos catalogo
Cliente app-repartidores Público; authorization code + PKCE; redirect_uris: ["km0://callback"]; scope reparto
Cliente pedidos-servicio Confidencial; solo service accounts (client credentials), sin flujos de usuario; rol de servicio svc-pedidos; audiencia inventario
Mappers roles del realm → claim roles (lista plana); atributo de grupo productor_id → claim productor_id; scope pedidos → añade pedidos a aud; scope catalogo → catalogo a aud
Federación de usuarios OpenLDAP: ldaps://openldap:636, bind dn: cn=keycloak-bind,dc=km0,dc=internal, users dn: ou=personas,dc=km0,dc=internal, modo READ_ONLY, sincronización periódica; group mapper ou=grupos → grupos de Keycloak; el grupo operadores asigna el rol operador
Proveedores de identidad Google (OIDC), solo para usuarios de la base local; "confiar en email" desactivado
Autenticación Flujo browser con OTP obligatorio para los roles operador y productor, opcional para cliente; recuperación por email con enlace de 15 min

Con docker compose up -d keycloak openldap, la consola en http://localhost:8080 muestra el realm km0, y curl http://localhost:8080/realms/km0/.well-known/openid-configuration devuelve el documento de discovery. Jordi y Marta aparecen como usuarios federados desde LDAP, con el rol operador heredado del grupo; ninguna contraseña suya está en Keycloak.

El servicios/identidad/tokens.py de 06-01 queda retirado: Keycloak emite. Lo que sobrevive es servicios/comun/auth.py, con una única modificación: en lugar del .pub en fichero, usa el JWKS.

  1. Código: cliente OIDC con PKCE, client credentials y búsqueda LDAP

Verificación con JWKS (actualiza servicios/comun/auth.py)

# km0/servicios/comun/auth.py (fragmento actualizado)
import jwt
from jwt import PyJWKClient

ISS = "http://localhost:8080/realms/km0"           # en producción: https://identidad.km0/realms/km0
_jwks = PyJWKClient(f"{ISS}/protocol/openid-connect/certs", cache_keys=True,
                    lifespan=3600)                 # cachea las claves; refresca si ve un kid nuevo

def verificar(token: str, audiencia: str) -> dict:
    try:
        clave = _jwks.get_signing_key_from_jwt(token).key   # elige por 'kid' del header
        return jwt.decode(token, clave, algorithms=["RS256"], audience=audiencia,
                          issuer=ISS, options={"require": ["exp", "iat", "sub"]}, leeway=30)
    except jwt.PyJWTError as e:
        raise TokenInvalido(str(e))

El AuthInterceptor de inventario y el decorador requiere_rol no cambian una línea: los tokens de Keycloak llevan roles y productor_id gracias a los mappers.

servicios/identidad/oidc_cliente.py: authorization code + PKCE

Este módulo es el que usa el backend de web-km0 (y, con las diferencias comentadas, la app de repartidores). Se implementa con requests para que se vea cada paso; authlib lo envuelve en tres llamadas y es lo que se usaría en producción.

# km0/servicios/identidad/oidc_cliente.py
# pip install requests "PyJWT[crypto]"
import base64, hashlib, secrets, urllib.parse
import requests, jwt
from jwt import PyJWKClient

ISSUER = "http://localhost:8080/realms/km0"
CLIENT_ID = "web-km0"
CLIENT_SECRET = "..."                  # confidencial: llega desde el gestor de secretos (06-04)
REDIRECT_URI = "http://localhost:5000/callback"

_conf = requests.get(f"{ISSUER}/.well-known/openid-configuration", timeout=5).json()
_jwks = PyJWKClient(_conf["jwks_uri"], cache_keys=True)

def _b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

def iniciar_login() -> tuple[str, dict]:
    """Paso 1: construye la URL de autorización. Devuelve la URL y lo que hay que
    guardar en la sesión del navegador (state, nonce, verifier) para el paso 2."""
    state = _b64url(secrets.token_bytes(24))          # anti-CSRF: debe volver igual
    nonce = _b64url(secrets.token_bytes(24))          # anti-repetición: irá dentro del id_token
    verifier = _b64url(secrets.token_bytes(32))       # PKCE: secreto de un solo uso
    challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
    params = {
        "client_id": CLIENT_ID, "response_type": "code",
        "scope": "openid profile email pedidos catalogo",
        "redirect_uri": REDIRECT_URI, "state": state, "nonce": nonce,
        "code_challenge": challenge, "code_challenge_method": "S256",
    }
    url = _conf["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
    return url, {"state": state, "nonce": nonce, "verifier": verifier}

def completar_login(query: dict, pendiente: dict) -> dict:
    """Paso 2: en /callback. Comprueba state, canjea el código y valida el ID token."""
    if not secrets.compare_digest(query.get("state", ""), pendiente["state"]):
        raise ValueError("state no coincide: posible CSRF")
    resp = requests.post(_conf["token_endpoint"], data={
        "grant_type": "authorization_code", "code": query["code"],
        "redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,               # la app de repartidores no lo envía
        "code_verifier": pendiente["verifier"],       # PKCE: demuestra que somos quien empezó
    }, timeout=5)
    resp.raise_for_status()
    tokens = resp.json()

    clave = _jwks.get_signing_key_from_jwt(tokens["id_token"]).key
    id_claims = jwt.decode(tokens["id_token"], clave, algorithms=["RS256"],
                           audience=CLIENT_ID, issuer=ISSUER, leeway=30)
    if id_claims.get("nonce") != pendiente["nonce"]:
        raise ValueError("nonce no coincide: id_token reutilizado")

    return {"sub": id_claims["sub"], "email": id_claims.get("email"),
            "nombre": id_claims.get("name"),
            "access_token": tokens["access_token"],     # para llamar a pedidos/catalogo
            "refresh_token": tokens["refresh_token"],   # guardar en servidor, nunca en el navegador
            "expira_en": tokens["expires_in"]}

def refrescar(refresh_token: str) -> dict:
    resp = requests.post(_conf["token_endpoint"], data={
        "grant_type": "refresh_token", "refresh_token": refresh_token,
        "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, timeout=5)
    resp.raise_for_status()
    return resp.json()          # trae refresh_token nuevo: rotación (guardar, descartar el viejo)

Integrado en Flask, /login llama a iniciar_login, guarda pendiente en la sesión y redirige; /callback llama a completar_login, guarda sub, access_token y refresh_token en la sesión de servidor (Redis, 06-01) y redirige a la página. El navegador solo tiene la cookie HttpOnly de la sesión.

Comprobación rápida desde la terminal, con un usuario ana creado en el realm:

python -c "from servicios.identidad.oidc_cliente import iniciar_login; print(iniciar_login()[0])"
# Abre la URL en el navegador, entra como ana, y copia el ?code=...&state=... de la redirección

Client credentials: pedidos obtiene su token de máquina

Cuando pedidos actúa por sí mismo (compensación de una saga, reconciliación nocturna), no hay token de usuario que reenviar. Pide el suyo:

# km0/servicios/pedidos/token_servicio.py
import time, requests

TOKEN_URL = "http://keycloak:8080/realms/km0/protocol/openid-connect/token"
CLIENT_ID, CLIENT_SECRET = "pedidos-servicio", "..."   # secreto: 06-04

_cache = {"token": None, "exp": 0}

def token_de_servicio() -> str:
    """Devuelve un access token para pedidos-servicio, cacheado hasta 60 s antes de caducar."""
    if time.time() < _cache["exp"] - 60:
        return _cache["token"]
    r = requests.post(TOKEN_URL, data={"grant_type": "client_credentials",
                                       "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
                                       "scope": "inventario"}, timeout=5)
    r.raise_for_status()
    t = r.json()
    _cache.update(token=t["access_token"], exp=time.time() + t["expires_in"])
    return _cache["token"]

# Uso en la saga de compensación (03-05), sin usuario detrás:
# stub.LiberarReserva(req, metadata=(("authorization", f"Bearer {token_de_servicio()}"),), timeout=2)

El token resultante tiene sub: service-account-pedidos-servicio, roles: [svc-pedidos], aud: inventario. En ROLES_POR_METODO de inventario (06-01) se añade svc-pedidos a ReservarStock y LiberarReserva. Es la primera identidad de máquina real de la plataforma; 06-04 la reforzará con un certificado en lugar de un client_secret.

Búsqueda LDAP con ldap3

Los servicios no deberían hablar con LDAP (lo hace Keycloak), pero un operador sí necesita a veces consultar el directorio: por ejemplo, un script interno que lista quién está en operadores para la revisión de accesos del apartado 9.

# km0/servicios/identidad/ldap_consulta.py
# pip install ldap3
from ldap3 import Server, Connection, Tls, SUBTREE, ALL
import ssl

tls = Tls(ca_certs_file="certs/km0-ca.crt", validate=ssl.CERT_REQUIRED)   # LDAPS con la CA de 06-02
servidor = Server("openldap", port=636, use_ssl=True, tls=tls, get_info=ALL)

BASE = "dc=km0,dc=internal"

def miembros_del_grupo(grupo: str) -> list[dict]:
    with Connection(servidor, user=f"cn=keycloak-bind,{BASE}", password="solo-lectura",
                    auto_bind=True) as c:                      # bind con la cuenta técnica
        c.search(search_base=f"ou=grupos,{BASE}", search_scope=SUBTREE,
                 search_filter=f"(&(objectClass=groupOfNames)(cn={grupo}))",
                 attributes=["member"])
        if not c.entries:
            return []
        miembros = []
        for dn in c.entries[0].member.values:
            c.search(search_base=dn, search_scope="BASE", search_filter="(objectClass=*)",
                     attributes=["uid", "cn", "mail", "title"])
            e = c.entries[0]
            miembros.append({"uid": str(e.uid), "nombre": str(e.cn),
                             "mail": str(e.mail), "puesto": str(e.title)})
        return miembros

def autenticar_empleado(uid: str, contrasena: str) -> bool:
    """Solo para ilustrar el bind de usuario: en Kilómetro Cero lo hace Keycloak, nunca un servicio."""
    with Connection(servidor, user=f"cn=keycloak-bind,{BASE}", password="solo-lectura",
                    auto_bind=True) as c:
        c.search(f"ou=personas,{BASE}", f"(uid={uid})", attributes=["dn"])
        if not c.entries:
            return False
        dn = c.entries[0].entry_dn
    try:
        Connection(servidor, user=dn, password=contrasena, auto_bind=True).unbind()
        return True
    except Exception:
        return False

if __name__ == "__main__":
    for m in miembros_del_grupo("operadores"):
        print(m)
    # {'uid': 'jsala', 'nombre': 'Jordi Sala', 'mail': '[email protected]', 'puesto': 'Operaciones'}
    # {'uid': 'mpuig', 'nombre': 'Marta Puig', 'mail': '[email protected]', 'puesto': 'Atencion al cliente'}

Dos detalles de seguridad en el código: el filtro de búsqueda se construye con valores controlados (grupo viene de un argumento del script); si viniera de una entrada de usuario habría que escaparlo (ldap3.utils.conv.escape_filter_chars) para evitar inyección LDAP, el equivalente de la inyección SQL. Y la cuenta técnica es de solo lectura: si alguien roba solo-lectura, puede listar empleados pero no modificar grupos.

Errores Comunes y Consejos

  • Usar el ID token para llamar a las APIs (o el access token para identificar al usuario en la web). El ID token tiene aud: web-km0 y pedidos debe rechazarlo; el access token puede ser opaco en otros IdPs. Cada token para su destinatario.
  • Omitir state o nonce "porque ya hay PKCE". Protegen contra ataques distintos: state contra CSRF en el callback (que te inicien sesión en la cuenta del atacante), nonce contra la reutilización de un ID token, PKCE contra la interceptación del código.
  • redirect_uri con comodines. El IdP debe aceptar solo URIs exactas; un comodín permite que el código acabe en un dominio del atacante.
  • Flujo password "porque es una app propia". Anula MFA, el consentimiento y la federación, y acostumbra al usuario a teclear su contraseña en apps. Authorization code + PKCE sirve también para apps nativas.
  • Secretos de cliente en apps móviles o SPAs. Son públicos por definición; regístralos como públicos con PKCE.
  • Aplicaciones que hacen bind contra LDAP directamente. Cada una ve contraseñas y ninguna aplica MFA. LDAP detrás del IdP.
  • Enlazar cuentas sociales por email no verificado. Es la vía clásica de apropiación de cuentas.
  • Bajas aplicación por aplicación. Una baja es desactivar en el IdP (o en LDAP) y revocar sesiones; si necesitas tocar cinco sistemas, el diseño está mal (SCIM existe para eso).
  • Copiar el JWKS a mano o fijar una sola clave. El IdP rota claves; usa jwks_uri con caché y refresco por kid desconocido.
  • Consejo: exporta el realm de Keycloak (kc.sh export) y versiónalo en identidad/realm-km0.json: la configuración de identidad es código, se revisa y se despliega igual que el resto.
  • Consejo: prueba el flujo con un IdP real en desarrollo (Keycloak en Compose), no con un "modo sin autenticación". Las diferencias entre entornos en autenticación son la fuente clásica de sorpresas en producción.
  • Advertencia de cumplimiento. Los datos de identidad (emails, nombres, grupos de empleados, proveedores sociales enlazados) son datos personales; su tratamiento, retención y la política de MFA y recuperación deben revisarse con un profesional de seguridad y protección de datos.

Ejercicios

Ejercicio 1: El código interceptado

Un repartidor tiene instalada una app maliciosa que registra el esquema km0:// y captura la redirección km0://callback?code=XYZ&state=... antes que app-repartidores. Explica qué puede hacer la app maliciosa con ese código (a) si app-repartidores fuera un cliente confidencial con secreto embebido en el APK, (b) si fuera público sin PKCE, (c) con la configuración del apartado 10. Indica en cada caso qué comprobación de Keycloak falla o no falla.

Ejercicio 2: Marta deja la empresa

Marta Puig (mpuig) deja Kilómetro Cero un viernes a las 17:00. Describe qué acciones realiza RR. HH./IT, en qué sistema, y qué ocurre con: su sesión abierta en el panel de operadores, su refresh token, un access token emitido a las 16:55, su cuenta en la wiki interna (que usa SAML contra Keycloak) y sus permisos en analitica. ¿Qué tendría que existir para que todo esto fuera una única acción? ¿Y qué falla si analitica mantuviera su propia tabla de usuarios?

Ejercicio 3: El DAG de Airflow y Flink piden identidad

km0_ventas_diarias (05-05) y el consumidor de Flink de pedidos.eventos (05-04) no tienen usuario. Diseña para cada uno: el cliente en el realm km0 (nombre, tipo, flujo, scopes/audiencia), los roles de servicio, cómo obtienen y renuevan el token, y qué comprobación añade inventario o el broker de Kafka. Señala qué parte de este diseño 06-04 va a cambiar y por qué.

Soluciones

Ejercicio 1.

(a) La app maliciosa extrae el secreto del APK (es trivial), canjea el código con client_id + client_secret y obtiene los tokens del repartidor: Keycloak no puede distinguirla de la app legítima; ninguna comprobación falla. (b) Sin PKCE, un cliente público canjea el código solo con client_id y redirect_uri; la app maliciosa lo hace antes que la legítima y obtiene los tokens; la legítima recibe un error al intentar canjear un código ya usado, que es la única señal, tardía. (c) Con PKCE S256 obligatorio, el canje exige el code_verifier cuyo SHA-256 se envió como code_challenge en la petición de autorización; la app maliciosa tiene el código pero no el verificador (nunca salió de app-repartidores); Keycloak calcula el hash del verificador que envíe (o rechaza su ausencia) y responde invalid_grant. El código queda inservible; la app legítima, si es la primera en canjear, entra con normalidad. state no interviene en este ataque (protege el callback contra CSRF, no contra interceptación).

Ejercicio 2.

IT desactiva uid=mpuig en OpenLDAP (o RR. HH. lo hace vía SCIM/sincronización desde su sistema). Keycloak, en modo READ_ONLY con sincronización periódica, ve la cuenta desactivada en el siguiente ciclo (o de inmediato si IT también la desactiva en Keycloak y revoca sus sesiones desde la consola, que es lo recomendable). La sesión del panel de operadores: si el panel guarda una sesión de servidor con el access token, sigue funcionando hasta que el token caduque (16:55 + 15 min = 17:10 como máximo) o hasta que intente refrescar; el refresh token fue revocado al cerrar sus sesiones en Keycloak, así que el refresh falla y el panel la expulsa. La wiki con SAML: la próxima vez que necesite reautenticarse (o de inmediato si la wiki recibe single logout), el IdP no la autentica; su sesión local en la wiki dura lo que dure su cookie, y por eso las sesiones de aplicaciones SAML se configuran cortas. analitica: si valida tokens de Keycloak, no hay nada que hacer allí. Para que todo sea una acción única hace falta que la baja se haga solo en la fuente (LDAP/RR. HH.) y que fluya por sincronización o SCIM hasta el IdP, con revocación automática de sesiones. Si analitica mantuviera su propia tabla de usuarios con contraseña, la baja no llegaría: Marta seguiría entrando en analitica el lunes, que es exactamente el motivo del apartado 1.

Ejercicio 3.

Airflow: cliente airflow-servicio, confidencial, solo service accounts (client credentials), scope/audiencia analitica y hdfs (si el lago se protege con tokens; en la práctica HDFS usa Kerberos, fuera del curso), rol de servicio svc-analitica-escritura; el DAG obtiene el token en un PythonOperator inicial (o un hook que lo cachee como token_de_servicio) y, como las tareas duran más de 15 minutos, lo renueva por client credentials cuando caduca (sin refresh token: client credentials se repite). Flink: cliente flink-pedidos-consumidor, confidencial, client credentials, audiencia kafka, rol svc-consumidor-pedidos; el broker de Kafka se configura con SASL_SSL y el mecanismo OAUTHBEARER, que valida el JWT contra el JWKS de Keycloak y aplica ACLs: ese principal solo puede leer pedidos.eventos con su grupo de consumidores, y no escribir en nada. inventario añade svc-analitica-... a los métodos de solo lectura si Airflow lo consulta. Lo que 06-04 cambia: el client_secret de ambos vive hoy en la configuración de Airflow y del job de Flink, es decir, en un fichero o variable de entorno; se sustituirá por autenticación con certificado de cliente (mTLS) o por un secreto inyectado y rotado por Vault, y las credenciales de PostgreSQL del DAG pasarán a ser dinámicas.

Conclusión

Centralizar la identidad significa que existe un único lugar donde se decide quién existe, cómo se autentica y qué atributos tiene, y que las aplicaciones consumen pruebas firmadas en lugar de contraseñas. LDAP es el directorio: un árbol de entradas con dn, unidades organizativas, grupos y bind, ideal como fuente de empleados detrás del IdP y pésimo como protocolo de login de aplicaciones. SAML 2.0 es el SSO empresarial por assertions XML, todavía necesario para integrar IdPs corporativos. OAuth 2.0 delega acceso con scopes y flujos, de los que solo tres importan hoy (authorization code con PKCE, client credentials y refresh), y OpenID Connect le añade la identidad: un ID token para la aplicación, un access token para las APIs, discovery para configurarse solo y JWKS para que la verificación RS256 de 06-01 rote claves sin intervención. La federación con identidades sociales, el ciclo de vida con SCIM, la MFA por rol y una recuperación cuidadosa completan el cuadro. Kilómetro Cero tiene ahora Keycloak con el realm km0, los clientes web-km0, app-repartidores y pedidos-servicio, roles y grupos por productor, OpenLDAP con Jordi y Marta, oidc_cliente.py con PKCE y validación contra JWKS, token_de_servicio() para pedidos, y una consulta LDAP para la revisión de accesos.

Y sin embargo, dos cosas siguen siendo frágiles. pedidos-servicio se identifica con un client_secret, que es una contraseña de máquina guardada en algún sitio; inventario acepta esa identidad porque el token es válido, pero no tiene forma de saber si la conexión TLS que le llega viene de verdad del contenedor de pedidos o de cualquier proceso que haya robado el secreto. Y la contraseña de km0_analitica sigue en una variable de entorno del DAG, como la dejó 05-05. La siguiente lección abandona la idea de perímetro: cada servicio tendrá su propio certificado, cada conexión se autenticará en ambos sentidos (mTLS), la autorización se hará también por identidad de servicio, y los secretos dejarán de escribirse en ficheros para vivir en Vault, con credenciales dinámicas que caducan solas.

Curso de Arquitecturas Distribuidas

Módulo 1: Introducción a los Sistemas Distribuidos

Módulo 2: Comunicación en Sistemas Distribuidos

Módulo 3: Consistencia y Replicación

Módulo 4: Almacenamiento Distribuido

Módulo 5: Computación Distribuida

Módulo 6: Seguridad en Sistemas Distribuidos

Módulo 7: Monitoreo y Mantenimiento

Módulo 8: Casos de Estudio y Aplicaciones

© Copyright 2026. Todos los derechos reservados