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.
- In the dashboard, create an application of type
managementand a management client record with the permissions it needs (see Create a management client). - Generate a key pair and register the public key as a
public_keycredential of the application. For example, for ES256:openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out management-key.pem. - 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:
- It sends
grant_type=client_credentialsto<issuer>/oauth2/tokenwithresourceset to the management API identifier. The default is the origin ofbaseUrl, for examplehttps://api.eu.keenauth.com; setcredentials.audienceto change it. - Each request gets a new client assertion:
issandsubare the client ID,audis the issuer,jtiis random and the assertion is valid for 120 seconds. Thekidheader is the key's RFC 7638 thumbprint, which is how KeenAuth finds the key when the application has several (for key rotation). - The algorithm follows the key: RSA keys sign with
RS256(setalg: "PS256"for RSA-PSS), P-256 keys withES256and Ed25519 keys withEdDSA. RSA keys must have at least 2048 bits. - The access token is cached and refreshed 60 seconds before it expires. Concurrent calls share one refresh. If the API answers
401, the cached token is dropped and the next call gets a new one.
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.
- If the response has a
Retry-Afterheader (seconds or an HTTP date), the SDK waits that long. ARetry-Afterlonger than 60 seconds is not waited for: the SDK returns that response to you. - Otherwise it waits 0.5 seconds, then 1 second, each with ±25% random jitter.
timeoutMs(default 30 seconds) limits the whole call, retries included. If a wait would go past it, the SDK returns the last response instead.
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
}
- Without
before, it walks forward withafteruntil the cursor isnull. Withbefore, it walks backward. limitis the page size (1-100) and is sent with every request; the other parameters are kept as well.paginatePagesgives you whole pages instead of items.- A server that returns the same cursor twice stops the loop with
PaginationError.
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);
});
- The SDK checks the
webhook-id,webhook-timestampandwebhook-signatureheaders. The timestamp must be within 5 minutes of your server's clock, in either direction. - During a secret rotation, pass both secrets:
new Webhook([newSecret, oldSecret]). A request is accepted when anyv1signature matches any secret. - Signatures are compared in constant time.
e.kindtells you what failed:headers(a header is missing),timestamp(not a number or outside the tolerance),signature(no signature matches) orsecret(the configured secret is not a valididp_whsec_value, a configuration error raised bynew Webhook). Each kind also has its own class, such asWebhookSignatureError.verifyreturns the parsed JSON body.verifyRawreturns the verified bytes.
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;
}
parseEventaccepts JSON text, bytes or an already-decoded value. It checks the envelope and the members the type's schema declares. A missing or wrong-typed member throwsEventParseErrorwith its path, such asdata.object.reason.- An unknown type, or a known type with a
schema_versionthis SDK does not model, is anUnknownEvent. It is not an error. - Members that the schema does not declare stay on the object.
- Enumerated fields are typed as the known values plus
string, so a new value from the server still type-checks and parses.
@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.