L'API de la Botiga Aroma funciona, persisteix i valida, però ara mateix qualsevol amb curl pot apujar el preu d'un cafè, esborrar el catàleg o llegir les comandes de tots els clients. Avui tanquem aquella porta amb les dues preguntes que tota API ha de saber respondre: qui ets i què pots fer. Són preguntes diferents, tenen codis HTTP diferents —401 i 403, que el contracte separa des de 02-04— i es resolen amb mecanismes diferents. Implementarem el registre de clients amb la contrasenya protegida amb bcrypt, l'inici de sessió que emet un JWT signat, el middleware que el verifica i distingeix un token absent d'un de caducat, els rols de la Botiga Aroma amb la seva matriu de permisos, i l'autorització a nivell de recurs, que és la que de debò impedeix que la Marta llegeixi les comandes d'un altre client.

Contingut

  1. Autenticació i autorització no són el mateix
  2. Panoràmica de mecanismes d'autenticació
  3. Per què una API REST sense estat encaixa amb tokens
  4. El model de clients i el seu repositori
  5. Contrasenyes: per què bcrypt i mai text pla
  6. Registre: POST /v1/clients
  7. Inici de sessió: POST /v1/sessions
  8. Anatomia d'un JWT
  9. Els claims i què no ficar mai al payload
  10. El middleware d'autenticació
  11. 401 ben fet: WWW-Authenticate i els dos codis del catàleg
  12. Autorització per rol: exigirRol
  13. Autorització a nivell de recurs
  14. 403 o 404: quan ocultar l'existència
  15. La matriu de permisos de la Botiga Aroma
  16. Caducitat, tokens de refresc i revocació
  17. On desa el token el client

  1. Autenticació i autorització no són el mateix

Autenticació Autorització
Pregunta Qui ets? Pots fer això?
Moment Primer Després
Codi HTTP 401 Unauthorized 403 Forbidden
Capçalera obligatòria WWW-Authenticate Cap
Solució per al client Autentica't o renova el token Cap: no insisteixis
Codis del catàleg no_autenticat, token_caducat permisos_insuficients

La confusió més estesa a les APIs reals és retornar 401 quan toca 403. La diferència és operativa, no acadèmica:

  • 401 significa "no sé qui ets, o ja no m'ho crec". El client ho pot arreglar: renova el token, torna a iniciar sessió i ho reintenta. Reintentar-ho té sentit.
  • 403 significa "sé perfectament qui ets i no tens permís". Reintentar-ho amb el mateix token donarà sempre el mateix. Reintentar-ho no té sentit.

Un client ben escrit automatitza la reacció al 401 —renovar i repetir— i mostra un missatge a l'usuari davant d'un 403. Si barreges els codis, la SPA de la Botiga Aroma entrarà en un bucle de renovació infinit davant d'un permís denegat.

Un apunt històric que despista: el nom oficial del 401 al RFC és Unauthorized, quan hauria de ser Unauthenticated. És un error de nomenclatura del 1997 que ja no es pot corregir. Fia't de la semàntica, no del nom.

  1. Panoràmica de mecanismes d'autenticació

Mecanisme Com viatja A favor En contra Ús típic
Basic Authorization: Basic base64(usuari:clau) Trivial d'implementar Envia la contrasenya a cada petició; només acceptable sobre HTTPS i ni així Eines internes, prototips
Clau d'API Capçalera pròpia o Authorization Simple, bona per a servidor a servidor No identifica una persona, no caduca sola, difícil de rotar Integracions de socis
Sessió amb galeta Cookie: sessio=abc, estat al servidor Revocació immediata, el navegador la gestiona Amb estat, complica l'escalat, exposada a CSRF Aplicacions web clàssiques
JWT (Bearer) Authorization: Bearer <token> Sense estat, verificable sense consultar la BD, porta claims No es pot revocar de manera senzilla, el payload és llegible APIs REST modernes
OAuth 2.0 / OIDC Bearer emès per un tercer Delegació d'accés, "entrar amb Google" Complexitat notable Accés de tercers, SSO

La Botiga Aroma fa servir JWT per als seus consumidors propis (SPA, Aroma Mòbil, panell intern) i claus d'API per al soci RàpidEnviaments, que és una màquina. OAuth 2.0 i OpenID Connect —la delegació d'accés a tercers i l'"entrar amb…"— es desenvolupen sencers a 04-03; aquí només cal situar-los: OAuth no substitueix el que construirem, sinó que hi afegeix a sobre un protocol perquè un altre emeti els tokens.

  1. Per què una API REST sense estat encaixa amb tokens

A 01-04 vam fixar la restricció d'absència d'estat: cada petició conté tot el que cal per ser atesa, i el servidor no desa context de sessió entre peticions.

Una sessió amb galeta trenca això: el servidor desa sessio_abc → client cli_842 en memòria o a Redis, i tota petició depèn d'aquell magatzem. Conseqüències: si hi ha tres instàncies darrere d'un balancejador, o comparteixen magatzem de sessions o cal afinitat de sessió; i aquell magatzem és un punt únic de fallada.

Un token signat inverteix el model: la informació viatja amb la petició i el servidor només comprova la signatura. Qualsevol instància la pot atendre sense consultar res.

Sessió amb galeta Token signat
On viu la identitat Al servidor Al token, al client
Escalat horitzontal Requereix magatzem compartit Immediat
Cost per petició Consulta al magatzem Verificació de signatura (microsegons)
Revocació Immediata: s'esborra Difícil: el token continua sent vàlid
Encaixa amb REST Regular

Aquest "difícil" de la revocació és la contrapartida real i la tractarem a la secció 16. No hi ha menjar de franc.

  1. El model de clients i el seu repositori

La taula clients ja existeix des de la migració 001-inicial.sql de 03-05, amb email UNIQUE, hash_contrasenya i rol. Ens falta el seu repositori:

// src/repositoris/clients-sqlite.js
import { baseDades } from '../config/base-dades.js';

function aModel(fila) {
  if (!fila) return undefined;
  return {
    id: fila.id,
    nom: fila.nom,
    email: fila.email,
    hashContrasenya: fila.hash_contrasenya,
    rol: fila.rol,
    dataCreacio: fila.data_creacio,
  };
}

const sentencies = {
  perId: baseDades.prepare('SELECT * FROM clients WHERE id = ?'),
  perEmail: baseDades.prepare('SELECT * FROM clients WHERE email = ?'),
  inserir: baseDades.prepare(`
    INSERT INTO clients (id, nom, email, hash_contrasenya, rol, data_creacio)
    VALUES (@id, @nom, @email, @hashContrasenya, @rol, @dataCreacio)
  `),
  seguentNumero: baseDades.prepare(
    "SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 840) + 1 AS seguent FROM clients"
  ),
};

export const repositoriClients = {
  buscarPerId(id) {
    return aModel(sentencies.perId.get(id));
  },

  buscarPerEmail(email) {
    // L'email es normalitza a minúscules SEMPRE, en desar i en cercar:
    // '[email protected]' i '[email protected]' són la mateixa persona.
    return aModel(sentencies.perEmail.get(email.toLowerCase()));
  },

  crear({ nom, email, hashContrasenya, rol = 'client' }) {
    const id = `cli_${sentencies.seguentNumero.get().seguent}`;
    sentencies.inserir.run({
      id,
      nom,
      email: email.toLowerCase(),
      hashContrasenya,
      rol,
      dataCreacio: new Date().toISOString(),
    });
    return this.buscarPerId(id);
  },
};

I el mapejador guanya una funció. Fixa't en el que no hi apareix:

// src/serveis/mapejadors.js  (afegit)

/**
 * Client → representació pública.
 * hashContrasenya NO hi apareix. Ni al registre, ni al detall, ni a
 * cap col·lecció. Un hash filtrat es pot atacar sense límit fora de
 * línia, i a més ningú no té cap motiu per veure'l.
 */
export function clientARepresentacio(client) {
  return {
    id: client.id,
    nom: client.nom,
    email: client.email,
    rol: client.rol,
    dataCreacio: client.dataCreacio,
    _links: {
      self: { href: `/v1/clients/${client.id}` },
      comandes: { href: `/v1/clients/${client.id}/comandes` },
      preferencies: { href: `/v1/clients/${client.id}/preferencies` },
    },
  };
}

Construir la representació camp a camp, en comptes de fer { ...client, hashContrasenya: undefined }, és el que garanteix que un camp sensible afegit demà no es filtri per descuit. Llista d'inclusió, mai d'exclusió.

  1. Contrasenyes: per què bcrypt i mai text pla

Regles, per ordre d'importància:

  1. Mai no es desa la contrasenya. Ni xifrada: xifrar és reversible, i qui tingui la clau les té totes.
  2. Es desa un hash, resultat d'una funció irreversible.
  3. No val qualsevol hash. MD5 i SHA-1 estan trencats. SHA-256 no ho està, però és massa ràpid: una GPU en calcula milers de milions per segon i prova un diccionari sencer en minuts.
  4. Es fa servir una funció de hash de contrasenyes, dissenyada per ser lenta i amb cost ajustable: bcrypt, scrypt o Argon2.

bcrypt fa dues coses que el defineixen:

  • Sal: genera un valor aleatori per contrasenya i l'incorpora al hash. Dos usuaris amb la mateixa contrasenya obtenen hashos diferents, cosa que anul·la les taules precalculades (rainbow tables). La sal va dins del hash resultant; no cal desar-la a part.
  • Cost: un paràmetre (per defecte 10, és a dir 2¹⁰ = 1024 iteracions) que es pot apujar amb el temps, a mesura que el maquinari millora.
$2b$10$N9qo8uLOickgx2ZMRZoMye.IjZAgcfl7p92ldGxad68LJZdL17lhW
 │  │  │                      │
 │  │  └── sal (22 car.)      └── hash (31 car.)
 │  └── cost: 10
 └── algorisme: 2b (bcrypt)
// src/serveis/autenticacio.js
import bcrypt from 'bcrypt';

/**
 * Cost de bcrypt. 10 ≈ 60-100 ms per hash en maquinari normal del 2026.
 * Compromís: prou lent per frenar un atac per força bruta,
 * prou ràpid per no bloquejar l'inici de sessió. Cada +1 DUPLICA el temps.
 */
const COST_BCRYPT = 10;

/** Genera el hash d'una contrasenya. Asíncron: no bloqueja el bucle. */
export async function hashejarContrasenya(contrasenya) {
  return bcrypt.hash(contrasenya, COST_BCRYPT);
}

/**
 * Comprova una contrasenya contra el seu hash.
 * bcrypt extreu la sal i el cost del mateix hash, així que continua
 * funcionant encara que demà apugem COST_BCRYPT a 12.
 */
export async function verificarContrasenya(contrasenya, hash) {
  return bcrypt.compare(contrasenya, hash);
}

Dos matisos de pes. bcrypt.compare és de temps constant: triga el mateix tant si encerta com si falla, per no filtrar informació pel temps de resposta. I el hash és asíncron a propòsit: 80 ms de càlcul al fil principal bloquejarien totes les altres peticions; bcrypt ho fa al pool de fils de Node.

Com a nota de futur: Argon2 va guanyar la competició de funcions de hash de contrasenyes el 2015 i és la recomanació actual de l'OWASP per a projectes nous, perquè a més de temps consumeix memòria, cosa que penalitza els atacants amb GPU. bcrypt continua sent perfectament acceptable i és l'opció més estesa i provada; fem servir bcrypt per això.

  1. Registre: POST /v1/clients

// src/esquemes/clients.js
import { z } from 'zod';

export const esquemaRegistre = z
  .object({
    nom: z.string().trim().min(2, 'El nom necessita almenys 2 caràcters').max(120),
    email: z.string().trim().toLowerCase().email('El correu no té un format vàlid'),
    contrasenya: z
      .string()
      .min(10, 'La contrasenya necessita almenys 10 caràcters')
      .max(200, 'La contrasenya no pot superar els 200 caràcters'),
  })
  .strict();

export const esquemaCrearSessio = z
  .object({
    email: z.string().trim().toLowerCase().email(),
    contrasenya: z.string().min(1),
  })
  .strict();

Sobre el màxim de 200 caràcters: bcrypt trunca a 72 bytes, així que un límit generós però explícit evita sorpreses i, sobretot, impedeix que algú enviï una contrasenya de 10 MB per consumir CPU. I sobre el mínim de 10 en comptes de regles del tipus "una majúscula, un número i un símbol": la recomanació moderna del NIST és premiar la longitud i no imposar composicions, que només produeixen Password1! i contrasenyes apuntades en un pòsit.

// src/serveis/autenticacio.js  (continuació)
import { repositoriClients } from '../repositoris/clients-sqlite.js';

export const serveiAutenticacio = {
  /** Registra un client. Retorna {client} o {error}. */
  async registrar({ nom, email, contrasenya }) {
    if (repositoriClients.buscarPerEmail(email)) {
      // 409: el conflicte és amb l'estat actual, no amb el format.
      return { error: 'email_ja_registrat' };
    }

    const hashContrasenya = await hashejarContrasenya(contrasenya);
    const client = repositoriClients.crear({
      nom,
      email,
      hashContrasenya,
      rol: 'client', // el rol MAI no el tria qui es registra
    });

    return { client };
  },
};

Com va passar amb ruta_no_trobada a 03-02, email_ja_registrat és un codi nou que no era al catàleg de 02-04. Afegir-lo és legítim —el catàleg només creix— i és obligatori documentar-lo a openapi.yaml. El 409 és el codi correcte: el cos era vàlid, el que xoca és l'estat actual del sistema.

La línia del rol és de les més importants de la lliçó. esquemaRegistre és .strict() i no inclou rol, així que un cos amb "rol": "administrador" rep 400 camp_desconegut. I encara que l'esquema fos lax, el servei fixa 'client' literalment. Dues barreres independents contra l'escalada de privilegis, perquè una de sola sempre acaba fallant.

// src/controladors/clients.js
import { serveiAutenticacio } from '../serveis/autenticacio.js';
import { clientARepresentacio } from '../serveis/mapejadors.js';

export const controladorClients = {
  /** POST /v1/clients */
  async registrar(req, res) {
    const { client, error } = await serveiAutenticacio.registrar(req.body);

    if (error === 'email_ja_registrat') {
      return res.status(409).json({
        error: {
          codi: 'email_ja_registrat',
          missatge: 'Ja existeix un compte amb aquell correu electrònic.',
          detalls: [],
        },
      });
    }

    res.set('Location', `/v1/clients/${client.id}`);
    res.status(201).json(clientARepresentacio(client));
  },
};
// src/rutes/clients.js
import { Router } from 'express';
import { controladorClients } from '../controladors/clients.js';
import { validar } from '../middleware/validacio.js';
import { esquemaRegistre } from '../esquemes/clients.js';

export const rutesClients = Router();

// Ruta PÚBLICA: per registrar-se no es pot exigir estar registrat.
rutesClients.post('/', validar(esquemaRegistre), controladorClients.registrar);
curl -i -s -X POST http://localhost:3000/v1/clients \
  -H "Content-Type: application/json" \
  -d '{"nom":"Llúcia Ferrer","email":"[email protected]","contrasenya":"torrefaccio-clara-2026"}'
HTTP/1.1 201 Created
Location: /v1/clients/cli_843

{"id":"cli_843","nom":"Llúcia Ferrer","email":"[email protected]","rol":"client","dataCreacio":"2026-03-14T10:28:00.000Z","_links":{...}}

Ni rastre de la contrasenya ni del hash a la resposta, com ha de ser.

Actualitza la sembra. El cli_842 de la Marta es va sembrar a 03-05 amb el text 'pendent-de-03-06' a hash_contrasenya, que no és un hash vàlid i per tant no deixarà entrar mai ningú. Substitueix-lo a migracions/sembrar.js per un hash real generat amb await hashejarContrasenya('cafe-especialitat-2026'), i aprofita per sembrar també un cli_001 amb rol empleat i un cli_002 amb rol administrador: els necessitaràs per provar la matriu de permisos i per a les proves de 03-08.

Nota sobre async: aquest controlador és asíncron perquè bcrypt ho és. Si llancés una excepció, Express 4 no la capturaria i la petició quedaria penjada, tal com vam advertir a 03-03. Avui funciona perquè el servei retorna {error} en comptes de llançar; a 03-07 ho resoldrem bé amb l'embolcall asincron().

  1. Inici de sessió: POST /v1/sessions

Fixa't en la URI: POST /v1/sessions, no /login. És coherent amb 02-02: iniciar sessió és crear un recurs sessió, no invocar un verb. Un DELETE /v1/sessions/actual seria el tancament de sessió, amb els matisos de la secció 16.

// src/serveis/autenticacio.js  (continuació)
import jwt from 'jsonwebtoken';
import { entorn } from '../config/entorn.js';

/** Signa un JWT per a un client. */
export function emetreToken(client) {
  return jwt.sign(
    {
      // Claims propis: el mínim per autoritzar sense consultar la BD.
      rol: client.rol,
    },
    entorn.jwtSecret,
    {
      subject: client.id,                        // sub: a qui identifica
      expiresIn: entorn.jwtCaducitat,            // exp: '1h' des de .env
      issuer: 'api.botigaaroma.example',         // iss: qui l'ha emès
      algorithm: 'HS256',                        // signatura simètrica
    }
  );
}

/** Verifica les credencials i retorna el token, o null si no són vàlides. */
export async function iniciarSessio({ email, contrasenya }) {
  const client = repositoriClients.buscarPerEmail(email);

  if (!client) {
    // Es gasta el mateix temps que si existís, per no revelar pel
    // rellotge quins correus estan registrats (atac d'enumeració).
    await verificarContrasenya(contrasenya, '$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidin');
    return null;
  }

  const correcta = await verificarContrasenya(contrasenya, client.hashContrasenya);
  if (!correcta) return null;

  return {
    token: emetreToken(client),
    caducaEn: entorn.jwtCaducitat,
    client,
  };
}
// src/controladors/sessions.js
import { iniciarSessio } from '../serveis/autenticacio.js';
import { clientARepresentacio } from '../serveis/mapejadors.js';

export const controladorSessions = {
  /** POST /v1/sessions */
  async crear(req, res) {
    const sessio = await iniciarSessio(req.body);

    if (!sessio) {
      // MATEIX missatge per a "no existeix el correu" i "la contrasenya falla".
      res.set('WWW-Authenticate', 'Bearer realm="api.botigaaroma.example"');
      return res.status(401).json({
        error: {
          codi: 'no_autenticat',
          missatge: 'Les credencials no són correctes.',
          detalls: [],
        },
      });
    }

    res.status(201).json({
      token: sessio.token,
      tipus: 'Bearer',
      caducaEn: sessio.caducaEn,
      client: clientARepresentacio(sessio.client),
    });
  },
};
// src/rutes/sessions.js
import { Router } from 'express';
import { controladorSessions } from '../controladors/sessions.js';
import { validar } from '../middleware/validacio.js';
import { esquemaCrearSessio } from '../esquemes/clients.js';

export const rutesSessions = Router();
rutesSessions.post('/', validar(esquemaCrearSessio), controladorSessions.crear);

I a src/rutes/index.js:

rutesV1.use('/clients', rutesClients);
rutesV1.use('/sessions', rutesSessions);

Per què el mateix missatge en els dos casos de fallada. Si responguéssim "aquell correu no està registrat" en un cas i "contrasenya incorrecta" en l'altre, qualsevol podria esbrinar quins correus tenen compte a la Botiga Aroma provant-los un a un. Això és una fuita de dades personals amb valor real per al frau i el phishing. La resposta genèrica costa una mica de comoditat i ho compensa de sobres. Per la mateixa raó el servei gasta temps comparant contra un hash fals quan el correu no existeix: sense això, una fallada en 3 ms davant d'una en 90 ms delataria el mateix per una altra via.

curl -s -X POST http://localhost:3000/v1/sessions \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","contrasenya":"cafe-especialitat-2026"}' | jq
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjbGlfODQyIiwicm9sIjoiY2xpZW50IiwiaXNzIjoiYXBpLmJvdGlnYWFyb21hLmV4YW1wbGUiLCJpYXQiOjE3NzM0ODQyMDAsImV4cCI6MTc3MzQ4NzgwMH0.k3vQ2xR7fW1sPmT9aZbN4cE8hJdL0gYuXi6oV5rSqBw",
  "tipus": "Bearer",
  "caducaEn": "1h",
  "client": { "id": "cli_842", "nom": "Marta Garcia", "rol": "client", "...": "..." }
}

  1. Anatomia d'un JWT

Un JWT són tres parts separades per punts, cadascuna codificada en base64url:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJjbGlfODQyIiwicm9sIjoi... . k3vQ2xR7fW1sPmT9aZbN4c...
└────────── capçalera ─────────────┘   └────────── payload ──────────┘   └──── signatura ────────┘

Capçalera, descodificada:

{ "alg": "HS256", "typ": "JWT" }

Payload (els claims), descodificat:

{
  "sub": "cli_842",
  "rol": "client",
  "iss": "api.botigaaroma.example",
  "iat": 1773484200,
  "exp": 1773487800
}

Signatura: HMAC-SHA256(base64url(capçalera) + "." + base64url(payload), secret).

Ho pots comprovar tu mateix ara:

# La segona part del token, descodificada. NO cal el secret.
echo "eyJzdWIiOiJjbGlfODQyIiwicm9sIjoiY2xpZW50IiwiaXNzIjoiYXBpLmJvdGlnYWFyb21hLmV4YW1wbGUiLCJpYXQiOjE3NzM0ODQyMDAsImV4cCI6MTc3MzQ4NzgwMH0=" | base64 -d
{"sub":"cli_842","rol":"client","iss":"api.botigaaroma.example","iat":1773484200,"exp":1773487800}

Aquesta és la propietat més malinterpretada del JWT: el payload NO està xifrat, només codificat. Base64 no és seguretat; és una manera d'escriure bytes amb caràcters imprimibles. Qualsevol que intercepti el token en llegeix el contingut.

Aleshores, què aporta la signatura? Integritat i autenticitat: garanteix que el contingut no ha estat modificat i que l'ha emès qui té el secret. Si un atacant canvia "rol":"client" per "rol":"administrador", la signatura deixa de quadrar i jwt.verify rebutja el token. No en pot recalcular la signatura perquè no coneix el secret.

Sobre HS256: és simètric, un únic secret serveix per signar i per verificar. Perfecte quan el mateix sistema fa totes dues coses, com aquí. L'alternativa és RS256, asimètric: se signa amb la clau privada i es verifica amb la pública, de manera que altres serveis poden validar tokens sense poder-los emetre. És el que fan servir els proveïdors d'OAuth (04-03).

I un advertiment de seguretat clàssic: va existir una vulnerabilitat històrica en diverses llibreries que acceptaven "alg": "none" —un token sense signatura— perquè confiaven en l'algorisme declarat a la capçalera del mateix token. La defensa és fixar l'algorisme esperat en verificar, i per això el nostre codi passarà algorithms: ['HS256'] explícitament.

  1. Els claims i què no ficar mai al payload

Claims estàndard del RFC 7519:

Claim Nom Significat El fem servir?
sub Subject A qui identifica el token : cli_842
iat Issued At Quan es va emetre Sí, automàtic
exp Expiration Quan caduca : obligatori
iss Issuer Qui l'ha emès
aud Audience Per a qui és No (una sola API)
nbf Not Before No vàlid abans de No
jti JWT ID Identificador únic del token No (útil per revocar)

I el nostre, rol, que és un claim privat.

La regla d'or del payload: és públic i és immutable. D'aquí se'n dedueixen les dues llistes.

Què NO hi has de ficar mai:

No hi fiquis Per què
Contrasenyes o el seu hash Qualsevol les llegeix
Números de targeta, DNI, adreça Dades personals llegibles per qui intercepti el token
Claus d'API o secrets Ídem
Dades que canvien sovint El token no s'actualitza: queden obsoletes fins que caduqui
Llistes llargues de permisos El token viatja a cada petició; infla totes les capçaleres

Aquest quart punt té una conseqüència operativa important i poc intuïtiva: si un administrador degrada un empleat a client, el seu token continua dient rol: empleat fins que caduqui. Amb una hora de caducitat, hi ha fins a una hora de finestra. Per a operacions crítiques, la solució és no fiar-se només del claim i consultar el rol real a la base de dades; hi tornarem en parlar de revocació.

Què sí que hi has de ficar: el mínim, estable i no sensible. sub, exp, iss i, com a molt, un rol. Un token ben dissenyat ocupa 150-250 bytes.

  1. El middleware d'autenticació

// src/middleware/autenticacio.js
import jwt from 'jsonwebtoken';
import { entorn } from '../config/entorn.js';

const REPTE = 'Bearer realm="api.botigaaroma.example"';

/** Resposta 401 uniforme, amb la capçalera que exigeix el RFC 9110. */
function noAutenticat(res, codi, missatge) {
  res.set('WWW-Authenticate', REPTE);
  return res.status(401).json({ error: { codi, missatge, detalls: [] } });
}

/**
 * Exigeix un JWT vàlid. Si n'hi ha, deixa la identitat a req.usuari i cedeix
 * el torn; si no, respon 401 i talla la cadena.
 */
export function autenticar(req, res, next) {
  const capcalera = req.get('Authorization');

  // --- 1. Token absent o mal format ---
  if (!capcalera || !capcalera.startsWith('Bearer ')) {
    return noAutenticat(
      res,
      'no_autenticat',
      'Falta la capçalera Authorization amb un token Bearer.'
    );
  }

  const token = capcalera.slice('Bearer '.length).trim();

  try {
    // --- 2. Verificació de la signatura i de la caducitat ---
    const contingut = jwt.verify(token, entorn.jwtSecret, {
      algorithms: ['HS256'],                  // MAI confiar en l''alg' del token
      issuer: 'api.botigaaroma.example',
    });

    // --- 3. Identitat disponible per a la resta de la cadena ---
    req.usuari = {
      id: contingut.sub,
      rol: contingut.rol ?? 'client',
    };

    next();
  } catch (error) {
    // --- 4. Caducat i invàlid són casos DIFERENTS del catàleg ---
    if (error.name === 'TokenExpiredError') {
      return noAutenticat(
        res,
        'token_caducat',
        'El token ha caducat. Torna a iniciar sessió per obtenir-ne un de nou.'
      );
    }
    return noAutenticat(res, 'no_autenticat', 'El token no és vàlid.');
  }
}

/**
 * Variant opcional: si hi ha token vàlid, omple req.usuari; si no hi ha
 * token, deixa passar igualment. Útil a GET /v1/cafes, que és públic però
 * es pot personalitzar si sabem qui pregunta.
 */
export function autenticarOpcional(req, res, next) {
  const capcalera = req.get('Authorization');
  if (!capcalera) return next();
  return autenticar(req, res, next);
}

  1. 401 ben fet: WWW-Authenticate i els dos codis del catàleg

Un 401 ha de portar la capçalera WWW-Authenticate; ho exigeix el RFC 9110. La seva funció és dir-li al client com autenticar-se, i ometre-la és un incompliment del protocol que a més deixa l'integrador sense pistes.

# Sense token
curl -i -s http://localhost:3000/v1/comandes | head -6
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api.botigaaroma.example"
Content-Type: application/json; charset=utf-8

{"error":{"codi":"no_autenticat","missatge":"Falta la capçalera Authorization amb un token Bearer.","detalls":[]}}
# Amb un token caducat
curl -s -H "Authorization: Bearer $TOKEN_VELL" http://localhost:3000/v1/comandes | jq .error.codi
"token_caducat"

Per què el catàleg distingeix no_autenticat de token_caducat. Tots dos són 401, però la reacció del client és diferent: davant de token_caducat, la SPA renova el token en silenci i repeteix la petició sense molestar l'usuari; davant de no_autenticat, el porta a la pantalla d'accés. Amb un sol codi, el client hauria d'endevinar-ho. És un exemple perfecte de per a què serveix tenir un codi de negoci a més del codi HTTP (02-04).

Ara protegim les rutes. A src/rutes/comandes.js:

import { autenticar } from '../middleware/autenticacio.js';

// Totes les rutes d'aquest router exigeixen identitat. Aquest app.use() sense
// ruta s'aplica a TOT el que es declari a continuació al router.
rutesComandes.use(autenticar);

rutesComandes.get('/', validar(esquemaConsultaComandes, 'query'), controladorComandes.llistar);
rutesComandes.get('/:id', controladorComandes.obtenir);

  1. Autorització per rol: exigirRol

La Botiga Aroma té quatre rols:

Rol Qui Què fa
client Compradors Comprar, veure les seves comandes, escriure ressenyes
empleat Atenció al client Veure totes les comandes, moderar ressenyes, gestionar enviaments
administrador Responsables Tot l'anterior més gestionar el catàleg
soci RàpidEnviaments Només actualitzar l'enviament de les comandes que li corresponen
// src/middleware/autenticacio.js  (continuació)

/**
 * Exigeix que req.usuari tingui un dels rols indicats.
 * Es registra SEMPRE després d'autenticar: sense identitat no hi ha permisos.
 */
export function exigirRol(...rolsPermesos) {
  return (req, res, next) => {
    // Salvaguarda: si falta req.usuari és que l'ordre és incorrecte.
    if (!req.usuari) {
      return noAutenticat(res, 'no_autenticat', 'Aquesta operació requereix autenticació.');
    }

    if (!rolsPermesos.includes(req.usuari.rol)) {
      // 403, no 401: sabem qui és; simplement no pot.
      return res.status(403).json({
        error: {
          codi: 'permisos_insuficients',
          missatge: `Aquesta operació requereix un d'aquests rols: ${rolsPermesos.join(', ')}.`,
          detalls: [],
        },
      });
    }

    next();
  };
}

I les rutes de cafès queden amb la seva declaració de permisos a la vista:

// src/rutes/cafes.js  (versió final del mòdul)
import { autenticar, exigirRol } from '../middleware/autenticacio.js';

// Lectura PÚBLICA: el catàleg és l'aparador de la botiga.
rutesCafes.get('/', validar(esquemaConsultaCafes, 'query'), controladorCafes.llistar);
rutesCafes.get('/:id', validar(esquemaIdCafe, 'params'), controladorCafes.obtenir);

// Escriptura: només empleats i administradors.
rutesCafes.post(
  '/',
  autenticar,
  exigirRol('empleat', 'administrador'),
  validar(esquemaCrearCafe),
  controladorCafes.crear
);
rutesCafes.put(
  '/:id',
  autenticar,
  exigirRol('empleat', 'administrador'),
  validar(esquemaIdCafe, 'params'),
  validar(esquemaReemplacarCafe),
  controladorCafes.reemplacar
);
rutesCafes.patch(
  '/:id',
  autenticar,
  exigirRol('empleat', 'administrador'),
  validar(esquemaIdCafe, 'params'),
  validar(esquemaModificarCafe),
  controladorCafes.modificar
);

// Esborrar del catàleg: només administradors.
rutesCafes.delete(
  '/:id',
  autenticar,
  exigirRol('administrador'),
  validar(esquemaIdCafe, 'params'),
  controladorCafes.esborrar
);

L'ordre de la cadena és obligatori: autenticarexigirRolvalidar → controlador. Autenticar abans d'autoritzar és evident; validar després de comprovar permisos és menys obvi i també deliberat: no té sentit gastar cicles analitzant el cos d'algú que no té dret a enviar-lo, i a més evita filtrar informació sobre la forma esperada del recurs a qui no l'hauria de conèixer.

  1. Autorització a nivell de recurs

El rol no n'hi ha prou. La Marta (cli_842) té rol client i pot consultar comandes… però només les seves. Cap middleware genèric no pot saber això, perquè depèn de les dades:

// src/serveis/comandes.js  (afegit)

export const serveiComandes = {
  /**
   * Obté una comanda comprovant que qui la demana la pot veure.
   * La comprovació viu al SERVEI perquè necessita la comanda carregada:
   * només consultant la base de dades se sap de qui és.
   */
  obtenirPer(id, usuari) {
    const comanda = repositoriComandes.buscarPerId(id);
    if (!comanda) return { noTrobat: true };

    const esPropietari = comanda.clientId === usuari.id;
    const esPersonal = ['empleat', 'administrador'].includes(usuari.rol);

    if (!esPropietari && !esPersonal) {
      return { noTrobat: true }; // vegeu la secció 14
    }

    return { comanda };
  },

  /** Llista comandes, restringint-les al client mateix si no és personal. */
  llistarPer(criteris, usuari) {
    const esPersonal = ['empleat', 'administrador'].includes(usuari.rol);

    // Un client NO pot consultar les comandes d'un altre encara que ho demani
    // per paràmetre de consulta: s'ignora el que enviï i es força el seu id.
    const clientId = esPersonal ? criteris.clientId : usuari.id;

    return repositoriComandes.buscar({ ...criteris, clientId });
  },
};

Aquesta darrera funció mereix atenció. Si ens limitéssim a passar req.query.clientId al repositori, qualsevol client autenticat llegiria les comandes de qualsevol altre amb ?clientId=cli_999. És la vulnerabilitat coneguda com a IDOR (Insecure Direct Object Reference), i encapçala des de fa anys la llista de riscos específics d'API de l'OWASP precisament perquè és invisible: les proves funcionals passen, el 401 funciona, el rol és correcte… i tot i així es filtren dades alienes.

La regla, que convé escriure a la guia d'estil de l'equip: l'identificador del propietari no es pren mai de l'entrada del client; es pren del token.

  1. 403 o 404: quan ocultar l'existència

Quan la Marta demana GET /v1/comandes/com_9999, una comanda que existeix però és d'una altra persona, hi ha dues respostes defensables:

Resposta Avantatge Inconvenient
403 permisos_insuficients Honesta i més fàcil de depurar Confirma que aquella comanda existeix
404 comanda_no_trobada No filtra res Pot desconcertar un integrador legítim

Que un 403 filtri informació no és un detall teòric: permet enumerar recursos. Provant com_5001, com_5002, com_5003… es distingeix "existeix però no és teva" (403) de "no existeix" (404), i així s'esbrina quantes comandes té la botiga i a quin ritme creix. És intel·ligència competitiva servida en safata, i en altres dominis (històries clíniques, expedients) el fet mateix que un recurs existeixi ja és informació sensible.

Criteri de la Botiga Aroma:

  • 404 quan el recurs pertany a una altra persona i qui el demana no té cap motiu legítim per saber que existeix. És el cas de les comandes alienes.
  • 403 quan el recurs és clarament compartit o públic i el que falta és un permís d'operació. Exemple: un client que intenta DELETE /v1/cafes/caf_001 rep 403, perquè el cafè és públic i ningú no dubta que existeixi.

L'important és triar un criteri i documentar-lo. Una API que retorna 403 unes vegades i 404 unes altres per al mateix tipus de situació és impossible d'integrar.

  1. La matriu de permisos de la Botiga Aroma

Endpoint Públic client empleat administrador soci
GET /v1/cafes
GET /v1/cafes/{id}
POST /v1/cafes
PUT/PATCH /v1/cafes/{id}
DELETE /v1/cafes/{id}
POST /v1/clients
POST /v1/sessions
GET /v1/clients/{id} Només el seu
GET /v1/comandes Només les seves Només assignades
GET /v1/comandes/{id} Només la seva Només assignades
POST /v1/comandes
POST /v1/comandes/{id}/pagament Només la seva
PUT /v1/comandes/{id}/enviament
POST /v1/cafes/{id}/ressenyes
POST /v1/ressenyes/{id}/aprovacio

Dues files mereixen comentari. POST /v1/comandes/{id}/pagament no el pot fer un empleat: ningú no ha de poder pagar en nom d'un altre, i limitar-ho és tant una mesura antifrau com una protecció per al mateix empleat. I PUT /v1/comandes/{id}/enviament és l'única cosa que pot tocar el soci, que és una màquina de RàpidEnviaments: el principi de mínim privilegi portat a la pràctica.

Aquesta taula no és documentació decorativa: és l'especificació dels exigirRol del codi, forma part d'openapi.yaml (02-08) i a 03-08 es convertirà en proves que comproven que cada cel·la ❌ retorna efectivament 403.

  1. Caducitat, tokens de refresc i revocació

El problema, dit sense embuts: un JWT vàlid no es pot invalidar. No hi ha estat al servidor per esborrar; mentre la signatura quadri i exp no hagi passat, el token val. Si a algú li roben el token, l'atacant entra fins que caduqui. I "tancar sessió" al client només esborra el token d'aquell dispositiu: la còpia robada continua funcionant.

Les estratègies, i els seus costos:

Estratègia Com funciona Cost
Vida curta exp de 15 min a 1 h Finestra d'exposició petita; obliga a renovar sovint
Token de refresc Token llarg (dies) que només serveix per demanar-ne un de nou S'ha d'emmagatzemar i poder revocar
Llista de revocació Taula de jti invalidats, consultada a cada petició Reintrodueix l'estat que volíem evitar
Canvi de secret Rotar JWT_SECRET Invalida tots els tokens de cop

El patró habitual combina les dues primeres:

sequenceDiagram
  participant C as Client
  participant API
  C->>API: POST /v1/sessions (email + contrasenya)
  API-->>C: token d'accés (1 h) + refresc (30 dies)
  C->>API: GET /v1/comandes amb Bearer
  API-->>C: 200 OK
  Note over C,API: passa una hora
  C->>API: GET /v1/comandes amb Bearer
  API-->>C: 401 token_caducat
  C->>API: POST /v1/sessions/renovacio (refresc)
  API-->>C: token d'accés nou
  C->>API: GET /v1/comandes (reintent)
  API-->>C: 200 OK

La clau del repartiment: el token d'accés és curt i sense estat, així que la majoria de peticions no consulten res; el token de refresc és llarg però es desa a la base de dades, així que sí que es pot revocar —i es fa servir un cop cada hora, no a cada petició—. Es concentra l'estat allà on gairebé no costa.

Per a la Botiga Aroma, el compromís raonable és: accés d'1 hora, refresc de 30 dies desat a la base de dades i revocable, revocació immediata en tres situacions (canvi de contrasenya, tancament de sessió explícit, sospita de robatori) i, per a les operacions crítiques —pagar, canviar la contrasenya—, consultar el rol real a la base de dades en lloc de fiar-se del claim del token.

Un avís de disseny: no caiguis en la temptació de consultar la base de dades a cada petició "per seguretat". Si fas això, has reconstruït les sessions amb estat pagant a més el cost dels JWT. Si el teu cas exigeix revocació immediata universal, les sessions amb galeta són una opció legítima i més simple; tria-les conscientment.

  1. On desa el token el client

Lloc Risc XSS Risc CSRF Nota
localStorage Alt: qualsevol script el llegeix Cap El més còmode i el més comú
sessionStorage Alt Cap Es perd en tancar la pestanya
Variable en memòria Baix Cap Es perd en recarregar
Galeta HttpOnly + Secure + SameSite Baix: JavaScript no la llegeix Requereix defensa L'opció més segura per a navegadors

Recomanació per a la SPA de la Botiga Aroma: galeta HttpOnly; Secure; SameSite=Strict per al token de refresc, i el token d'accés en memòria. Així, un atac XSS no pot robar la sessió de llarga durada, que és el que és veritablement valuós.

Per a l'Aroma Mòbil, que no és un navegador, es fa servir el magatzem segur del sistema (Keychain a iOS, Keystore a Android), mai un fitxer de preferències en clar.

I tres regles que valen per a qualsevol client: el token viatja només per HTTPS (en clar, qualsevol de la xarxa el copia); mai a la URL (?token=... acaba als logs del servidor, a l'històric del navegador i a la capçalera Referer); i mai s'escriu en un log. Tot això s'amplia a 04-02.

Errors Comuns i Consells

1. Retornar 403 quan toca 401, o a l'inrevés. El client no sap si ha de renovar el token o rendir-se. Autenticació és 401; permisos és 403.

2. Ometre WWW-Authenticate al 401. Ho exigeix el RFC i sense ella l'integrador no sap quin esquema fer servir.

3. Desar contrasenyes amb SHA-256. Massa ràpid. Fes servir bcrypt, scrypt o Argon2.

4. Confiar en l'alg de la capçalera del token. Passa sempre algorithms: ['HS256'] a jwt.verify.

5. Ficar dades personals o canviants al payload. És llegible per qualsevol i no s'actualitza fins que caduca.

6. Acceptar el rol al cos del registre. Escalada de privilegis immediata. El rol el fixa el servidor.

7. Filtrar el hashContrasenya en una resposta. Construeix la representació camp a camp; no retornis mai el model intern tal qual.

8. Comprovar només el rol i oblidar la propietat. Un client autenticat amb ?clientId=cli_999 no ha de veure comandes alienes. L'id del propietari surt del token.

9. Missatges d'inici de sessió diferents segons la fallada. Permet enumerar els correus registrats. Un únic missatge genèric.

10. Un JWT sense exp. Un token etern és una clau que no es pot canviar mai.

Consell: escriu la matriu de permisos abans de programar els middleware, revisa-la amb qui conegui el negoci i converteix-la en proves (03-08). Els forats d'autorització apareixen gairebé sempre als endpoints que ningú no va pensar a revisar.

Exercicis

Exercici 1

Implementa GET /v1/clients/:id amb aquestes regles: un client només pot veure la seva pròpia fitxa; empleat i administrador en poden veure qualsevol; el soci no en pot veure cap. Decideix si un client que demana la fitxa d'un altre rep 403 o 404, justifica l'elecció amb el criteri de la secció 14 i escriu la ruta amb la seva cadena de middleware.

Exercici 2

Un desenvolupador proposa incloure al payload del JWT el nom complet, el correu, l'adreça d'enviament i la llista de les últimes cinc comandes, "perquè la SPA no els hagi de demanar". Enumera quatre problemes concrets d'aquella proposta i ofereix una alternativa que resolgui la seva necessitat real.

Exercici 3

Escriu el middleware exigirPropietatOPersonal(obtenirPropietari) que generalitzi la comprovació de la secció 13: rep una funció que, donat el req, retorna l'id del propietari del recurs, i deixa passar si qui ho demana és el propietari o té rol empleat/administrador. Després explica per què, tot i ser possible, no és la millor solució per a les comandes i què es fa al seu lloc.

Solucions

Solució 1

// src/controladors/clients.js  (afegit)
obtenir(req, res) {
  const { id } = req.params;
  const peticionari = req.usuari;

  const esPersonal = ['empleat', 'administrador'].includes(peticionari.rol);
  const esElMateix = peticionari.id === id;

  if (!esPersonal && !esElMateix) {
    // 404, no 403: no confirmem que aquell client existeixi.
    return res.status(404).json({
      error: {
        codi: 'client_no_trobat',
        missatge: `No existeix cap client amb l'identificador '${id}'.`,
        detalls: [],
      },
    });
  }

  const client = serveiClients.obtenir(id);
  if (!client) {
    return res.status(404).json({
      error: {
        codi: 'client_no_trobat',
        missatge: `No existeix cap client amb l'identificador '${id}'.`,
        detalls: [],
      },
    });
  }

  res.status(200).json(clientARepresentacio(client));
},
// src/rutes/clients.js
rutesClients.get(
  '/:id',
  autenticar,
  exigirRol('client', 'empleat', 'administrador'), // exclou el 'soci'
  controladorClients.obtenir
);

Per què 404 i no 403: la fitxa d'un client conté dades personals, i aquí la mateixa existència ja és informació. Amb 403, qualsevol podria provar cli_800, cli_801, cli_802… i esbrinar quants comptes té la Botiga Aroma, a quin ritme creix i en quin rang són els identificadors. Amb un 404 uniforme, un client aliè és indistingible d'un d'inexistent. Fixa't que totes dues branques retornen exactament el mateix cos: si el missatge diferís, la fuita tornaria per la porta del darrere.

El soci queda fora per exigirRol, i rep 403: és una màquina amb un contracte clar que no inclou dades de clients, així que aquí l'honestedat no filtra res d'útil.

Solució 2

Quatre problemes concrets:

  1. Fuita de dades personals. El payload és base64, no xifrat. Qualsevol que capturi el token —un proxy corporatiu, un registre mal configurat, una extensió del navegador— llegeix el correu i l'adreça d'enviament de la Marta. Amb el token a localStorage, un XSS ho obté tot de cop.
  2. Dades obsoletes. Si la Marta canvia la seva adreça, el token continua dient l'antiga fins que caduqui. I si el token durés un mes, la SPA mostraria durant un mes dades incorrectes que a més semblen autoritzades pel servidor.
  3. Mida. Cinc comandes amb les seves línies poden ser 2 KB. Aquell token viatja a cada petició, incloses les d'imatges si estan protegides. Molts servidors i proxys limiten les capçaleres a 8 KB, i superar-ho produeix un 431 desconcertant. És trànsit malbaratat a cada crida.
  4. Responsabilitat equivocada. El token és una credencial d'identitat, no una memòria cau de dades. Barrejar-les fa que la SPA depengui de l'estructura interna del token i que qualsevol canvi a les dades del client obligui a tocar l'emissió de credencials.

Alternativa: el payload es queda amb sub, rol, exp i iss, i la SPA obté les dades amb una crida a GET /v1/clients/{id} just després d'iniciar sessió, desant-les a la seva memòria cau d'estat. La necessitat real —evitar peticions repetides— es resol amb memòria cau HTTP al client (04-06), no ficant dades a la credencial. A més, la resposta del POST /v1/sessions ja retorna client amb la representació pública, així que a la pràctica ni tan sols cal aquella crida extra.

Solució 3

// src/middleware/autenticacio.js  (afegit)

/**
 * Deixa passar si qui ho demana és el propietari del recurs o és personal
 * de la botiga. 'obtenirPropietari' rep req i retorna l'id del
 * propietari, o undefined si el recurs no existeix.
 */
export function exigirPropietatOPersonal(obtenirPropietari, codiNoTrobat) {
  return (req, res, next) => {
    if (!req.usuari) {
      return noAutenticat(res, 'no_autenticat', 'Aquesta operació requereix autenticació.');
    }

    if (['empleat', 'administrador'].includes(req.usuari.rol)) return next();

    const propietariId = obtenirPropietari(req);

    // Recurs inexistent i recurs aliè donen la MATEIXA resposta.
    if (propietariId === undefined || propietariId !== req.usuari.id) {
      // El codi concret del catàleg l'aporta qui fa servir el middleware:
      // així no cal inventar un 'recurs_no_trobat' genèric.
      return res.status(404).json({
        error: {
          codi: codiNoTrobat,
          missatge: 'No existeix el recurs sol·licitat.',
          detalls: [],
        },
      });
    }

    next();
  };
}

// Ús:
rutesComandes.get(
  '/:id',
  autenticar,
  exigirPropietatOPersonal(
    (req) => repositoriComandes.buscarPerId(req.params.id)?.clientId,
    'comanda_no_trobada'
  ),
  controladorComandes.obtenir
);

Per què no és la millor solució per a les comandes, amb tres raons:

  1. Consulta duplicada. El middleware carrega la comanda per saber de qui és, i tot seguit el controlador la torna a carregar per respondre. Són dues consultes per a una petició, i el patró es repeteix a cada endpoint protegit així.
  2. No serveix per a les col·leccions. GET /v1/comandes no té un propietari únic: cal filtrar els resultats, no acceptar o rebutjar la petició sencera. Un middleware que només sap dir sí o no no pot expressar "només les teves", i aquesta és justament l'operació més freqüent.
  3. La regla es parteix en dos llocs. Una part de "qui pot veure una comanda" queda a la ruta i una altra al servei, i quan la política canviï —per exemple, permetre que un soci vegi les comandes que té assignades— caldrà recordar tocar tots dos.

Què es fa al seu lloc: la comprovació viu al servei, amb obtenirPer(id, usuari) i llistarPer(criteris, usuari), com vam escriure a la secció 13. El servei ja té la comanda carregada, així que no hi ha consulta extra; sap filtrar a més de rebutjar; i tota la política d'accés a comandes és en un únic fitxer que es pot provar sense HTTP (03-08). El middleware exigirRol continua sent útil per al que que depèn únicament del rol —qui pot tocar el catàleg—, que és una decisió que no necessita mirar les dades.

Conclusió

L'API ja sap qui truca i què pot fer, i ho expressa amb la precisió que exigeix el contracte. L'autenticació es resol amb JWT signats amb HS256: registre amb la contrasenya protegida per bcrypt amb sal i cost ajustable, inici de sessió a POST /v1/sessions —un recurs, no un verb— amb missatge genèric per no revelar quins correus existeixen, i un middleware que verifica la signatura fixant l'algorisme, omple req.usuari i distingeix els dos casos que el catàleg separa: no_autenticat quan no hi ha token o no és vàlid, token_caducat quan simplement ha vençut, tots dos amb WWW-Authenticate com mana el RFC. L'autorització funciona en dos nivells: exigirRol per al que depèn només del rol, i la comprovació de propietat al servei per al que depèn de les dades, amb l'identificador del propietari pres sempre del token i mai de l'entrada del client.

I has vist el que un JWT no és: el payload va codificat, no xifrat, així que és llegible per qualsevol i no admet dades personals ni secrets; els seus claims no s'actualitzen fins que el token caduca; i revocar-lo exigeix reintroduir estat, que és exactament el que el model sense estat volia evitar. D'aquí ve el compromís: tokens d'accés curts i sense estat, tokens de refresc llargs, desats i revocables, i consulta a la base de dades només a les operacions crítiques.

Queda un deute tècnic que ja no es pot continuar ajornant i que has vist créixer a cada lliçó: els res.status(404).json({error: {...}}) escrits a mà estan repartits per controladors, middleware i serveis, amb el mateix cos copiat un cop i un altre; els serveis retornen {error: 'email_ja_registrat'} o {noTrobat: true} en lloc de fallar netament; i ara que hi ha controladors async per culpa de bcrypt, una excepció inesperada deixa la petició penjada sense resposta. A 03-07, Gestió d'errors, ho unifiquem tot: la classe ErrorApi amb les seves fàbriques, llançada des dels serveis sense que aquests sàpiguen d'HTTP; el middleware d'errors de quatre arguments, registrat l'últim, que distingeix un error del catàleg d'un d'inesperat i emet 500 error_intern amb tracaId i sense filtrar la pila; l'embolcall asincron() que per fi resol les promeses rebutjades; i la traducció dels errors de Zod, de SQLite i del JSON mal format a l'únic format d'error que coneix el consumidor.

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