Node.js management SDK

@keenauth/node is the TypeScript SDK for the KeenAuth management API. It needs Node.js 22 or later and has no runtime dependencies. It is not published to npm yet.

The package has these entry points:

Import Use it for
@keenauth/node The management API client, management tokens, pagination and errors
@keenauth/node/webhooks Verifying webhook signatures, without loading the API client
@keenauth/node/bff Sign-in for server apps: Express and Fastify
@keenauth/node/middleware Protecting your API with bearer tokens
@keenauth/node/testing The offline test kit

Create a client

With a secret or restricted API key:

import { KeenAuth } from "@keenauth/node";

const keenauth = new KeenAuth({
  baseUrl: "https://api.eu.keenauth.com/v1",
  apiKey: process.env.KEENAUTH_API_KEY!,
});

const user = await keenauth.users.getUser({ userId: "usr_01j9zzzzzzzzzzzzzzzzzzzz" });

baseUrl is your region's API address with /v1. It must use https; plain http is accepted only for localhost. The key is sent only in the Authorization header and only to that address. Redirects are not followed.

Authenticate without an API key

A management application can call the API with a short-lived token instead of a static key. The application signs a client assertion with its private key (private_key_jwt, RFC 7523), and the SDK exchanges it at your environment's token endpoint.

  1. In the dashboard, create an application of type management and a management client record with the permissions it needs (see Create a management client).
  2. Generate a key pair and register the public key as a public_key credential of the application. For example, for ES256: openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out management-key.pem.
  3. Keep the private key in your secret store. Pass it to the SDK:
import { readFileSync } from "node:fs";
import { KeenAuth } from "@keenauth/node";

const keenauth = new KeenAuth({
  baseUrl: "https://api.eu.keenauth.com/v1",
  credentials: {
    issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
    clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
    privateKey: readFileSync("management-key.pem", "utf8"), // PEM, a private JWK or a KeyObject
    scope: ["users:read", "users:write"], // optional; default: the application's default scopes
  },
});

What the SDK does:

A failed token request throws TokenError with the OAuth code (for example invalid_client or invalid_scope), description and status. A configuration error, such as a public key instead of a private key, throws when you create the client.

apiKey and credentials cannot be used together.

Retries

The SDK retries a request at most 3 times in total when the API answers 429, 502, 503 or 504, or when the connection fails. It retries only GET, PUT and DELETE requests and POST requests that carry an Idempotency-Key. A PATCH that carries If-Match is retried on 429 only: the rate limiter refuses a request before it runs, and If-Match keeps a change from being applied twice. Any other PATCH is never retried.

The SDK adds a random Idempotency-Key to every POST whose operation accepts one, so a retried create does not create a second object. To make your own retries safe as well, pass the same key yourself:

await keenauth.users.createUser({
  userCreateRequest: { email: "ada@example.com" },
  idempotencyKey: "signup-7d1f0c2a-0d7e-4c55",
});

Pagination

List endpoints return one page: data and listMetadata with the before and after cursors. paginate follows the cursors for you:

import { paginate } from "@keenauth/node";

for await (const user of paginate((p) => keenauth.users.listUsers(p), { limit: 100 })) {
  console.log(user.id);
  if (user.email === "ada@example.com") break; // stops without loading the next page
}

Errors

An error response becomes a ProblemError (RFC 9457) with status, code, title, detail, requestId and field errors. Check code, not the message. Codes the SDK does not know stay available as strings.

import { ProblemError } from "@keenauth/node";

try {
  await keenauth.users.getUser({ userId });
} catch (e) {
  if (e instanceof ProblemError && e.code === "not_found") {
    // …
  }
}

A response without a problem document, such as an HTML page from a proxy, becomes the generated ResponseError with the raw response.

Verify webhooks

KeenAuth signs webhooks with the Standard Webhooks v1 scheme. Verify the signature with the raw request body, before you parse it:

import express from "express";
import { Webhook, WebhookVerificationError } from "@keenauth/node/webhooks";

const webhook = new Webhook(process.env.KEENAUTH_WEBHOOK_SECRET!); // idp_whsec_…

const app = express();
app.post("/webhooks/keenauth", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers);
  } catch (e) {
    if (e instanceof WebhookVerificationError) return res.status(400).send(e.kind);
    throw e;
  }
  // handle event.type, then answer quickly
  res.sendStatus(204);
});

Webhooks can arrive more than once. Use webhook-id to ignore duplicates.

Typed events and claims

@keenauth/node/events has a type for every customer event, generated from the public event schemas. parseEvent checks a verified body and returns KeenAuthEvent | UnknownEvent. After isKnownEvent, switch (event.type) narrows the event to one type. verifyEvent in @keenauth/node/webhooks does both steps:

import { verifyEvent } from "@keenauth/node/webhooks";
import { isKnownEvent } from "@keenauth/node/events";

const event = verifyEvent(webhook, req.body, req.headers); // WebhookVerificationError or EventParseError
if (!isKnownEvent(event)) {
  // A type this SDK version does not know yet. event.type and event.data.object.id are still typed.
  return res.sendStatus(204);
}
switch (event.type) {
  case "user.created":
    console.log(event.data.object.id);
    break;
  case "email_sending.suspended":
    console.log(event.data.object.reason); // "bounce_rate" | "complaint_rate" | (string & {})
    break;
}

@keenauth/node/claims types the claims of a verified token: parseIdTokenClaims (OpenID Connect Core) and parseAccessTokenClaims (RFC 9068). They check claim types but verify nothing, so call them on the claims that the verifier returns:

import { parseAccessTokenClaims } from "@keenauth/node/claims";

const at = await verifier.verifyAccessToken(token);
const claims = parseAccessTokenClaims(at.claims);
claims.client_id; // string
claims.org_roles; // string[] | undefined
claims["tenant_tier"]; // unknown: your own claims

A claim that does not fit throws ClaimsParseError with its name.

Deprecation warnings

When a response carries a Deprecation (RFC 9745) or Sunset (RFC 8594) header, the SDK writes one warning per endpoint and header value for the life of the process. The warning names the method, the path with IDs replaced by {id} and the header values; it never contains tokens, query strings or user data. It never throws.

By default the warning goes to process.emitWarning with the type KeenAuthDeprecationWarning. To send it to your logger instead:

const keenauth = new KeenAuth({
  baseUrl: "https://api.eu.keenauth.com/v1",
  apiKey: process.env.KEENAUTH_API_KEY!,
  onWarning: (message) => logger.warn(message),
});

Telemetry header

Every request carries Idp-Client: keenauth-ts/<version> (node/<version>). It holds no personal data and cannot be turned off; KeenAuth uses it for support and to tell you about deprecated SDK versions.