Les dues lliçons anteriors han deixat Quilòmetre Zero amb tokens RS256 que tots els serveis verifiquen i amb canals xifrats pels quals viatgen. Però han donat per resolt una cosa que no ho està: d'on surten les identitats. serveis/identitat/tokens.py signa amb una clau el .pub de la qual es va copiar a mà a cada servei; el login és un formulari propi amb Argon2; els empleats de Quilòmetre Zero tenen comptes corporatius en un altre sistema; els productors preferirien entrar amb el seu compte de Google; l'app de repartidors no hauria de mostrar mai una contrasenya de llarga durada; i comandes necessita un token de màquina per cridar inventari quan actua pel seu compte (una saga de compensació de 03-05, per exemple) sense cap usuari al darrere. Resoldre cada cas a cada servei és inviable i, pitjor, insegur: cada implementació pròpia de login és una oportunitat d'error.

Aquesta lliçó centralitza la identitat en un proveïdor d'identitat (IdP) i explica els protocols que permeten que una identitat establerta allà valgui a tota la plataforma: LDAP com a directori d'empleats, SAML 2.0 per a l'inici de sessió únic empresarial, i OAuth 2.0 amb OpenID Connect per a aplicacions web, mòbils i màquina a màquina. Veurem la federació amb identitats socials, el cicle de vida d'una identitat (alta, canvis de rol, baixa), l'autenticació multifactor i la recuperació de comptes. I ho posarem en marxa: Keycloak com a IdP a docker-compose.yml amb un realm km0, tres clients, rols i grups per productor, i OpenLDAP per als empleats; un client OIDC en Python amb PKCE, l'obtenció d'un token de màquina per a comandes, i una cerca LDAP. La identitat dels serveis per certificat (mTLS) i la custòdia de secrets són 06-04; la vora que valida tot això de manera centralitzada, 06-05.

Contingut

  1. Per què centralitzar la identitat
  2. Proveïdor d'identitat davant d'aplicacions
  3. Directoris LDAP: DIT, dn, ou, cerques i bind
  4. SAML 2.0: inici de sessió únic empresarial
  5. OAuth 2.0: delegació d'accés
  6. OpenID Connect: identitat sobre OAuth 2.0
  7. LDAP, SAML i OAuth2/OIDC comparats
  8. Federació i identitats socials
  9. Cicle de vida, MFA i recuperació
  10. Quilòmetre Zero: Keycloak com a IdP i OpenLDAP per als empleats
  11. Codi: client OIDC amb PKCE, client credentials i cerca LDAP
  12. Errors Comuns i Consells
  13. Exercicis
  14. Conclusió

  1. Per què centralitzar la identitat

Quilòmetre Zero té quatre poblacions amb necessitats diferents:

Població Exemples Com s'autentica avui Què necessita
Clients Anna, Marc, Llúcia Formulari propi, Argon2 Registre fàcil, login amb Google/Apple, recuperació de contrasenya, MFA opcional
Productors Horta La Vega, Formatgeria Montblanc, Celler Roure Alt El mateix formulari Diverses persones per productor amb permisos diferents (ABAC de 06-01)
Empleats i operadors Atenció al client, operacions, finances Comptes corporatius (correu, wiki, nòmines) Un sol compte per a tot (SSO), baixa immediata en deixar l'empresa, MFA obligatori
Serveis i treballs comandes, analitica, el DAG km0_vendes_diaries, el consumidor de Flink Res, o una contrasenya en una variable d'entorn Credencials de màquina, de vida curta, sense cap humà pel mig

Si cada servei gestiona els seus usuaris, apareixen els problemes coneguts: contrasenyes diferents a cada lloc (i reutilitzades), un empleat que deixa l'empresa continua tenint accés a analitica perquè ningú no l'ha esborrat d'allà, MFA a la web però no al panell d'operadors, i quatre implementacions de recuperació de contrasenya amb quatre conjunts d'errors. La centralització ho resol: una font de veritat de qui existeix i quins atributs té, un punt on aplicar MFA i polítiques de contrasenya, una baixa que tanca totes les portes, i aplicacions que deixen de tocar credencials.

  1. Proveïdor d'identitat davant d'aplicacions

El proveïdor d'identitat (IdP) és el sistema que autentica els subjectes i emet proves d'identitat (assertions SAML, tokens OIDC) que les aplicacions consumeixen. Les aplicacions passen a ser parts confiants (relying parties, o service providers a SAML): no veuen mai la contrasenya, només la prova signada.

flowchart LR
    subgraph IdP["Proveïdor d'identitat (Keycloak, realm km0)"]
        U[(Usuaris, rols, grups)]
        L[(OpenLDAP: empleats)]
        G[Google / Apple]
        U --- L
        U --- G
    end
    W[web-km0] -->|OIDC| IdP
    R[app-repartidors] -->|OIDC + PKCE| IdP
    P[comandes] -->|client credentials| IdP
    O[Panell d'operadors] -->|OIDC| IdP
    IdP -->|tokens RS256 + JWKS| S[Serveis: verifiquen com a 06-01]

Keycloak, l'IdP que farem servir, cobreix tot el quadre: base d'usuaris pròpia, federació amb LDAP i amb proveïdors socials, emissió de tokens OIDC i assertions SAML, MFA, i una consola d'administració. Alternatives amb el mateix paper: Auth0/Okta, Azure AD (Entra ID), AWS Cognito, Ory, Authentik. Els protocols són estàndard; canviar d'IdP no hauria de canviar el codi de les aplicacions.

  1. Directoris LDAP: DIT, dn, ou, cerques i bind

LDAP (Lightweight Directory Access Protocol) és el protocol de consulta de directoris: bases de dades jeràrquiques optimitzades per a lectures, que emmagatzemen persones, grups, equips i els seus atributs. Active Directory de Microsoft és la implementació més estesa a les empreses (amb Kerberos per a l'autenticació a Windows); OpenLDAP i 389 Directory Server són les de codi obert. A Quilòmetre Zero el directori guarda els empleats, que és el que sol haver-hi ja a qualsevol empresa abans que existeixi la plataforma.

El directori és un arbre, el DIT (Directory Information Tree). Cada entrada té un DN (distinguished name) únic, que és la ruta des de l'arrel:

dc=km0,dc=internal                          ← arrel (domain components)
├── ou=persones                             ← organizational unit
│   ├── uid=jsala,ou=persones,dc=km0,dc=internal
│   │     cn: Jordi Sala · mail: [email protected] · title: Operacions
│   │     memberOf: cn=operadors,ou=grups,dc=km0,dc=internal
│   └── uid=mpuig,ou=persones,dc=km0,dc=internal
│         cn: Marta Puig · mail: [email protected] · title: Atenció al client
├── ou=grups
│   ├── cn=operadors,ou=grups,...            member: uid=jsala,...  member: uid=mpuig,...
│   └── cn=finances,ou=grups,...             member: uid=jsala,...
└── cn=keycloak-bind,dc=km0,dc=internal     ← compte tècnic de només lectura

Conceptes:

  • Entrada i atributs: cada node té atributs amb tipus definits per classes d'objecte (inetOrgPerson aporta cn, sn, mail, uid; groupOfNames aporta member). L'esquema és estricte: no es pot inventar un atribut sense declarar-lo.
  • dn, ou, cn, uid, dc: distinguished name, organizational unit, common name, user id, domain component. El dn és la clau primària; la resta són atributs amb convenis.
  • Cerca: s'indica una base (ou=persones,dc=km0,dc=internal), un abast (base, one, subtree) i un filtre amb sintaxi de prefix: (&(objectClass=inetOrgPerson)(memberOf=cn=operadors,ou=grups,dc=km0,dc=internal)) retorna els operadors; (|(mail=j*)(uid=j*)) els que comencen per j.
  • Bind: l'autenticació LDAP. Un simple bind envia dn i contrasenya; el servidor els verifica contra el hash desat (SSHA, o millor, Argon2 a l'OpenLDAP modern). Autenticar un usuari "contra LDAP" és: (1) amb el compte tècnic, cercar el dn amb l'uid o el mail que coincideix; (2) intentar un bind amb aquest dn i la contrasenya que l'usuari ha teclejat. Si el bind té èxit, la contrasenya és correcta.
  • LDAPS / StartTLS: LDAP en clar envia les contrasenyes en clar; sempre per TLS (port 636) o amb StartTLS sobre el 389.

LDAP no és un protocol de SSO: cada aplicació que autentica contra LDAP veu la contrasenya de l'usuari. Per això el paper de LDAP en una plataforma moderna és ser la font d'usuaris i grups que l'IdP consulta, i no una cosa que les aplicacions toquin directament. Només l'IdP fa bind.

  1. SAML 2.0: inici de sessió únic empresarial

SAML (Security Assertion Markup Language, 2005) és el protocol clàssic de SSO entre empreses: un empleat entra un cop a l'IdP corporatiu i accedeix a desenes d'aplicacions de tercers (correu, nòmines, CRM) sense tornar-se a autenticar. És XML signat, transportat per redireccions del navegador.

Actors i peces:

  • IdP: autentica i emet assertions: documents XML signats que afirmen "el subjecte [email protected] s'ha autenticat a les 10:02 amb contrasenya i MFA, i té aquests atributs (grups, departament)".
  • SP (service provider): l'aplicació, que confia en l'IdP perquè en té el certificat de signatura, i que consumeix l'assertion.
  • Metadades: cada part publica un XML amb els seus URL i certificats; s'intercanvien un cop en configurar la confiança.
  • Bindings: com viatja el missatge: HTTP-Redirect (a l'URL), HTTP-POST (formulari autoenviat). Tot passa pel navegador; IdP i SP no es parlen directament.

El flux SP-initiated (l'usuari comença a l'aplicació):

sequenceDiagram
    participant N as Navegador (Jordi)
    participant SP as Panell d'operadors (SP)
    participant IdP as IdP corporatiu (SAML)
    N->>SP: GET /panell (sense sessió)
    SP-->>N: 302 a l'IdP amb AuthnRequest (signat, comprimit a l'URL)
    N->>IdP: GET /sso?SAMLRequest=...
    IdP->>N: Formulari de login (+ MFA)
    N->>IdP: Credencials
    IdP-->>N: Pàgina amb formulari ocult: SAMLResponse (assertion XML signada)
    N->>SP: POST /saml/acs (Assertion Consumer Service) amb SAMLResponse
    SP->>SP: Verifica la signatura amb el certificat de l'IdP, audiència, caducitat, InResponseTo
    SP-->>N: Galeta de sessió + 302 /panell

El flux IdP-initiated comença en un portal de l'IdP amb icones d'aplicacions; és menys segur (no hi ha InResponseTo que lligui la resposta a una petició) i es desaconsella llevat que sigui necessari. SAML continua sent obligatori quan l'IdP d'un client corporatiu només parla SAML; per a aplicacions noves, OIDC és més simple i està millor adaptat a APIs i mòbils. Keycloak parla tots dos, així que el panell d'operadors de Quilòmetre Zero podria fer servir SAML si demà s'integra amb un IdP corporatiu extern, i OIDC avui.

  1. OAuth 2.0: delegació d'accés

OAuth 2.0 (RFC 6749, 2012) va néixer per a un problema diferent de l'autenticació: delegar accés. L'Anna vol que una app de tercers llegeixi les seves comandes de Quilòmetre Zero sense donar-li la seva contrasenya. OAuth defineix com aquesta app obté un token d'accés limitat (a certs scopes, durant cert temps) amb el consentiment de l'Anna. Que després es faci servir com a base de l'autenticació és obra d'OpenID Connect (apartat 6).

Rols:

Rol Qui és A Quilòmetre Zero
Resource owner El propietari de les dades L'Anna
Client L'aplicació que vol accedir web-km0, app-repartidors, comandes
Authorization server Emet tokens després d'autenticar i demanar consentiment Keycloak, realm km0
Resource server L'API que accepta tokens comandes, inventari, cataleg (verifiquen com a 06-01)

Els scopes són cadenes que acoten el permís: comandes:llegir, comandes:crear, repartiment:posicio. El token porta els scopes concedits i el resource server els comprova. Els tipus de client: confidencial (pot guardar un secret: un servei de backend) i públic (no pot: una SPA al navegador, una app mòbil; qualsevol secret incrustat és extraïble).

Els fluxos (grants)

Flux Per a qui Com Estat
Authorization code Aplicacions web amb backend El navegador va a l'IdP, torna amb un codi d'un sol ús; el backend el bescanvia per tokens amb el seu secret Recomanat
Authorization code + PKCE Clients públics (SPA, mòbil) Igual, però sense secret: el client genera un code_verifier aleatori, n'envia el hash (code_challenge) en començar, i el verificador en bescanviar; qui intercepti el codi no el pot bescanviar Recomanat; obligatori a OAuth 2.1 per a tothom
Client credentials Màquina a màquina, sense usuari El client presenta el seu client_id i client_secret (o un certificat/JWT signat) i rep un token amb sub = el mateix client Recomanat
Refresh token Qualsevol client amb sessió llarga Bescanvia un refresh token per un access token nou (06-01, apartat 6); amb rotació als clients públics Recomanat
Device code Dispositius sense teclat (TV, terminal) El dispositiu mostra un codi; l'usuari l'introdueix en un altre dispositiu Nínxol
Implicit SPAs (històric) El token tornava directament al fragment de l'URL Desaconsellat: token a l'historial, sense PKCE, sense refresh
Resource owner password Apps "de confiança" L'app recull usuari i contrasenya i els envia a l'IdP Desaconsellat: anul·la el propòsit d'OAuth, sense MFA, ensenya els usuaris a donar la contrasenya a apps

La raó de PKCE mereix aturar-s'hi. En un mòbil, l'IdP retorna el codi a l'app mitjançant un URL personalitzat (km0://callback?code=...), i una altra app maliciosa podria registrar el mateix esquema i capturar-lo. Amb PKCE, el codi només serveix juntament amb el code_verifier original, que mai no ha sortit de l'app legítima. En una SPA, protegeix igualment contra un codi robat per un script o pels logs d'un proxy.

  1. OpenID Connect: identitat sobre OAuth 2.0

OpenID Connect (OIDC, 2014) afegeix a OAuth 2.0 el que li faltava per autenticar: un ID token (un JWT, exactament els de 06-01, signat per l'IdP) amb claims sobre qui és l'usuari i com s'ha autenticat, un endpoint userinfo per obtenir més atributs, i descobriment automàtic de la configuració.

  • ID token: claims iss, sub, aud (= client_id), exp, iat, més auth_time, nonce (antirepetició, generat pel client), amr (mètodes: pwd, otp), i de perfil (email, name, preferred_username) segons els scopes openid profile email. Va adreçat al client: li diu qui ha entrat.
  • Access token: continua sent el d'OAuth, adreçat als resource servers (aud: [comandes, inventari]). Keycloak també l'emet com a JWT RS256 amb els rols del realm, que és el que l'AuthInterceptor de 06-01 verifica. No s'ha de fer servir l'ID token per cridar les APIs, ni l'access token per saber qui hi ha a la web.
  • Discovery: GET https://identitat.km0/realms/km0/.well-known/openid-configuration retorna un JSON amb tots els endpoints (authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, end_session_endpoint) i els algorismes suportats.
  • JWKS (JSON Web Key Set): jwks_uri publica les claus públiques de signatura de l'IdP, cadascuna amb el seu kid. Això substitueix el fitxer .pub copiat a mà de 06-01: els serveis descarreguen les claus, les guarden a la memòria cau, i quan veuen un kid desconegut les refresquen. La rotació de claus de signatura passa a ser automàtica.

El flux complet amb authorization code + PKCE, a Quilòmetre Zero:

sequenceDiagram
    participant N as Navegador (Anna)
    participant W as web-km0 (backend)
    participant K as Keycloak (realm km0)
    participant P as comandes
    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 (contrasenya + OTP si està activat)
    N->>K: credencials
    K-->>N: 302 redirect_uri?code=abc&state=...
    N->>W: GET /callback?code=abc&state=...
    W->>W: comprova 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 (en memòria cau)
    W->>W: verifica id_token: signatura (kid), iss, aud=web-km0, exp, nonce
    W-->>N: galeta de sessió HttpOnly
    N->>W: GET /les-meves-comandes
    W->>P: GET /comandes  Authorization: Bearer access_token
    P->>P: verifica amb JWKS, aud=comandes, roles (06-01)
    P-->>W: comandes de u-anna

Fixa't que web-km0 és un client confidencial (té backend i secret) i tot i així fa servir PKCE: no costa res i OAuth 2.1 ho exigeix. app-repartidors és públic i fa servir només PKCE.

  1. LDAP, SAML i OAuth2/OIDC comparats

LDAP SAML 2.0 OAuth 2.0 / OIDC
Què és Protocol de directori (consulta i bind) Protocol de SSO amb assertions XML Marc de delegació (OAuth) + capa d'identitat (OIDC) amb JWT
Any 1993 (v3: 1997) 2005 2012 / 2014
Format Binari (ASN.1/BER) XML signat (XML-DSig) JSON, JWT
Transport TCP 389/636, connexió directa app → directori Navegador (redireccions, POST) HTTPS, navegador per als fluxos d'usuari, directe per a màquina
L'app veu la contrasenya? Sí No No
Mòbils i APIs Malament Malament (XML pesat, sense tokens per a APIs) Dissenyat per a això
Màquina a màquina Bind de compte tècnic No Client credentials
Atributs de l'usuari Tots els del directori A l'assertion Claims de l'ID token i userinfo
Paper a Quilòmetre Zero Font d'empleats i grups per a l'IdP Integració amb IdPs corporatius externs Tota la resta: web, app, serveis

No competeixen: el patró habitual és LDAP darrere de l'IdP, SAML o OIDC davant, i OIDC per a tot el que és nou.

  1. Federació i identitats socials

Federar és acceptar identitats d'un altre IdP. Keycloak actua com a broker: la Formatgeria Montblanc prem "Entra amb Google", Keycloak redirigeix a Google (que és al seu torn un IdP OIDC), rep l'ID token de Google, el verifica, i crea o enllaça un compte local al realm km0 amb sub propi. Les aplicacions de Quilòmetre Zero només veuen tokens de Keycloak: no saben ni els importa si l'usuari ha entrat amb contrasenya, amb Google o amb Apple.

Avantatges: menys contrasenyes per gestionar, registre amb un clic, MFA delegada al proveïdor social. Precaucions: el correu no verificat d'alguns proveïdors no s'ha de fer servir per enllaçar automàticament amb un compte existent (un atacant registra al proveïdor X un correu aliè i hereta el compte); Keycloak l'obliga a verificar. I els comptes d'empleats no es federen mai amb proveïdors socials: vénen del LDAP corporatiu, amb MFA obligatori.

  1. Cicle de vida, MFA i recuperació

Una identitat no és un registre estàtic:

  • Alta (onboarding): els clients s'autoregistren (amb verificació de correu); els productors són aprovats per un operador (rol productor i grup del productor, apartat 10); els empleats apareixen a LDAP des de RR. HH. i Keycloak els sincronitza. SCIM (System for Cross-domain Identity Management) és l'estàndard REST perquè un sistema de RR. HH. o un IdP creï, modifiqui i elimini usuaris a altres aplicacions automàticament; és el que evita l'"algú l'ha de donar d'alta a mà a cinc llocs".
  • Canvis de rol: la Marta passa d'atenció al client a operacions: canvia el seu grup a LDAP, Keycloak ho reflecteix a la següent sincronització o al següent login, i el seu proper token porta els rols nous. Amb tokens de 15 minuts (06-01), la latència màxima és aquesta.
  • Baixa (offboarding): desactivar a LDAP o a Keycloak, revocar sessions i refresh tokens. Els access tokens vius caduquen en minuts. És la raó per la qual les baixes es fan a l'IdP i mai aplicació per aplicació.
  • Revisió periòdica (access review): llistar qui té operador i confirmar que continua sent cert. Els permisos només creixen si ningú no els revisa.
  • MFA: obligatòria per a operadors i productors (TOTP amb una app, o WebAuthn/passkeys); opcional però fomentada per als clients. Es configura un cop a l'IdP i s'aplica a tot. Keycloak la imposa per flux d'autenticació i per rol.
  • Recuperació: el punt més atacat. Enllaç d'un sol ús per correu amb caducitat curta, mai preguntes de seguretat, verificació addicional per a comptes amb MFA (codis de recuperació), i registre de cada recuperació a l'auditoria de 06-05. Un flux de recuperació feble anul·la la millor MFA.

  1. Quilòmetre Zero: Keycloak com a IdP i OpenLDAP per als empleats

Afegim dos serveis a docker-compose.yml. Keycloak necessita la seva pròpia base de dades; OpenLDAP s'inicialitza amb un LDIF amb els empleats d'exemple:

# km0/docker-compose.yml (fragment). Contrasenyes en clar només per a desenvolupament: 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:
      - ./identitat/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: "Quilometre Zero"
      LDAP_DOMAIN: "km0.internal"
      LDAP_ADMIN_PASSWORD: "admin"
      LDAP_READONLY_USER: "true"
      LDAP_READONLY_USER_USERNAME: "keycloak-bind"
      LDAP_READONLY_USER_PASSWORD: "nomes-lectura"
      LDAP_TLS: "true"
    volumes:
      - ./identitat/empleats.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-empleats.ldif:ro
    ports: [ "636:636" ]

volumes:
  pg_keycloak:

start-dev i --hostname=http://localhost:8080 són de desenvolupament: en producció, Keycloak va darrere de TLS amb start i un hostname real. El fitxer identitat/empleats.ldif crea l'arbre de l'apartat 3:

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

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

dn: uid=jsala,ou=persones,dc=km0,dc=internal
objectClass: inetOrgPerson
uid: jsala
cn: Jordi Sala
sn: Sala
mail: [email protected]
title: Operacions
userPassword: {SSHA}...   # generat amb slappasswd; només desenvolupament

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

dn: cn=operadors,ou=grups,dc=km0,dc=internal
objectClass: groupOfNames
cn: operadors
member: uid=jsala,ou=persones,dc=km0,dc=internal
member: uid=mpuig,ou=persones,dc=km0,dc=internal

I el realm, exportat a identitat/realm-km0.json (Keycloak l'importa en arrencar). L'essencial, en forma de taula perquè el JSON complet ocupa centenars de línies:

Element Configuració
Realm km0; tokens d'accés de 15 min; refresh de 30 dies amb rotació (revoke refresh token: ON); signatura RS256 amb rotació de claus gestionada per Keycloak (kid al JWKS)
Rols de realm client, productor, repartidor, operador (els de 06-01)
Grups /productors/horta-la-vega, /productors/formatgeria-montblanc, /productors/celler-roure-alt: cada grup assigna el rol productor i l'atribut productor_id amb l'slug; /repartidors/furgoneta-3 assigna repartidor
Client web-km0 Confidencial; Standard flow (authorization code) amb PKCE S256 obligatori; redirect_uris: ["https://km0.example/callback"]; scopes openid profile email + comandes cataleg
Client app-repartidors Públic; authorization code + PKCE; redirect_uris: ["km0://callback"]; scope repartiment
Client comandes-servei Confidencial; només service accounts (client credentials), sense fluxos d'usuari; rol de servei svc-comandes; audiència inventari
Mappers roles del realm → claim roles (llista plana); atribut de grup productor_id → claim productor_id; scope comandes → afegeix comandes a aud; scope cataleg → cataleg a aud
Federació d'usuaris OpenLDAP: ldaps://openldap:636, bind dn: cn=keycloak-bind,dc=km0,dc=internal, users dn: ou=persones,dc=km0,dc=internal, mode READ_ONLY, sincronització periòdica; group mapper ou=grups → grups de Keycloak; el grup operadors assigna el rol operador
Proveïdors d'identitat Google (OIDC), només per a usuaris de la base local; "confiar en el correu" desactivat
Autenticació Flux browser amb OTP obligatori per als rols operador i productor, opcional per a client; recuperació per correu amb enllaç de 15 min

Amb docker compose up -d keycloak openldap, la consola a http://localhost:8080 mostra el realm km0, i curl http://localhost:8080/realms/km0/.well-known/openid-configuration retorna el document de discovery. En Jordi i la Marta apareixen com a usuaris federats des de LDAP, amb el rol operador heretat del grup; cap contrasenya seva no és a Keycloak.

El serveis/identitat/tokens.py de 06-01 queda retirat: Keycloak emet. El que sobreviu és serveis/comu/auth.py, amb una única modificació: en lloc del .pub en fitxer, fa servir el JWKS.

  1. Codi: client OIDC amb PKCE, client credentials i cerca LDAP

Verificació amb JWKS (actualitza serveis/comu/auth.py)

# km0/serveis/comu/auth.py (fragment actualitzat)
import jwt
from jwt import PyJWKClient

ISS = "http://localhost:8080/realms/km0"           # en producció: https://identitat.km0/realms/km0
_jwks = PyJWKClient(f"{ISS}/protocol/openid-connect/certs", cache_keys=True,
                    lifespan=3600)                 # guarda les claus a la memòria cau; refresca si veu un kid nou

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

L'AuthInterceptor d'inventari i el decorador requereix_rol no canvien ni una línia: els tokens de Keycloak porten roles i productor_id gràcies als mappers.

serveis/identitat/oidc_client.py: authorization code + PKCE

Aquest mòdul és el que fa servir el backend de web-km0 (i, amb les diferències comentades, l'app de repartidors). S'implementa amb requests perquè es vegi cada pas; authlib ho embolcalla en tres crides i és el que es faria servir en producció.

# km0/serveis/identitat/oidc_client.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: arriba des del gestor de secrets (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]:
    """Pas 1: construeix l'URL d'autorització. Retorna l'URL i el que cal
    desar a la sessió del navegador (state, nonce, verifier) per al pas 2."""
    state = _b64url(secrets.token_bytes(24))          # anti-CSRF: ha de tornar igual
    nonce = _b64url(secrets.token_bytes(24))          # antirepetició: anirà dins de l'id_token
    verifier = _b64url(secrets.token_bytes(32))       # PKCE: secret d'un sol ús
    challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
    params = {
        "client_id": CLIENT_ID, "response_type": "code",
        "scope": "openid profile email comandes cataleg",
        "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, pendent: dict) -> dict:
    """Pas 2: a /callback. Comprova state, bescanvia el codi i valida l'ID token."""
    if not secrets.compare_digest(query.get("state", ""), pendent["state"]):
        raise ValueError("state no coincideix: possible 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,               # l'app de repartidors no l'envia
        "code_verifier": pendent["verifier"],         # PKCE: demostra que som qui va començar
    }, timeout=5)
    resp.raise_for_status()
    tokens = resp.json()

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

    return {"sub": id_claims["sub"], "email": id_claims.get("email"),
            "nom": id_claims.get("name"),
            "access_token": tokens["access_token"],     # per cridar comandes/cataleg
            "refresh_token": tokens["refresh_token"],   # desar al servidor, mai al 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()          # porta un refresh_token nou: rotació (desar-lo, descartar el vell)

Integrat a Flask, /login crida iniciar_login, desa pendent a la sessió i redirigeix; /callback crida completar_login, desa sub, access_token i refresh_token a la sessió de servidor (Redis, 06-01) i redirigeix a la pàgina. El navegador només té la galeta HttpOnly de la sessió.

Comprovació ràpida des del terminal, amb un usuari anna creat al realm:

python -c "from serveis.identitat.oidc_client import iniciar_login; print(iniciar_login()[0])"
# Obre l'URL al navegador, entra com a anna, i copia el ?code=...&state=... de la redirecció

Client credentials: comandes obté el seu token de màquina

Quan comandes actua per si mateix (compensació d'una saga, reconciliació nocturna), no hi ha cap token d'usuari per reenviar. Demana el seu:

# km0/serveis/comandes/token_servei.py
import time, requests

TOKEN_URL = "http://keycloak:8080/realms/km0/protocol/openid-connect/token"
CLIENT_ID, CLIENT_SECRET = "comandes-servei", "..."   # secret: 06-04

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

def token_de_servei() -> str:
    """Retorna un access token per a comandes-servei, en memòria cau fins a 60 s abans 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": "inventari"}, timeout=5)
    r.raise_for_status()
    t = r.json()
    _cache.update(token=t["access_token"], exp=time.time() + t["expires_in"])
    return _cache["token"]

# Ús a la saga de compensació (03-05), sense usuari al darrere:
# stub.AlliberarReserva(req, metadata=(("authorization", f"Bearer {token_de_servei()}"),), timeout=2)

El token resultant té sub: service-account-comandes-servei, roles: [svc-comandes], aud: inventari. A ROLS_PER_METODE d'inventari (06-01) s'afegeix svc-comandes a ReservarEstoc i AlliberarReserva. És la primera identitat de màquina real de la plataforma; 06-04 la reforçarà amb un certificat en lloc d'un client_secret.

Cerca LDAP amb ldap3

Els serveis no haurien de parlar amb LDAP (ho fa Keycloak), però un operador sí que necessita de vegades consultar el directori: per exemple, un script intern que llista qui és a operadors per a la revisió d'accessos de l'apartat 9.

# km0/serveis/identitat/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 amb la CA de 06-02
servidor = Server("openldap", port=636, use_ssl=True, tls=tls, get_info=ALL)

BASE = "dc=km0,dc=internal"

def membres_del_grup(grup: str) -> list[dict]:
    with Connection(servidor, user=f"cn=keycloak-bind,{BASE}", password="nomes-lectura",
                    auto_bind=True) as c:                      # bind amb el compte tècnic
        c.search(search_base=f"ou=grups,{BASE}", search_scope=SUBTREE,
                 search_filter=f"(&(objectClass=groupOfNames)(cn={grup}))",
                 attributes=["member"])
        if not c.entries:
            return []
        membres = []
        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]
            membres.append({"uid": str(e.uid), "nom": str(e.cn),
                            "mail": str(e.mail), "lloc": str(e.title)})
        return membres

def autenticar_empleat(uid: str, contrasenya: str) -> bool:
    """Només per il·lustrar el bind d'usuari: a Quilòmetre Zero ho fa Keycloak, mai un servei."""
    with Connection(servidor, user=f"cn=keycloak-bind,{BASE}", password="nomes-lectura",
                    auto_bind=True) as c:
        c.search(f"ou=persones,{BASE}", f"(uid={uid})", attributes=["dn"])
        if not c.entries:
            return False
        dn = c.entries[0].entry_dn
    try:
        Connection(servidor, user=dn, password=contrasenya, auto_bind=True).unbind()
        return True
    except Exception:
        return False

if __name__ == "__main__":
    for m in membres_del_grup("operadors"):
        print(m)
    # {'uid': 'jsala', 'nom': 'Jordi Sala', 'mail': '[email protected]', 'lloc': 'Operacions'}
    # {'uid': 'mpuig', 'nom': 'Marta Puig', 'mail': '[email protected]', 'lloc': 'Atencio al client'}

Dos detalls de seguretat al codi: el filtre de cerca es construeix amb valors controlats (grup ve d'un argument de l'script); si vingués d'una entrada d'usuari caldria escapar-lo (ldap3.utils.conv.escape_filter_chars) per evitar la injecció LDAP, l'equivalent de la injecció SQL. I el compte tècnic és de només lectura: si algú roba nomes-lectura, pot llistar empleats però no modificar grups.

Errors Comuns i Consells

  • Fer servir l'ID token per cridar les APIs (o l'access token per identificar l'usuari a la web). L'ID token té aud: web-km0 i comandes l'ha de rebutjar; l'access token pot ser opac en altres IdPs. Cada token per al seu destinatari.
  • Ometre state o nonce "perquè ja hi ha PKCE". Protegeixen contra atacs diferents: state contra CSRF al callback (que t'iniciïn sessió al compte de l'atacant), nonce contra la reutilització d'un ID token, PKCE contra la intercepció del codi.
  • redirect_uri amb comodins. L'IdP només ha d'acceptar URIs exactes; un comodí permet que el codi acabi en un domini de l'atacant.
  • Flux password "perquè és una app pròpia". Anul·la MFA, el consentiment i la federació, i acostuma l'usuari a teclejar la seva contrasenya a apps. Authorization code + PKCE serveix també per a apps natives.
  • Secrets de client a apps mòbils o SPAs. Són públics per definició; registra'ls com a públics amb PKCE.
  • Aplicacions que fan bind contra LDAP directament. Cadascuna veu contrasenyes i cap no aplica MFA. LDAP darrere de l'IdP.
  • Enllaçar comptes socials per correu no verificat. És la via clàssica d'apropiació de comptes.
  • Baixes aplicació per aplicació. Una baixa és desactivar a l'IdP (o a LDAP) i revocar sessions; si necessites tocar cinc sistemes, el disseny està malament (SCIM existeix per a això).
  • Copiar el JWKS a mà o fixar una sola clau. L'IdP rota claus; fes servir jwks_uri amb memòria cau i refresc per kid desconegut.
  • Consell: exporta el realm de Keycloak (kc.sh export) i versiona'l a identitat/realm-km0.json: la configuració d'identitat és codi, es revisa i es desplega igual que la resta.
  • Consell: prova el flux amb un IdP real en desenvolupament (Keycloak a Compose), no amb un "mode sense autenticació". Les diferències entre entorns en autenticació són la font clàssica de sorpreses en producció.
  • Advertència de compliment normatiu. Les dades d'identitat (correus, noms, grups d'empleats, proveïdors socials enllaçats) són dades personals; el seu tractament, la retenció i la política de MFA i recuperació s'han de revisar amb un professional de seguretat i protecció de dades.

Exercicis

Exercici 1: El codi interceptat

Un repartidor té instal·lada una app maliciosa que registra l'esquema km0:// i captura la redirecció km0://callback?code=XYZ&state=... abans que app-repartidors. Explica què pot fer l'app maliciosa amb aquest codi (a) si app-repartidors fos un client confidencial amb el secret incrustat a l'APK, (b) si fos públic sense PKCE, (c) amb la configuració de l'apartat 10. Indica en cada cas quina comprovació de Keycloak falla o no falla.

Exercici 2: La Marta deixa l'empresa

La Marta Puig (mpuig) deixa Quilòmetre Zero un divendres a les 17:00. Descriu quines accions fa RR. HH./IT, a quin sistema, i què passa amb: la seva sessió oberta al panell d'operadors, el seu refresh token, un access token emès a les 16:55, el seu compte a la wiki interna (que fa servir SAML contra Keycloak) i els seus permisos a analitica. Què hauria d'existir perquè tot això fos una única acció? I què falla si analitica mantingués la seva pròpia taula d'usuaris?

Exercici 3: El DAG d'Airflow i Flink demanen identitat

km0_vendes_diaries (05-05) i el consumidor de Flink de comandes.esdeveniments (05-04) no tenen usuari. Dissenya per a cadascun: el client al realm km0 (nom, tipus, flux, scopes/audiència), els rols de servei, com obtenen i renoven el token, i quina comprovació afegeix inventari o el broker de Kafka. Assenyala quina part d'aquest disseny 06-04 canviarà i per què.

Solucions

Exercici 1.

(a) L'app maliciosa extreu el secret de l'APK (és trivial), bescanvia el codi amb client_id + client_secret i obté els tokens del repartidor: Keycloak no la pot distingir de l'app legítima; cap comprovació no falla. (b) Sense PKCE, un client públic bescanvia el codi només amb client_id i redirect_uri; l'app maliciosa ho fa abans que la legítima i obté els tokens; la legítima rep un error en intentar bescanviar un codi ja usat, que és l'únic senyal, tardà. (c) Amb PKCE S256 obligatori, el bescanvi exigeix el code_verifier el SHA-256 del qual es va enviar com a code_challenge a la petició d'autorització; l'app maliciosa té el codi però no el verificador (mai no ha sortit d'app-repartidors); Keycloak calcula el hash del verificador que enviï (o en rebutja l'absència) i respon invalid_grant. El codi queda inservible; l'app legítima, si és la primera a bescanviar-lo, entra amb normalitat. state no intervé en aquest atac (protegeix el callback contra CSRF, no contra la intercepció).

Exercici 2.

IT desactiva uid=mpuig a OpenLDAP (o RR. HH. ho fa via SCIM/sincronització des del seu sistema). Keycloak, en mode READ_ONLY amb sincronització periòdica, veu el compte desactivat al següent cicle (o immediatament si IT també el desactiva a Keycloak i en revoca les sessions des de la consola, que és el recomanable). La sessió del panell d'operadors: si el panell guarda una sessió de servidor amb l'access token, continua funcionant fins que el token caduqui (16:55 + 15 min = 17:10 com a màxim) o fins que intenti refrescar; el refresh token va ser revocat en tancar les seves sessions a Keycloak, així que el refresh falla i el panell l'expulsa. La wiki amb SAML: la propera vegada que necessiti reautenticar-se (o immediatament si la wiki rep single logout), l'IdP no l'autentica; la seva sessió local a la wiki dura el que duri la seva galeta, i per això les sessions de les aplicacions SAML es configuren curtes. analitica: si valida tokens de Keycloak, no hi ha res a fer allà. Perquè tot sigui una única acció cal que la baixa es faci només a la font (LDAP/RR. HH.) i que flueixi per sincronització o SCIM fins a l'IdP, amb revocació automàtica de sessions. Si analitica mantingués la seva pròpia taula d'usuaris amb contrasenya, la baixa no hi arribaria: la Marta continuaria entrant a analitica dilluns, que és exactament el motiu de l'apartat 1.

Exercici 3.

Airflow: client airflow-servei, confidencial, només service accounts (client credentials), scope/audiència analitica i hdfs (si el llac es protegeix amb tokens; a la pràctica HDFS fa servir Kerberos, fora del curs), rol de servei svc-analitica-escriptura; el DAG obté el token en un PythonOperator inicial (o un hook que el guardi a la memòria cau com token_de_servei) i, com que les tasques duren més de 15 minuts, el renova per client credentials quan caduca (sense refresh token: client credentials es repeteix). Flink: client flink-comandes-consumidor, confidencial, client credentials, audiència kafka, rol svc-consumidor-comandes; el broker de Kafka es configura amb SASL_SSL i el mecanisme OAUTHBEARER, que valida el JWT contra el JWKS de Keycloak i aplica ACL: aquest principal només pot llegir comandes.esdeveniments amb el seu grup de consumidors, i no escriure enlloc. inventari afegeix svc-analitica-... als mètodes de només lectura si Airflow el consulta. El que 06-04 canvia: el client_secret de tots dos viu avui a la configuració d'Airflow i del job de Flink, és a dir, en un fitxer o variable d'entorn; se substituirà per autenticació amb certificat de client (mTLS) o per un secret injectat i rotat per Vault, i les credencials de PostgreSQL del DAG passaran a ser dinàmiques.

Conclusió

Centralitzar la identitat vol dir que existeix un únic lloc on es decideix qui existeix, com s'autentica i quins atributs té, i que les aplicacions consumeixen proves signades en lloc de contrasenyes. LDAP és el directori: un arbre d'entrades amb dn, unitats organitzatives, grups i bind, ideal com a font d'empleats darrere de l'IdP i pèssim com a protocol de login d'aplicacions. SAML 2.0 és el SSO empresarial per assertions XML, encara necessari per integrar IdPs corporatius. OAuth 2.0 delega accés amb scopes i fluxos, dels quals només tres importen avui (authorization code amb PKCE, client credentials i refresh), i OpenID Connect li afegeix la identitat: un ID token per a l'aplicació, un access token per a les APIs, discovery per configurar-se sol i JWKS perquè la verificació RS256 de 06-01 roti claus sense intervenció. La federació amb identitats socials, el cicle de vida amb SCIM, la MFA per rol i una recuperació acurada completen el quadre. Quilòmetre Zero té ara Keycloak amb el realm km0, els clients web-km0, app-repartidors i comandes-servei, rols i grups per productor, OpenLDAP amb en Jordi i la Marta, oidc_client.py amb PKCE i validació contra JWKS, token_de_servei() per a comandes, i una consulta LDAP per a la revisió d'accessos.

I tanmateix, dues coses continuen sent fràgils. comandes-servei s'identifica amb un client_secret, que és una contrasenya de màquina desada en algun lloc; inventari accepta aquesta identitat perquè el token és vàlid, però no té manera de saber si la connexió TLS que li arriba ve de debò del contenidor de comandes o de qualsevol procés que hagi robat el secret. I la contrasenya de km0_analitica continua en una variable d'entorn del DAG, tal com la va deixar 05-05. La següent lliçó abandona la idea de perímetre: cada servei tindrà el seu propi certificat, cada connexió s'autenticarà en tots dos sentits (mTLS), l'autorització es farà també per identitat de servei, i els secrets deixaran d'escriure's en fitxers per viure a Vault, amb credencials dinàmiques que caduquen soles.

Curs d'Arquitectures Distribuïdes

Mòdul 1: Introducció als Sistemes Distribuïts

Mòdul 2: Comunicació en Sistemes Distribuïts

Mòdul 3: Consistència i Replicació

Mòdul 4: Emmagatzematge Distribuït

Mòdul 5: Computació Distribuïda

Mòdul 6: Seguretat en Sistemes Distribuïts

Mòdul 7: Monitoratge i Manteniment

Mòdul 8: Casos d'Estudi i Aplicacions

© Copyright 2026. Tots els drets reservats