Arriba un correu a l'equip de la Botiga Aroma. CataBox, una aplicació de tercers que ajuda els aficionats al cafè a portar un quadern de tastos, vol oferir als seus usuaris que importin automàticament els cafès que han comprat a Aroma. La petició tècnica és simple: «podem llegir les comandes d'un client vostre?».

Amb l'autenticació que vam construir a 03-06 només hi ha una resposta possible, i és dolenta: que el client doni a CataBox el seu correu i la seva contrasenya de la Botiga Aroma, i que CataBox cridi POST /v1/sessions fent-se passar per ell. Això significa que una empresa que no controles desa les contrasenyes dels teus clients, que el token que obté té tots els permisos —inclòs crear comandes i canviar l'adreça d'enviament—, que no li pots revocar l'accés sense obligar el client a canviar la contrasenya, i que no tens manera de saber quines peticions són del client i quines de CataBox.

OAuth 2.0 existeix exactament per a això. Aquesta lliçó explica quin problema resol, com s'articula, quins fluxos es fan servir avui i quins estan desaconsellats, com OpenID Connect hi afegeix a sobre la peça que li falta —la identitat—, i com es valida un token d'un servidor d'autorització extern a la nostra API. En acabar, src/middleware/autenticacio-oauth.js existirà i conviurà amb l'autenticar de 03-06.

Advertiment. OAuth 2.0 és un protocol de seguretat i els seus detalls importen: un paràmetre mal validat converteix un flux correcte en una presa de comptes. El codi d'aquesta lliçó és didàctic i l'ha de revisar un professional de seguretat abans d'un desplegament real. Totes les dades, dominis i credencials són ficticis.

Contingut

  1. El problema: delegar accés sense compartir credencials
  2. Els quatre rols d'OAuth 2.0
  3. Tokens: accés, refresc i àmbits
  4. Tokens opacs enfront de JWT: introspecció o validació local
  5. Authorization Code + PKCE
  6. Per què el flux implícit i el de contrasenya estan desaconsellats
  7. Client Credentials: RàpidEnviaments, màquina a màquina
  8. Refresh Token i Device Code
  9. state, CSRF i els paràmetres de la petició d'autorització
  10. OpenID Connect: la identitat a sobre d'OAuth
  11. Validar el token a l'API: JWKS, kid i les claims
  12. El middleware autenticarOAuth i la seva convivència amb autenticar
  13. Àmbits i rols combinats
  14. Registre de clients, secrets i redirect_uri
  15. Revocació i tancament de sessió
  16. Per què no has d'implementar el teu propi servidor d'autorització
  17. Errors d'OAuth i la seva traducció al catàleg d'Aroma

  1. El problema: delegar accés sense compartir credencials

Abans d'OAuth, el patró habitual s'anomenava antipatró de la contrasenya compartida, i es veia així:

Contrasenya compartida OAuth 2.0
Què desa CataBox Correu i contrasenya del client Un token que caduca
Què pot fer Tot el que pot fer el client Només allò autoritzat (comandes.llegir)
Quant dura Per sempre Minuts o hores, amb refresc revocable
Com es revoca Canviant la contrasenya (i trencant tota la resta) Un clic a «aplicacions connectades»
Auditoria Impossible distingir client d'aplicació Cada petició identifica el client OAuth
Segon factor Incompatible: CataBox no el pot passar Compatible: el resol el servidor d'autorització
Si CataBox pateix una bretxa Les contrasenyes dels teus clients són a fora Es revoquen els tokens i s'ha acabat

La idea central d'OAuth 2.0 cap en una frase: el client mai no veu les credencials; l'usuari s'autentica en un lloc de confiança i el que torna és un permís acotat i revocable.

I una precisió que evita el 90 % de la confusió inicial: OAuth 2.0 és un protocol d'autorització delegada, no d'autenticació. No serveix per a «iniciar sessió amb», per molt que s'utilitzi així a tot arreu. El que serveix per a això és OpenID Connect, que es construeix a sobre (apartat 10).

  1. Els quatre rols d'OAuth 2.0

Rol Nom a l'estàndard Qui és en el nostre cas
Propietari del recurs Resource Owner Marta Garcia (cli_842), la persona propietària de les comandes
Client Client CataBox, l'aplicació que hi vol accedir. També la SPA i Aroma Mòbil
Servidor d'autorització Authorization Server (AS) https://auth.botigaaroma.example — autentica i emet tokens
Servidor de recursos Resource Server (RS) https://api.botigaaroma.example/v1la nostra API

La conseqüència pràctica més important per a aquest curs: la nostra API és només el servidor de recursos. No mostra pantalles d'inici de sessió, no gestiona contrasenyes, no emet tokens i no sap res de consentiments. La seva única feina a OAuth és:

  1. Rebre un Authorization: Bearer <token>.
  2. Verificar que aquell token és autèntic, vigent i està adreçat a ella.
  3. Extreure qui és el subjecte i quins àmbits té.
  4. Decidir si aquella combinació pot fer el que demana.

Això és tot. Res més d'aquesta lliçó no s'implementa dins de la nostra API, i aquesta separació és precisament el valor del protocol.

graph LR
  U[Marta - propietari del recurs] -->|1. autoritza| AS[auth.botigaaroma.example<br/>Servidor d'autoritzacio]
  C[CataBox - client] -->|2. demana token| AS
  AS -->|3. emet access token| C
  C -->|4. Bearer token| RS[api.botigaaroma.example<br/>Servidor de recursos: LA NOSTRA API]
  RS -->|5. valida signatura amb JWKS| AS
  RS -->|6. dades autoritzades| C

Fixa't en el pas 5: l'API no pregunta al servidor d'autorització a cada petició. Es descarrega les seves claus públiques una vegada, les desa a la memòria cau i les verifica localment. Per què, a l'apartat 4.

  1. Tokens: accés, refresc i àmbits

Access token i refresh token

Access token Refresh token
Per a què serveix Accedir a l'API Obtenir un access token nou
A qui es presenta Al servidor de recursos (la nostra API) Només al servidor d'autorització
Durada típica 5–60 minuts Dies o mesos
S'envia a Authorization: Bearer Cos de POST /token
Si es roba Dany acotat pel temps de vida Greu: accés perllongat
La nostra API el veu Sí, a cada petició Mai

La raó que n'existeixin dos és un compromís entre seguretat i usabilitat: vols que el token que viatja constantment per la xarxa caduqui aviat, però no vols demanar la contrasenya a l'usuari cada quinze minuts. El refresh token viatja poc, es desa millor i es pot revocar de manera centralitzada.

Aquesta arquitectura és la mateixa que ja vam construir a mà a 03-06 amb els nostres propis tokens d'accés i de refresc. La diferència és qui els emet: allà els emetia la nostra API; aquí els emet un servidor d'autorització especialitzat, i la nostra API només els verifica.

Àmbits (scopes)

Un àmbit és una etiqueta que acota el que un token permet fer. Es demanen a la petició d'autorització, l'usuari els veu a la pantalla de consentiment i viatgen dins del token.

Els de la Botiga Aroma:

Àmbit Permet Qui el demana Text de consentiment
cafes.llegir Consultar el catàleg Tots «Veure el catàleg de cafès»
comandes.llegir Llegir les comandes de l'usuari CataBox, SPA, Aroma Mòbil «Veure el teu historial de comandes»
comandes.escriure Crear i pagar comandes SPA, Aroma Mòbil «Crear comandes en nom teu»
ressenyes.escriure Publicar ressenyes SPA, Aroma Mòbil «Publicar ressenyes amb el teu nom»
ressenyes.moderar Aprovar o rebutjar ressenyes Tauler intern «Moderar ressenyes de la botiga»
enviaments.escriure Marcar comandes com a enviades RàpidEnviaments (sense consentiment: màquina)

Quatre regles de disseny d'àmbits que eviten la majoria dels problemes:

  1. Granularitat recurs.accio, no un àmbit per endpoint. Amb 24 URIs, un àmbit per endpoint produeix una pantalla de consentiment illegible.
  2. Separar lectura d'escriptura sempre. CataBox demana comandes.llegir i mai comandes.escriure. Aquesta separació és el 80 % del valor.
  3. L'àmbit acota, no concedeix. Que un token tingui comandes.llegir no significa que pugui llegir totes les comandes: significa que pot llegir les del subjecte del token. La comprovació de propietat (BOLA, 04-02) continua sent obligatòria.
  4. Redactar el text que veurà l'usuari juntament amb l'àmbit. Si no saps explicar-lo en una línia comprensible, l'àmbit està mal dissenyat.

  1. Tokens opacs enfront de JWT: introspecció o validació local

L'access token pot ser de dues naturaleses, i l'elecció afecta directament com el valida la nostra API.

Token opac JWT (autocontingut)
Què és Una cadena aleatòria: a7f3c9... Tres parts Base64 amb les claims a dins
Qui sap què significa Només el servidor d'autorització Qualsevol que el verifiqui
Com el valida l'API Introspecció: POST /introspect a l'AS Localment: verifica la signatura
Latència per petició Una crida de xarxa extra Zero
Revocació immediata No: val fins al seu exp
Fuita del contingut No revela res El payload és llegible (no xifrat!)
Acoblament L'API depèn de l'AS en calent L'API només necessita les claus públiques

La introspecció (RFC 7662) es veu així:

POST /introspect HTTP/1.1
Host: auth.botigaaroma.example
Authorization: Basic <credencials del servidor de recursos>
Content-Type: application/x-www-form-urlencoded

token=a7f3c9d2e8b1...
{
  "active": true,
  "sub": "cli_842",
  "scope": "cafes.llegir comandes.llegir",
  "client_id": "catabox",
  "exp": 1786000000
}

El camp decisiu és active: si és false, el token no val, sense més explicacions.

Què tria la Botiga Aroma: JWT amb validació local, per tres raons. La latència importa (una crida extra per petició multiplica per dos el temps de resposta del catàleg); la disponibilitat importa (si l'AS cau, amb introspecció cau tota l'API); i la revocació immediata l'aconseguim per una altra via, amb tokens de vida curta (15 minuts) i revocació del refresh token.

El compromís: un access token JWT revocat continua sent vàlid fins que caduqui. Si això és inacceptable per a alguna operació —anul·lar una comanda, canviar la contrasenya—, es fa introspecció només per a aquelles operacions, o es consulta una llista de revocació a Redis. És una decisió per endpoint, no global.

I un recordatori que mai no sobra: un JWT està signat, no xifrat. Qualsevol que intercepti el token en llegeix el contingut amb un descodificador Base64. Mai no fiquis dades sensibles a les claims.

  1. Authorization Code + PKCE

És el flux. Si només en recordes un d'aquesta lliçó, que sigui aquest: serveix per a SPA, per a aplicacions mòbils, per a aplicacions de servidor i per a aplicacions de tercers com CataBox.

PKCE (Proof Key for Code Exchange, es pronuncia «pixy») és l'extensió que el fa segur per a clients que no poden desar un secret. Avui es considera obligatori per a tots els clients, inclosos els confidencials.

sequenceDiagram
  participant U as Marta (navegador)
  participant C as CataBox
  participant AS as auth.botigaaroma.example
  participant RS as api.botigaaroma.example

  C->>C: 1. Genera code_verifier aleatori
  C->>C: 2. code_challenge = BASE64URL(SHA256(verifier))
  C->>U: 3. Redirigeix a /authorize amb code_challenge i state
  U->>AS: 4. GET /authorize?...
  AS->>U: 5. Pantalla de login (contrasenya + 2FA)
  U->>AS: 6. Credencials
  AS->>U: 7. Pantalla de consentiment: "CataBox vol veure les teves comandes"
  U->>AS: 8. Accepta
  AS->>U: 9. Redirigeix a redirect_uri?code=xyz&state=...
  U->>C: 10. Lliura el code
  C->>AS: 11. POST /token amb code + code_verifier
  AS->>AS: 12. Comprova SHA256(verifier) == challenge
  AS->>C: 13. access_token + refresh_token
  C->>RS: 14. GET /v1/comandes amb Bearer
  RS->>RS: 15. Verifica signatura (JWKS), iss, aud, exp, scope
  RS->>C: 16. 200 amb les comandes de la Marta

Vegem les peticions reals.

Pas 3-4: la petició d'autorització. Passa al navegador de l'usuari, no al servidor de CataBox:

GET /authorize
  ?response_type=code
  &client_id=catabox
  &redirect_uri=https%3A%2F%2Fcatabox.example%2Fcallback
  &scope=cafes.llegir%20comandes.llegir
  &state=xY9fK2mQ7pL1
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256 HTTP/1.1
Host: auth.botigaaroma.example

Pas 9: la redirecció de tornada. El codi arriba per la URL, i és d'un sol ús i de vida molt curta (típicament 30–60 segons):

HTTP/1.1 302 Found
Location: https://catabox.example/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xY9fK2mQ7pL1

Pas 11: el bescanvi del codi pel token. Això és una petició de servidor a servidor (o des de l'app), mai una redirecció:

POST /token HTTP/1.1
Host: auth.botigaaroma.example
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fcatabox.example%2Fcallback
&client_id=catabox
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFyb21hLTIwMjYtMDgifQ...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "def50200a1b2c3...",
  "scope": "cafes.llegir comandes.llegir"
}

Què resol exactament PKCE

El codi d'autorització viatja per la URL del navegador, i això és un canal poc segur: queda a l'historial, pot aparèixer als logs d'un proxy i, al mòbil, una altra aplicació pot registrar el mateix esquema d'URI i robar la redirecció. Abans de PKCE, un atacant que capturés el codi podia bescanviar-lo per un token, perquè l'AS no tenia manera de saber que qui el bescanvia no és qui el va demanar.

PKCE lliga les dues peticions amb un secret d'un sol ús:

// Client (SPA o app mòbil). S'executa ABANS de redirigir l'usuari.
import crypto from 'node:crypto';

function base64url(buffer) {
  return buffer.toString('base64')
    .replace(/\+/g, '-')      // Base64URL: + → -
    .replace(/\//g, '_')      //             / → _
    .replace(/=+$/, '');      //  sense farciment
}

// 1. Un secret aleatori de 32 bytes, diferent a cada intent de login.
const codeVerifier = base64url(crypto.randomBytes(32));

// 2. El seu hash: és l'ÚNIC que viatja per la URL del navegador.
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());

// 3. El verifier es desa localment (sessionStorage a la SPA) i encara NO s'envia.
sessionStorage.setItem('pkce_verifier', codeVerifier);

// 4. Només al pas 11, juntament amb el codi, s'envia el verifier original.
//    L'AS calcula SHA256(verifier) i el compara amb el challenge que va rebre al pas 4.

La propietat criptogràfica que ho fa funcionar: SHA-256 no es pot invertir. Un atacant que vegi el code_challenge a la URL no pot deduir el code_verifier, així que encara que robi el codi no el pot bescanviar. I code_challenge_method ha de ser sempre S256; el valor plain existeix per compatibilitat i no protegeix de res.

  1. Per què el flux implícit i el de contrasenya estan desaconsellats

Els trobaràs en tutorials antics. Les millors pràctiques actuals d'OAuth 2.0 (i OAuth 2.1) els retiren.

Flux Com funcionava Per què es retira Què fer servir
Implícit (response_type=token) L'AS retornava l'access token directament al fragment de la URL El token queda a l'historial, al Referer i exposat a qualsevol script de la pàgina; sense refresh token segur Authorization Code + PKCE
Contrasenya del propietari (password) L'app demana usuari i contrasenya i els envia a l'AS Reintrodueix l'antipatró que OAuth va venir a eliminar; incompatible amb 2FA i amb inici de sessió federat Authorization Code + PKCE

El cas del flux de contrasenya mereix un matís perquè genera dubtes legítims: es va crear per a aplicacions de primera part, és a dir, teves. Però fins i tot allà és mala idea, perquè la teva pròpia app acaba gestionant contrasenyes, no pot passar per un segon factor i no es beneficia de res de la infraestructura de l'AS. La SPA de la Botiga Aroma i Aroma Mòbil fan servir Authorization Code + PKCE igual que CataBox, encara que siguin nostres. La diferència entre app pròpia i de tercers és que a la pròpia se li pot saltar la pantalla de consentiment, no que faci servir un altre flux.

  1. Client Credentials: RàpidEnviaments, màquina a màquina

Quan no hi ha cap usuari implicat, no hi ha a qui demanar consentiment. RàpidEnviaments és un sistema que actua en nom propi per marcar comandes com a enviades.

sequenceDiagram
  participant R as RapidEnviaments (backend)
  participant AS as auth.botigaaroma.example
  participant RS as api.botigaaroma.example

  R->>AS: POST /token (grant_type=client_credentials + client_secret)
  AS->>R: access_token (scope=enviaments.escriure, 1 h)
  R->>RS: POST /v1/comandes/com_5001/enviament (Bearer)
  RS->>RS: Verifica signatura, aud, scope=enviaments.escriure
  RS->>R: 200
POST /token HTTP/1.1
Host: auth.botigaaroma.example
Authorization: Basic cmFwaWRlbnZpYW1lbnRzOnNlY3JldEZpY3RpY2k=
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=enviaments.escriure

Tres característiques que el distingeixen:

  • No hi ha redirect_uri, ni code, ni consentiment: només el client i el seu secret.
  • No hi ha refresh token: quan l'access token caduca, se'n demana un altre. És barat perquè no requereix ningú al davant.
  • El sub del token és el mateix client (rapidenviaments), no una persona. A la nostra API això correspon al rol soci, i significa que les comprovacions de propietat per client no s'apliquen: cal autoritzar per una altra via (quines comandes pot tocar RàpidEnviaments i en quins estats).

Un client que fa servir aquest flux és confidencial per definició: desa un secret en un servidor. Mai no es fa servir en una SPA ni en una app mòbil, perquè qualsevol pot extreure el secret del codi descarregat.

  1. Refresh Token i Device Code

Refresh Token

POST /token HTTP/1.1
Host: auth.botigaaroma.example
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=def50200a1b2c3...
&client_id=catabox

La resposta és un access token nou i, a les implementacions modernes, també un refresh token nou. Això s'anomena rotació de refresh tokens i és una defensa important: si un refresh token es fa servir dues vegades, l'AS sap que un dels dos usos és d'un atacant —perquè el legítim ja va rebre el substitut— i revoca tota la família de tokens, tancant la sessió. És la detecció de reutilització, i convé exigir-la en triar proveïdor.

On es desa el refresh token, per tipus de client:

Client Emmagatzematge Risc
Backend (CataBox) Base de dades xifrada Baix
App mòbil Clauer del sistema (Keychain / Keystore) Baix
SPA Galeta HttpOnly+Secure+SameSite, gestionada per un backend propi Mitjà
SPA localStorage Alt: qualsevol XSS el roba

Per a SPA, la recomanació actual és el patró BFF (Backend For Frontend): un petit backend propi desa els tokens i la SPA hi parla mitjançant una galeta de sessió. Si això no és viable, refresh tokens rotatius amb vida curta i mai a localStorage.

Device Code

Per a dispositius sense teclat ni navegador —una pantalla de la cafeteria que mostra l'estoc, un televisor—. El dispositiu demana un codi, mostra a l'usuari «entra a botigaaroma.example/activar i introdueix KDLF-9XZQ», i mentrestant consulta l'AS fins que l'usuari completa l'autorització al seu mòbil. Es menciona per completesa; no s'aplica als consumidors actuals de la Botiga Aroma.

  1. state, CSRF i els paràmetres de la petició d'autorització

Paràmetre Obligatori Què és Risc si s'omet o no es valida
response_type code
client_id Identificador públic del client
redirect_uri Sí (recomanat) On tornar Redirecció oberta si l'AS no la compara exacta
scope Recomanat Permisos demanats S'apliquen els del registre
state Valor aleatori opac CSRF d'inici de sessió
code_challenge Hash del verifier Robatori del codi
code_challenge_method S256 plain no protegeix
nonce Sí a OIDC Aleatori, torna a l'id_token Reproducció d'id_token
prompt No login, consent, none

Quin atac evita state. Sense ell, un atacant inicia el flux amb el seu compte de la Botiga Aroma, captura el code de la redirecció i enganya la víctima perquè el seu navegador visiti https://catabox.example/callback?code=<el de l'atacant>. CataBox bescanvia aquell codi i associa el compte de la Botiga Aroma de l'atacant a la sessió de CataBox de la víctima. A partir d'aquí, tot el que la víctima desi a CataBox va a un compte que l'atacant controla.

La defensa és un valor aleatori lligat a la sessió del navegador:

// Abans de redirigir: es genera i es desa lligat a la sessió.
const state = base64url(crypto.randomBytes(16));
sessionStorage.setItem('oauth_state', state);
// ... redirecció a /authorize?...&state=<state>

// Al callback: comparació OBLIGATÒRIA abans de bescanviar el codi.
const rebut = new URLSearchParams(location.search).get('state');
const esperat = sessionStorage.getItem('oauth_state');
if (!rebut || rebut !== esperat) {
  throw new Error('state no coincideix: possible CSRF. No es bescanvia el codi.');
}
sessionStorage.removeItem('oauth_state');   // un sol ús

Amb PKCE ben implementat el risc es redueix molt, però state continua sent obligatori: protegeix d'un atac diferent (la fixació de la sessió del client, no el robatori del codi) i a més serveix per recordar on tornar dins de l'aplicació.

  1. OpenID Connect: la identitat a sobre d'OAuth

OAuth 2.0 respon a «pot aquesta aplicació fer això?». No respon a «qui és l'usuari?». Fer servir un access token com a prova d'identitat és un error clàssic i perillós: el token no diu res verificable sobre qui el va obtenir ni per a quina aplicació es va emetre, i per això van existir els atacs de substitució de token.

OpenID Connect (OIDC) és una capa fina i estandarditzada sobre OAuth 2.0 que afegeix exactament el que falta.

OAuth 2.0 OpenID Connect
Pregunta Què pot fer? Qui és?
Àmbit clau comandes.llegir openid
Retorna access_token access_token + id_token
Format del resultat Lliure id_token és sempre un JWT
Destinatari El servidor de recursos El client
Descobriment /.well-known/openid-configuration

N'hi ha prou d'afegir openid al scope perquè el flux es converteixi en OIDC:

scope=openid profile email cafes.llegir comandes.llegir

L'id_token descodificat:

{
  "iss": "https://auth.botigaaroma.example",
  "sub": "cli_842",
  "aud": "catabox",
  "exp": 1786003600,
  "iat": 1786000000,
  "nonce": "n-0S6_WzA2Mj",
  "auth_time": 1785999950,
  "name": "Marta Garcia",
  "email": "[email protected]",
  "email_verified": true,
  "locale": "ca-ES"
}

Les claims estàndard més utilitzades:

Claim Significat
sub Identificador estable i únic de l'usuari en aquell emissor. La clau real
iss Qui va emetre el token
aud Per a quin client es va emetre
nonce L'aleatori que va enviar el client: evita reproduir un id_token vell
auth_time Quan es va autenticar realment (útil per exigir reautenticació)
name, email, picture Perfil (amb scope=profile email)
email_verified Si l'emissor va verificar aquell correu

Dos advertiments que causen bugs reals:

  • La clau de l'usuari és sub, mai email. Un correu es pot canviar, es pot reassignar i pot arribar sense verificar. Vincular comptes per correu és una via de presa de compte si email_verified és false.
  • L'id_token és per al client, no per a l'API. No s'envia a Authorization: Bearer al servidor de recursos. La nostra API valida l'access token; l'id_token el consumeix CataBox per saber a qui ha connectat.

L'endpoint /userinfo completa el quadre: es crida amb l'access token i retorna les claims de perfil actualitzades. Es fa servir quan l'id_token es va emetre fa temps o quan es prefereix no engreixar-lo.

GET /userinfo HTTP/1.1
Host: auth.botigaaroma.example
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

I el document de descobriment, que evita configurar URL a mà:

GET /.well-known/openid-configuration HTTP/1.1
Host: auth.botigaaroma.example
{
  "issuer": "https://auth.botigaaroma.example",
  "authorization_endpoint": "https://auth.botigaaroma.example/authorize",
  "token_endpoint": "https://auth.botigaaroma.example/token",
  "userinfo_endpoint": "https://auth.botigaaroma.example/userinfo",
  "jwks_uri": "https://auth.botigaaroma.example/.well-known/jwks.json",
  "revocation_endpoint": "https://auth.botigaaroma.example/revoke",
  "introspection_endpoint": "https://auth.botigaaroma.example/introspect",
  "scopes_supported": ["openid", "profile", "email", "cafes.llegir", "comandes.llegir",
                       "comandes.escriure", "ressenyes.moderar", "enviaments.escriure"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "code_challenge_methods_supported": ["S256"]
}

D'aquí surt el jwks_uri, que és l'única cosa que la nostra API necessita.

  1. Validar el token a l'API: JWKS, kid i les claims

Aquí entra per fi el nostre codi. La mecànica del JWT ja la vam veure a 03-06; el que és nou són dues coses: la signatura és asimètrica (l'AS signa amb la seva clau privada, nosaltres verifiquem amb la seva clau pública) i la clau pública es descobreix dinàmicament mitjançant JWKS.

Què és JWKS

JWKS (JSON Web Key Set) és un document públic amb les claus de verificació de l'emissor:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "aroma-2026-08",
      "alg": "RS256",
      "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4...",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "aroma-2026-05",
      "alg": "RS256",
      "n": "sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23V0dpBB...",
      "e": "AQAB"
    }
  ]
}

Que hi hagi dues claus alhora no és un error: és la rotació. L'AS comença a signar amb aroma-2026-08 mentre els tokens signats amb aroma-2026-05 continuen vigents fins a caducar. La capçalera de cada JWT indica quina cal fer servir:

{ "alg": "RS256", "typ": "JWT", "kid": "aroma-2026-08" }

Això resol de manera nativa el problema de rotació de secrets que vam plantejar a 04-02: no cal redesplegar res, l'API descobreix la clau nova sola.

El middleware

npm install jose

jose és la biblioteca estàndard de facto per a JOSE/JWT a Node: implementa la memòria cau de JWKS, la rotació i les verificacions de l'estàndard.

// src/config/oauth.js  (fitxer NOU)
import { createRemoteJWKSet } from 'jose';
import { entorn } from './entorn.js';

/**
 * Conjunt remot de claus públiques del servidor d'autorització.
 *
 * createRemoteJWKSet retorna una funció que 'jose' fa servir per resoldre la clau
 * a partir del 'kid' de la capçalera del token. Internament:
 *  - descarrega el JWKS la primera vegada i el manté en memòria;
 *  - si arriba un 'kid' desconegut, el torna a descarregar (rotació automàtica);
 *  - limita aquestes recàrregues perquè un token amb 'kid' inventat no es converteixi
 *    en un atac de denegació de servei contra l'AS.
 */
export const jwks = createRemoteJWKSet(new URL(entorn.OAUTH_JWKS_URI), {
  cooldownDuration: 30_000,     // no torna a buscar més d'un cop cada 30 s
  cacheMaxAge: 600_000,         // refresca el joc de claus cada 10 min
  timeoutDuration: 5_000,       // talla si l'AS no respon en 5 s
});

export const OAUTH = {
  emissor: entorn.OAUTH_EMISSOR,          // https://auth.botigaaroma.example
  audiencia: entorn.OAUTH_AUDIENCIA,      // https://api.botigaaroma.example
  algorismes: ['RS256'],                  // llista blanca tancada
  toleranciaRellotge: '30s',
};
// src/middleware/autenticacio-oauth.js  (fitxer NOU)
import { jwtVerify, errors as errorsJose } from 'jose';
import { jwks, OAUTH } from '../config/oauth.js';
import { errors } from '../errors/error-api.js';

/**
 * Autentica la petició amb un access token emès pel servidor d'autorització
 * extern. Deixa a req.usuari el subjecte i els seus àmbits.
 */
export async function autenticarOAuth(req, res, next) {
  const capcalera = req.get('Authorization') ?? '';

  // 1. L'esquema ha de ser exactament 'Bearer'.
  const [esquema, token] = capcalera.split(' ');
  if (esquema !== 'Bearer' || !token) {
    res.set('WWW-Authenticate', `Bearer realm="api.botigaaroma.example"`);
    return next(errors.noAutenticat('no_autenticat', 'Falta el token d\'accés.'));
  }

  try {
    // 2. Verificació completa: signatura + claims registrades.
    const { payload } = await jwtVerify(token, jwks, {
      issuer: OAUTH.emissor,            // iss ha de ser EL NOSTRE servidor d'autorització
      audience: OAUTH.audiencia,        // aud ha de ser LA NOSTRA API
      algorithms: OAUTH.algorismes,     // només RS256: bloqueja alg:none i confusió HS/RS
      clockTolerance: OAUTH.toleranciaRellotge,   // marge per a rellotges desajustats
    });
    // jwtVerify ja ha comprovat exp (no caducat) i nbf (no usat abans d'hora).

    // 3. Els àmbits arriben com una cadena separada per espais.
    const ambits = new Set((payload.scope ?? '').split(' ').filter(Boolean));

    // 4. Subjecte normalitzat: l'API el fa servir igual que el de 03-06.
    req.usuari = {
      id: payload.sub,                      // cli_842, o 'rapidenviaments' en client credentials
      rol: payload.rol ?? deduirRol(payload),
      ambits,
      clientOauth: payload.client_id ?? payload.azp ?? null,   // qui actua: catabox, spa...
      origenToken: 'oauth',
    };
    return next();
  } catch (error) {
    return next(traduirErrorJose(error, res));
  }
}

/**
 * Tradueix els errors de 'jose' al catàleg de la Botiga Aroma i fixa
 * WWW-Authenticate segons el RFC 6750 (Bearer Token Usage).
 */
function traduirErrorJose(error, res) {
  if (error instanceof errorsJose.JWTExpired) {
    res.set('WWW-Authenticate',
      `Bearer error="invalid_token", error_description="The access token expired"`);
    return errors.noAutenticat('token_caducat', 'El token d\'accés ha caducat.');
  }
  if (error instanceof errorsJose.JWKSNoMatchingKey) {
    // 'kid' desconegut: token d'un altre emissor o clau retirada.
    res.set('WWW-Authenticate', `Bearer error="invalid_token"`);
    return errors.noAutenticat('no_autenticat', 'El token d\'accés no és vàlid.');
  }
  if (error instanceof errorsJose.JWKSTimeout) {
    // El servidor d'autorització no respon: NO és culpa del client.
    return errors.serveiNoDisponible(
      'servei_no_disponible',
      'No s\'ha pogut verificar el token en aquest moment.'
    );
  }
  res.set('WWW-Authenticate', `Bearer error="invalid_token"`);
  return errors.noAutenticat('no_autenticat', 'El token d\'accés no és vàlid.');
}

/**
 * Exigeix un o diversos àmbits. Es compon DESPRÉS d'autenticarOAuth.
 */
export function exigirAmbit(...requerits) {
  return (req, res, next) => {
    const te = requerits.every((a) => req.usuari?.ambits?.has(a));
    if (!te) {
      res.set('WWW-Authenticate',
        `Bearer error="insufficient_scope", scope="${requerits.join(' ')}"`);
      return next(
        errors.permisDenegat(
          'permisos_insuficients',
          `El token no inclou l'àmbit requerit: ${requerits.join(', ')}.`
        )
      );
    }
    return next();
  };
}

Les claims que cal verificar, i què passa si te'n saltes alguna:

Claim Què comprova Si no la valides
signatura Que la va emetre qui diu Qualsevol fabrica tokens
iss Qui el va emetre Acceptes tokens de qualsevol altre emissor
aud Per a qui és Acceptes un token emès per a una altra API: l'atac de confusió d'audiència
exp No caducat Els tokens no caduquen mai
nbf No usat abans d'hora Acceptes tokens preemesos
alg (llista blanca) Algorisme esperat alg:none i confusió RS/HS
scope Permís concret Qualsevol token val per a tot

aud és la que més s'oblida i una de les més greus. Si un usuari té un token per a una altra aplicació del mateix servidor d'autorització i la teva API no comprova aud, aquell token obre la teva API.

Sobre el rellotge desincronitzat: exp i nbf són instants absoluts. Si el rellotge del teu servidor va trenta segons avançat, rebutjaràs tokens acabats d'emetre amb un token_caducat incomprensible. Per això clockTolerance, i per això els servidors porten NTP. Una tolerància raonable són 30–60 segons; més que això comença a ser un risc.

Sobre la memòria cau de claus: sense ella faries una petició HTTP a l'AS per cada petició entrant, amb la qual cosa hauries perdut l'avantatge de la validació local i hauries creat una dependència dura. Amb ella, l'AS pot estar caigut deu minuts sense que la teva API deixi d'autenticar.

  1. El middleware autenticarOAuth i la seva convivència amb autenticar

No cal triar de cop. Durant la migració, els dos mecanismes conviuen: els tokens propis de 03-06 (HS256, emesos per la nostra API) i els d'OAuth (RS256, emesos per l'AS). Un middleware selector decideix per la capçalera del token:

// src/middleware/autenticacio.js  (MODIFICAT: s'hi afegeix el selector)
import { decodeProtectedHeader } from 'jose';
import { autenticarPropi } from './autenticacio-propia.js';   // el de 03-06, reanomenat
import { autenticarOAuth } from './autenticacio-oauth.js';

/**
 * Punt d'entrada únic d'autenticació.
 * Tria el verificador segons l'algorisme declarat a la capçalera del token.
 * Nota: la capçalera NO és de fiar per si sola; només es fa servir per ENCAMINAR,
 * i cada verificador imposa després la seva pròpia llista blanca d'algorismes.
 */
export function autenticar(req, res, next) {
  const token = (req.get('Authorization') ?? '').split(' ')[1];
  if (!token) return autenticarPropi(req, res, next);   // respondrà 401 amb WWW-Authenticate

  let capcalera;
  try {
    capcalera = decodeProtectedHeader(token);   // només descodifica, NO verifica
  } catch {
    return autenticarPropi(req, res, next);
  }

  return capcalera.alg === 'RS256'
    ? autenticarOAuth(req, res, next)
    : autenticarPropi(req, res, next);
}

El comentari del codi assenyala l'important: la capçalera del token és entrada no fiable. Es fa servir només per decidir quin verificador l'atén; aquell verificador imposa després algorithms: ['RS256'] o ['HS256'] segons correspongui, així que un atacant no hi guanya res mentint a l'alg.

Posició a la cadena. No canvia respecte a 03-06: l'autenticació continua sent el primer dins de cada ruta, no a src/app.js. L'ordre de la cadena de ruta passa a ser:

autenticar → exigirRol / exigirAmbit → exigirClauIdempotencia → validar(esquema, origen) → asincron(controlador)

I src/app.js no es toca en aquesta lliçó: OAuth no afegeix middleware global.

  1. Àmbits i rols combinats

Aquí hi ha una confusió freqüent que convé esvair amb precisió, perquè són dos mecanismes que responen a preguntes diferents:

Rol (client, empleat, administrador, soci) Àmbit (comandes.llegir)
Respon a Qui és el subjecte Què va deixar fer a aquesta aplicació
Ho decideix La Botiga Aroma, a la seva base de dades L'usuari, a la pantalla de consentiment
Canvia Rarament A cada autorització
Exemple La Marta és client La Marta va permetre a CataBox comandes.llegir

L'autorització efectiva és la intersecció de les dues. Un token amb ressenyes.moderar el subjecte del qual sigui un client no pot moderar: l'àmbit diu el que l'aplicació va demanar, però el rol diu el que la persona pot. I a l'inrevés: una administradora que fa servir CataBox, que només va demanar comandes.llegir, no pot moderar ressenyes des de CataBox encara que el seu rol l'hi permeti al tauler.

// src/rutes/ressenyes.js  (MODIFICAT)
router.post(
  '/:id/aprovacio',
  autenticar,                                   // qui ets? (token propi o OAuth)
  exigirRol('empleat', 'administrador'),        // la teva persona pot moderar?
  exigirAmbit('ressenyes.moderar'),             // aquesta aplicació té permís per fer-ho?
  validar(esquemaAprovacio, 'body'),
  asincron(controladors.ressenyes.aprovar)
);

I una regla de compatibilitat per a la convivència: un token propi de 03-06 no porta scope. Perquè exigirAmbit no trenqui les rutes existents, l'autenticarPropi omple ambits amb el conjunt complet que correspon al rol; és a dir, un token de primera part es comporta com si l'usuari hagués consentit tot. És coherent, perquè en aquell flux l'usuari està fent servir directament la nostra aplicació.

  1. Registre de clients, secrets i redirect_uri

Abans que CataBox pugui demanar res, es registra al servidor d'autorització i obté:

Dada Exemple Públic
client_id catabox
client_secret cbx_sk_9f3a... (fictici) No, només si és confidencial
redirect_uri https://catabox.example/callback
Àmbits permesos cafes.llegir comandes.llegir
Tipus de client Confidencial / públic

Confidencial enfront de públic:

Confidencial Públic
Pot desar un secret Sí (servidor sota el seu control) No
Exemples Backend de CataBox, RàpidEnviaments SPA de la botiga, Aroma Mòbil
Autenticació a /token client_secret Només PKCE
Client Credentials Permès Prohibit

Un error molt repetit: incrustar el client_secret en una SPA o en una app mòbil. Tot el que es descarrega al dispositiu de l'usuari és públic, per molt que estigui ofuscat o compilat. Aquesta és la raó exacta que PKCE existeixi.

La redirect_uri es compara byte a byte. No per prefix, no per comodí, no ignorant el fragment. Un AS que accepti https://catabox.example/* permet que un atacant que controli qualsevol ruta d'aquell domini (una pàgina de perfil amb contingut pujat, per exemple) rebi els codis d'autorització. Regles: HTTPS obligatori, sense comodins, sense paràmetres dinàmics —el que necessitis recordar va a state— i al mòbil, esquemes propis o App Links verificats en comptes d'un esquema que qualsevol app pugui registrar.

  1. Revocació i tancament de sessió

Revocació de token (RFC 7009):

POST /revoke HTTP/1.1
Host: auth.botigaaroma.example
Authorization: Basic <credencials del client>
Content-Type: application/x-www-form-urlencoded

token=def50200a1b2c3...&token_type_hint=refresh_token

Respon 200 fins i tot si el token no existia, deliberadament: així no es pot fer servir l'endpoint per esbrinar si un token és vàlid.

Què revocar i quin efecte té:

Acció Efecte immediat Efecte diferit
Revocar el refresh token No es poden demanar tokens nous L'access token viu fins al seu exp (≤ 15 min)
Revocar l'access token (opac) Deixa de valer ja
Revocar l'access token (JWT) Cap, llevat de llista de revocació Caduca sol
L'usuari retira el consentiment Es revoca tota la família Ídem
Canvi de contrasenya Es revoquen totes les sessions Ídem

Tancament de sessió. Aquí hi ha tres nivells que convé no confondre, perquè l'usuari creu que «tancar sessió» és un de sol:

  1. Local: l'aplicació esborra els seus tokens. L'usuari continua amb sessió oberta a l'AS.
  2. RP-Initiated Logout (OIDC): l'aplicació redirigeix a end_session_endpoint i l'AS tanca també la seva sessió.
  3. Back-Channel Logout: l'AS notifica per darrere a totes les aplicacions connectades perquè tanquin la sessió. És el que cal en un escenari d'inici de sessió únic real.

A la SPA de la Botiga Aroma, «tancar sessió» ha de ser com a mínim el nivell 2; si només fas l'1, prémer «entrar» un altre cop torna a fer entrar l'usuari sense demanar-li res, i en un ordinador compartit això és un problema de seguretat.

  1. Per què no has d'implementar el teu propi servidor d'autorització

Ja ho hauràs intuït llegint la lliçó: implementar un servidor d'autorització OAuth 2.0 correcte és un projecte en si mateix, i fer-ho malament no produeix una fallada visible, produeix una vulnerabilitat silenciosa.

El que cal construir i mantenir correctament:

  • Emissió i validació de codis d'un sol ús, amb vida curta i lligats al client.
  • PKCE amb S256, comparació exacta de redirect_uri, validació de state i nonce.
  • Signatura asimètrica, publicació de JWKS i rotació de claus sense tallar el servei.
  • Rotació de refresh tokens amb detecció de reutilització i revocació en cascada.
  • Pantalles de consentiment, registre de consentiments i la seva retirada.
  • Gestió de contrasenyes, segon factor, recuperació de compte, bloqueig per intents.
  • Endpoints de descobriment, introspecció, revocació i userinfo.
  • Compliment amb les actualitzacions de l'estàndard i amb els avisos de seguretat.

A més, el teu servidor d'autorització és l'actiu més crític del sistema: qui el compromet, ho compromet tot.

Opció Quan té sentit Consideracions
Auth0 / Okta SaaS, vols zero manteniment Cost per usuari actiu; dependència externa
AWS Cognito / Azure AD B2C Ja ets en aquell núvol Bona integració; personalització limitada
Keycloak Autoallotjat, control total, sense cost de llicència Tu operes, actualitzes i assegures el servei
Ory Hydra Vols l'AS i gestionar tu el login Lleuger, però més peces per integrar
Implementar-lo tu Pràcticament mai Només amb un equip de seguretat dedicat

La decisió de la Botiga Aroma: un proveïdor gestionat a auth.botigaaroma.example. La nostra API continua sent únicament servidor de recursos, que és exactament el paper que hem implementat.

I una precisió útil: això no invalida la feina de 03-06. Els tokens propis continuen sent perfectament raonables per al cas de primera part —la teva SPA parlant amb la teva API, sense tercers—. OAuth s'incorpora quan apareix un tercer, quan necessites inici de sessió federat o quan diverses APIs comparteixen identitat.

  1. Errors d'OAuth i la seva traducció al catàleg d'Aroma

OAuth defineix els seus propis codis d'error, amb aquesta forma:

{
  "error": "invalid_grant",
  "error_description": "The authorization code has expired",
  "error_uri": "https://auth.botigaaroma.example/docs/errors#invalid_grant"
}

Aquest format l'emet el servidor d'autorització, no la nostra API. Els errors que veu un client de CataBox es reparteixen així:

Error OAuth L'emet Significat Al catàleg d'Aroma
invalid_request AS Falta un paràmetre obligatori
invalid_client AS client_id/client_secret erronis
invalid_grant AS Codi caducat, ja usat, o refresh revocat
unauthorized_client AS Aquell client no pot fer servir aquell flux
unsupported_grant_type AS grant_type desconegut
invalid_scope AS Àmbit inexistent o no permès al client
access_denied AS L'usuari va rebutjar el consentiment
invalid_token La nostra API Token no verificable, mal format o d'un altre emissor no_autenticat (401)
invalid_token (caducat) La nostra API exp passat token_caducat (401)
insufficient_scope La nostra API Falten àmbits permisos_insuficients (403)

La nostra API manté el seu format propi al cos i fa servir la capçalera WWW-Authenticate per parlar l'idioma d'OAuth, que és el que el RFC 6750 espera:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="comandes.escriure"
Content-Type: application/json

{
  "error": {
    "codi": "permisos_insuficients",
    "missatge": "El token no inclou l'àmbit requerit: comandes.escriure.",
    "detalls": []
  }
}

Així es compleixen les dues coses alhora: la consistència del catàleg cap als nostres consumidors (04-01) i la interoperabilitat amb les biblioteques OAuth genèriques, que llegeixen WWW-Authenticate per decidir si han de refrescar el token o demanar més permisos. No cal ampliar el catàleg d'errors: no_autenticat, token_caducat, permisos_insuficients i servei_no_disponible cobreixen tots els casos que emet el servidor de recursos.

Errors Comuns i Consells

Fer servir l'access token com a prova d'identitat. L'access token és per a l'API; la identitat la dona l'id_token d'OIDC. Confondre'ls obre atacs de substitució de token.

No validar aud. Un token emès per a una altra aplicació del mateix emissor obriria la teva API. És la comprovació més oblidada.

Acceptar qualsevol algorisme. Sense llista blanca, alg:none i la confusió RS/HS són explotables. Fixa ['RS256'].

Desar tokens a localStorage. Qualsevol XSS els roba. Galeta HttpOnly amb un BFF, o el clauer del sistema al mòbil.

Incrustar un client_secret en una SPA o app mòbil. És públic per definició. Client públic + PKCE.

Acceptar redirect_uri amb comodins. Una sola ruta controlable al teu domini es converteix en robatori de codis.

Ometre state perquè «ja fem servir PKCE». Protegeixen d'atacs diferents. Tots dos, sempre.

Demanar tots els àmbits «per si de cas». Baixa la conversió —l'usuari veu una pantalla alarmant— i augmenta el dany d'una bretxa. Demana el mínim i amplia quan calgui.

Consell: comença pel document de descobriment. /.well-known/openid-configuration et dona totes les URL. Configurar token_endpoint a mà és una font d'errors tontos.

Consell: prova la caducitat de debò. Configura un AS de proves amb tokens de 30 segons i comprova que el teu client refresca sol i que la teva API respon token_caducat amb el WWW-Authenticate correcte.

Consell: registra client_id als logs. Saber que una onada de 429 ve de CataBox i no de la teva SPA canvia completament el diagnòstic. Ho veurem a 04-07.

Exercicis

Exercici 1: triar el flux

Per a cada consumidor de la Botiga Aroma, indica el flux correcte, el tipus de client i els àmbits mínims. Justifica cada elecció.

  1. La SPA de la botiga (https://botigaaroma.example), que permet comprar i ressenyar.
  2. L'app Aroma Mòbil, amb les mateixes funcions.
  3. El backend de RàpidEnviaments, que marca comandes com a enviades.
  4. CataBox, que importa l'historial de compres de l'usuari.
  5. Un script intern nocturn que exporta estadístiques de vendes.

Exercici 2: trobar les fallades en una validació

Aquest middleware s'ha proposat per validar tokens OAuth. Troba almenys quatre fallades de seguretat i corregeix-les.

import jwt from 'jsonwebtoken';

export async function autenticarOAuth(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const capcalera = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString());
  const jwksResposta = await fetch('https://auth.botigaaroma.example/.well-known/jwks.json');
  const { keys } = await jwksResposta.json();
  const clau = keys.find((k) => k.kid === capcalera.kid) ?? keys[0];
  const payload = jwt.verify(token, aPem(clau));
  req.usuari = { id: payload.email, ambits: payload.scope };
  next();
}

Exercici 3: dissenyar els àmbits d'una funció nova

La Botiga Aroma permetrà que aplicacions de tercers gestionin la subscripció mensual de cafè d'un client: consultar-la, pausar-la, reprendre-la i canviar la varietat. Dissenya els àmbits necessaris, el text de consentiment de cadascun, i decideix quina combinació de rol i àmbit exigeix cada endpoint.

Solucions

Solució 1

Consumidor Flux Tipus de client Àmbits mínims
1. SPA de la botiga Authorization Code + PKCE (idealment amb BFF) Públic openid cafes.llegir comandes.llegir comandes.escriure ressenyes.escriure
2. Aroma Mòbil Authorization Code + PKCE Públic Els mateixos
3. RàpidEnviaments Client Credentials Confidencial enviaments.escriure
4. CataBox Authorization Code + PKCE Confidencial (té backend) openid comandes.llegir
5. Script intern Client Credentials Confidencial vendes.llegir (àmbit nou)

Justificacions:

  • 1 i 2 són públics encara que siguin nostres: el codi es descarrega al dispositiu i no pot desar un secret. I fan servir Authorization Code + PKCE, no el flux de contrasenya, per poder passar per 2FA i no gestionar contrasenyes.
  • 3 i 5 no tenen usuari al davant, així que Client Credentials. El sub és la mateixa màquina i no hi ha consentiment.
  • 4 és confidencial perquè el bescanvi passa al seu servidor, però fa servir PKCE igualment: la recomanació actual és aplicar-lo sempre. Demana només comandes.llegir; si demanés comandes.escriure caldria rebutjar el registre, perquè no ho necessita per al seu cas d'ús.
  • El cas 5 requereix ampliar el catàleg d'àmbits amb vendes.llegir, i convé notar-ho explícitament: els àmbits, com els codis d'error, formen part del contracte i es documenten a openapi.yaml.

Solució 2

Fallades:

  1. No valida iss ni aud: accepta tokens de qualsevol emissor i emesos per a qualsevol altra API.
  2. No fixa algorithms: vulnerable a alg:none i a la confusió RS/HS.
  3. ?? keys[0]: si el kid no coincideix amb cap clau, fa servir la primera «a veure si cola». Un kid desconegut ha de ser un rebuig.
  4. Descarrega el JWKS a cada petició: latència afegida, dependència dura de l'AS i un vector de denegació de servei (n'hi ha prou d'enviar tokens amb kid inventats).
  5. id: payload.email: l'identificador estable és sub. El correu pot canviar o arribar sense verificar.
  6. ambits: payload.scope deixa una cadena on s'espera un conjunt; .has() fallaria o, pitjor, .includes() donaria falsos positius (comandes.llegir «inclou» comandes.lleg).
  7. Sense try/catch: si falta la capçalera o el token està mal format, el split o el JSON.parse llancen un TypeError i acaba en un 500 en comptes d'un 401 — i sense WWW-Authenticate.

La correcció és el middleware de l'apartat 12: createRemoteJWKSet amb memòria cau per a 3 i 4, jwtVerify amb issuer, audience i algorithms per a 1 i 2, payload.sub per a 5, un Set per a 6 i el try/catch amb traducció d'errors per a 7.

Solució 3

Àmbits:

Àmbit Text de consentiment Justificació
subscripcions.llegir «Veure la teva subscripció de cafè i el seu proper lliurament» Només lectura, risc baix
subscripcions.escriure «Pausar, reprendre i canviar la teva subscripció de cafè» Modifica l'estat; separat de la lectura

Deliberadament no es crea un àmbit per acció (subscripcions.pausar, subscripcions.reprendre…): produiria una pantalla de consentiment illegible i cap d'aquestes accions no té un perfil de risc diferent de les altres. Tampoc no es reutilitza comandes.escriure, perquè una aplicació que gestiona subscripcions no ha de poder crear comandes soltes: barrejar les dues ampliaria el permís més enllà del necessari.

Endpoints:

Endpoint Rol Àmbit Nota
GET /v1/clients/{id}/subscripcio client (propi), empleat, administrador subscripcions.llegir Comprovació de propietat obligatòria
POST /v1/clients/{id}/subscripcio/pausa client (propi), administrador subscripcions.escriure Idempotent: pausar dues vegades ho deixa igual
POST /v1/clients/{id}/subscripcio/represa client (propi), administrador subscripcions.escriure Ídem
PATCH /v1/clients/{id}/subscripcio client (propi), administrador subscripcions.escriure Canvia la varietat; pedaç absolut
DELETE /v1/clients/{id}/subscripcio client (propi) subscripcions.escriure Cancellació; un empleat no cancel·la pel seu compte
router.post(
  '/:id/subscripcio/pausa',
  autenticar,
  exigirRol('client', 'administrador'),
  exigirAmbit('subscripcions.escriure'),
  asincron(controladors.subscripcions.pausar)   // a dins: comprovació de propietat
);

Dues observacions finals. L'exigirRol('client', ...) no n'hi ha prou per impedir que un client pausi la subscripció d'un altre: això és un BOLA i es comprova al servei, com vam veure a 04-02. I les accions es modelen com a subrecursos substantius (/pausa, /represa) en comptes de verbs, coherentment amb 02-02 i amb els antipatrons de 04-01.

Conclusió

OAuth 2.0 resol un problema molt concret que l'autenticació de 03-06 no podia resoldre: que una aplicació de tercers accedeixi a les dades d'un usuari sense conèixer-ne la contrasenya, amb permisos acotats, caducitat i revocació. Has vist els quatre rols i, sobretot, que la nostra API és només el servidor de recursos: rep un token, el verifica i decideix; res més. Coneixes la diferència entre access token i refresh token, els àmbits dissenyats per a la Botiga Aroma i per què se separa sempre lectura d'escriptura; l'elecció entre token opac amb introspecció i JWT amb validació local, amb el seu compromís explícit sobre la revocació; el flux Authorization Code + PKCE amb el detall de quin atac evita exactament el code_verifier, per què l'implícit i el de contrasenya estan retirats, Client Credentials per a RàpidEnviaments i la rotació de refresh tokens amb detecció de reutilització. Has vist que OpenID Connect és la capa que respon «qui és» amb l'id_token, sub com a clau estable i /userinfo per al perfil. I al projecte ja tens src/config/oauth.js i src/middleware/autenticacio-oauth.js, amb verificació per JWKS i kid —que resol de passada la rotació de claus—, validació completa d'iss, aud, exp, nbf i algorisme, memòria cau de claus, tolerància de rellotge, el nou exigirAmbit i un selector que fa conviure els tokens propis amb els d'OAuth.

Ja saps qui crida i què pot fer. Falta quant pot cridar. A 04-04, Rate limiting i throttling, hi posarem límits: veurem per què tota API pública els necessita —abús, scraping del catàleg, bucles de clients mal programats, cost i equitat—, la diferència entre limitar, estrangular i posar quotes, i els quatre algorismes clàssics amb la seva comparativa, inclòs un cubell de fitxes implementat i comentat. Decidirem què es fa servir com a clau (IP amb el problema de NAT i X-Forwarded-For, client autenticat, client_id d'OAuth) i amb quins nivells per a anònims, clients, RàpidEnviaments i tauler; construirem src/middleware/limit-peticions.js amb express-rate-limit i la seva posició a src/app.js, amb magatzem a Redis per a diverses instàncies; retornarem el 429 amb Retry-After i les capçaleres Aroma-RateLimit-*; i veurem què ha de fer un client ben educat amb el seu retrocés exponencial i el seu jitter.

Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats