Protéger une API Express

Ce démarrage rapide protège une API Express 5 avec des jetons d'accès KeenAuth. Vous ajoutez le middleware expressAuth du SDK Node.js, exigez une scope sur une route et testez l'API hors ligne avec le kit de test du SDK. Express n'est pas une dépendance du SDK ; le middleware fonctionne aussi avec Express 4.

L'API comporte trois points de terminaison :

Point de terminaison Exige
GET /api/public rien
GET /api/private un jeton d'accès valide pour votre API
GET /api/messages un jeton d'accès valide avec la scope messages:read

Avant de commencer

1. Installer le SDK

npm install @keenauth/node express@5.2.1

Le SDK n'est pas encore publié. En attendant, l'exemple renvoie vers le code source du SDK dans son package.json.

2. Configurer

L'API lit sa configuration uniquement depuis des variables d'environnement. Placez-les dans un fichier .env, que npm start charge avec node --env-file-if-exists=.env, et ne le versionnez jamais.

Variable Obligatoire Signification
KEENAUTH_ISSUER oui L'émetteur de votre environnement
KEENAUTH_AUDIENCE oui L'identifiant de votre API
PUBLIC_BASE_URL oui L'URL appelée par les clients (voir Derrière un proxy)
KEENAUTH_WEBHOOK_SECRET recommandé Le secret de signature idp_whsec_ du point de terminaison webhook qui envoie token.revoked (deux valeurs séparées par des virgules pendant la rotation). Sans lui, l'API ne connaît jamais les jetons révoqués
PORT non Port d'écoute, par défaut 8080

3. Protéger vos routes

createApp crée le protecteur et les routes. expressAuth(protector) exige un jeton valide ; avec { scopes }, il exige aussi des scopes. Le gestionnaire lit les revendications vérifiées dans req.auth.

export function createApp({ verifier, publicBaseUrl, revocation, webhookSecrets = [] }) {
  const protector = createProtector(verifier, {
    publicBaseUrl,
    revocation,
    // The error never contains the token or claim values; never log the Authorization or DPoP header.
    onError: (err) => console.warn(`rejected: ${err.message}`),
  });

  const app = express();
  app.disable("x-powered-by");

  // No token needed.
  app.get("/api/public", (req, res) => {
    res.json({ message: "Hello from a public endpoint." });
  });

  // Any valid access token for this API.
  app.get("/api/private", expressAuth(protector), (req, res) => {
    res.json({ message: "Hello from a private endpoint.", sub: req.auth.subject });
  });

  // A valid access token that also carries the messages:read scope.
  app.get("/api/messages", expressAuth(protector, { scopes: ["messages:read"] }), (req, res) => {
    res.json({ messages: ["Hello from a scoped endpoint."] });
  });

  // token.revoked deliveries from KeenAuth; the handler reads the raw body itself, so no JSON parser runs before it.
  if (webhookSecrets.length > 0) {
    app.post(WEBHOOK_PATH, ...createWebhookHandler({ secrets: webhookSecrets, revocation }));
  } else {
    console.warn("KEENAUTH_WEBHOOK_SECRET is not set: token.revoked events are not received, so revoked tokens stay valid until they expire");
  }

  return app;
}

server.js construit le vérificateur réel à partir de la configuration. Le vérificateur récupère les clés de signature de votre environnement la première fois qu'il rencontre un jeton.

const issuer = process.env.KEENAUTH_ISSUER;
const audience = process.env.KEENAUTH_AUDIENCE;
// The URL clients call: DPoP proofs are checked against it, never against the Host header.
const publicBaseUrl = process.env.PUBLIC_BASE_URL;
if (!issuer || !audience || !publicBaseUrl) {
  console.error("KEENAUTH_ISSUER, KEENAUTH_AUDIENCE and PUBLIC_BASE_URL are required");
  process.exit(1);
}

// createVerifier refuses an issuer under the reserved .invalid domain, so a test token can never pass here.
const verifier = createVerifier({ issuer, audience });
const revocation = createMemoryRevocationCache();
// The signing secret (idp_whsec_...) of the webhook endpoint that delivers token.revoked; comma separated while rotating.
const webhookSecrets = (process.env.KEENAUTH_WEBHOOK_SECRET ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const app = createApp({ verifier, publicBaseUrl, revocation, webhookSecrets });

const port = Number(process.env.PORT || 8080);
app.listen(port, () => console.log(`listening on port ${port}`));

4. Lancer et appeler l'API

npm start

Pour un premier appel, obtenez un jeton avec le grant client credentials d'une application machine-à-machine qui dispose de la scope messages:read sur votre API :

ACCESS_TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d resource=https://api.example.com \
  -d scope=messages:read \
  https://your-environment.eu.keenauth.app/oauth2/token | jq -r .access_token)

curl -i http://localhost:8080/api/messages -H "Authorization: Bearer $ACCESS_TOKEN"

5. Tester sans réseau

@keenauth/node/testing génère des jetons signés avec une clé de test publique, et createTestVerifier n'accepte que ces jetons. Les tests transmettent ce vérificateur à createApp et appellent l'application sur un port local ; ils ne nécessitent donc ni réseau ni compte KeenAuth. Voir Kit de test hors ligne.

test("scope check: 403 insufficient_scope without messages:read, 200 with it", async () => {
  await expectProblem(await get("/api/messages", bearer(mintAccessToken(AUDIENCE))), 403, "insufficient_scope", /scope="messages:read"/);
  const ok = await get("/api/messages", bearer(mintAccessToken(AUDIENCE, { claims: { scope: "profile messages:read" } })));
  assert.equal(ok.status, 200);
});
npm test

Les tests de l'exemple vérifient qu'un jeton valide est accepté, et qu'un jeton absent, expiré ou destiné à une autre audience, une scope manquante, un jeton placé dans la query de l'URL et un jeton révoqué sont rejetés avec les statuts ci-dessous. Ils envoient aussi un jeton lié à DPoP avec une preuve pour PUBLIC_BASE_URL, et vérifient que server. The tests also send signed token.revokeddeliveries: a valid one revokes the token, a forged, tampered or stale one gets400 and revokes nothing, and a repeated one has one effect.js refuse de démarrer avec l'émetteur de test.

Réponses

Requête Statut code dans le corps
Pas d'en-tête Authorization 401 missing_token
Jeton expiré, jeton pour une autre API, mauvaise signature, jeton révoqué 401 invalid_token
Jeton sans la scope requise 403 insufficient_scope
Jeton dans la query de l'URL (?access_token=) 400 invalid_request

Le corps est application/problem+json et l'en-tête WWW-Authenticate transporte le challenge standard. La table complète figure dans Protéger votre API avec un middleware.

Derrière un proxy

Si les clients utilisent DPoP, le middleware vérifie que chaque preuve a été produite pour l'URL appelée par le client. Définissez PUBLIC_BASE_URL sur l'URL externe appelée par les clients, par exemple https://api.example.com, y compris derrière un proxy ou un load balancer. Cette variable est obligatoire et doit utiliser https (http uniquement pour localhost, 127.0.0.1 ou [::1]) ; l'application refuse de démarrer sans elle. Le middleware ne fait jamais confiance aux en-têtes X-Forwarded-*, quelle que soit la valeur du paramètre Express trust proxy.

Jetons révoqués

Un jeton d'accès reste valide jusqu'à son expiration ; KeenAuth informe donc votre API lorsque des jetons sont révoqués. L'exemple transmet un cache de révocation en mémoire au protecteur et sert un webhook token.revoked sur POST /webhooks/keenauth (webhook.js) qui l'alimente.

  1. Dans le tableau de bord, créez un point de terminaison webhook avec l'URL https://<your host>/webhooks/keenauth qui s'abonne à token.revoked, puis exportez son secret de signature dans KEENAUTH_WEBHOOK_SECRET.
  2. Le gestionnaire vérifie la signature avec le SDK sur le corps brut et répond 400 lorsqu'elle est invalide ou que l'horodatage s'écarte de plus de cinq minutes. Il associe le sélecteur de l'événement (access_token, session, user, client, org, org_user, client_user) à un marqueur de révocation et répond 204 ; le middleware rejette ensuite chaque jeton correspondant émis avant not_before.
  3. KeenAuth livre au moins une fois, donc le gestionnaire applique chaque webhook-id une seule fois. Si le marqueur ne peut pas être stocké, il répond 503 et KeenAuth renvoie l'événement. Il ignore les autres types d'événements et les sélecteurs qu'aucun jeton d'accès ne peut correspondre (refresh_family, api).

Le gestionnaire journalise l'identifiant de l'événement, le type de sélecteur et la raison, jamais le corps, une signature ni le secret. Chaque processus a son propre cache ; avec plusieurs instances, placez les marqueurs dans un stockage partagé derrière la même méthode revoke. Voir Cache de révocation.

Étapes suivantes