Proteger una API de Express

Esta guía de inicio rápido protege una API de Express 5 con tokens de acceso de KeenAuth. Añada el middleware expressAuth del SDK de Node.js, requiera un scope en una ruta y pruebe la API sin conexión con el kit de pruebas del SDK. Express no es una dependencia del SDK; el middleware también funciona con Express 4.

La API tiene tres endpoints:

Endpoint Requiere
GET /api/public nada
GET /api/private un token de acceso válido para su API
GET /api/messages un token de acceso válido con el scope messages:read

Antes de empezar

1. Instalar el SDK

npm install @keenauth/node express@5.2.1

El SDK todavía no está publicado. Mientras tanto, el ejemplo enlaza con el código fuente del SDK en su package.json.

2. Configurar

La API lee su configuración solo desde variables de entorno. Colóquelas en un archivo .env, que npm start carga con node --env-file-if-exists=.env, y nunca suba ese archivo al control de versiones.

Variable Obligatoria Significado
KEENAUTH_ISSUER sí El emisor de su entorno
KEENAUTH_AUDIENCE sí El identificador de su API
PUBLIC_BASE_URL sí La URL que llaman los clientes (consulte Detrás de un proxy)
KEENAUTH_WEBHOOK_SECRET recomendada El secreto de firma idp_whsec_ del endpoint de webhook que entrega token.revoked (dos, separados por comas, mientras los rota). Sin él, la API nunca se entera de los tokens revocados
PORT no Puerto de escucha, por defecto 8080

3. Proteger sus rutas

createApp crea el protector y las rutas. expressAuth(protector) requiere un token válido; con { scopes } también requiere scopes. El manejador lee los claims verificados de 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 construye el verificador real a partir de la configuración. El verificador obtiene las claves de firma de su entorno la primera vez que ve un token.

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. Ejecutar y llamar a la API

npm start

Para una primera llamada, obtenga un token con el flujo de client credentials de una aplicación máquina a máquina que tenga el scope messages:read en su 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. Probar sin red

@keenauth/node/testing emite tokens firmados con una clave de prueba pública, y createTestVerifier acepta solo esos tokens. Las pruebas pasan ese verificador a createApp y llaman a la aplicación en un puerto local, así que no necesitan red ni una cuenta de KeenAuth. Consulte Kit de pruebas sin conexión.

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

Las pruebas del ejemplo comprueban que se acepta un token válido y que un token ausente, caducado o con audiencia incorrecta, un scope ausente, un token en la query de la URL y un token revocado se rechazan con los estados indicados a continuación. También envían un token vinculado con DPoP con una prueba para PUBLIC_BASE_URL, y comprueban 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 se niega a arrancar con el emisor de prueba.

Respuestas

Solicitud Estado code en el cuerpo
Sin cabecera Authorization 401 missing_token
Token caducado, token de otra API, firma incorrecta, token revocado 401 invalid_token
Token sin el scope requerido 403 insufficient_scope
Token en la query de la URL (?access_token=) 400 invalid_request

El cuerpo es application/problem+json y la cabecera WWW-Authenticate incluye el desafío estándar. La tabla completa está en Proteger su API con middleware.

Detrás de un proxy

Si los clientes usan DPoP, el middleware comprueba que cada prueba se hizo para la URL que llamó el cliente. Establezca PUBLIC_BASE_URL en la URL externa que llaman los clientes, por ejemplo https://api.example.com, incluso detrás de un proxy o balanceador de carga. Es obligatoria y debe usar https (http solo para localhost, 127.0.0.1 o [::1]); la aplicación se niega a arrancar sin ella. El middleware nunca confía en las cabeceras X-Forwarded-*, sea cual sea la configuración de trust proxy de Express.

Tokens revocados

Un token de acceso sigue siendo válido hasta que caduca, así que KeenAuth informa a su API cuando se revocan tokens. El ejemplo pasa una caché de revocación en memoria al protector y sirve un webhook token.revoked en POST /webhooks/keenauth (webhook.js) que la alimenta.

  1. En el panel, cree un endpoint de webhook con la URL https://<your host>/webhooks/keenauth que se suscriba a token.revoked, y exporte su secreto de firma como KEENAUTH_WEBHOOK_SECRET.
  2. El manejador verifica la firma con el SDK sobre el cuerpo sin procesar y responde 400 cuando no es válida o cuando la marca de tiempo difiere más de cinco minutos. Asigna el selector del evento (access_token, session, user, client, org, org_user, client_user) a un marcador de revocación y responde 204; el middleware rechaza entonces todos los tokens coincidentes emitidos antes de not_before.
  3. KeenAuth entrega al menos una vez, así que el manejador aplica cada webhook-id una sola vez. Si no se puede guardar el marcador, responde 503 y KeenAuth vuelve a entregar el evento. Ignora otros tipos de evento y selectores que ningún token de acceso puede cumplir (refresh_family, api).

El manejador registra el id del evento, el tipo de selector y el motivo, nunca el cuerpo, una firma ni el secreto. Cada proceso tiene su propia caché; con varias instancias, guarde los marcadores en un almacén compartido detrás del mismo método revoke. Consulte Caché de revocación.

Próximos pasos