@zanii-id/sdk
v0.7.0
Published
Zanii ID TypeScript SDK — OAuth 2.1 / OIDC client for Node.js servers
Readme
@zanii-id/sdk
Node.js SDK for Zanii ID — OAuth 2.1 / OpenID Connect sign-in for products in the Zanii ecosystem.
Authorization Code flow with mandatory PKCE, offline id_token verification via jose,
and drop-in integrations for Express and the Next.js App Router.
npm install @zanii-id/sdkRequires Node 20+.
Use it
Configuration is read from ZANII_ISSUER, ZANII_CLIENT_ID, ZANII_CLIENT_SECRET and
ZANII_REDIRECT_URI, validated with zod, and fails with the exact missing variable names.
import { ZaniiClient } from "@zanii-id/sdk";
const zanii = new ZaniiClient();
const { url, state, nonce, codeVerifier } = await zanii.getAuthorizationUrl();
// redirect to `url`, then in your callback:
const tokens = await zanii.exchangeCode({ code, state }, expectedState, codeVerifier);
const claims = await zanii.verifyIdToken(tokens.id_token!, nonce);Express
import { buildAuthRouter, requireAuth } from "@zanii-id/sdk/express";
app.use("/auth", buildAuthRouter(zanii, { sessionSecret, onUser }));
app.get("/dashboard", requireAuth(zanii, { sessionSecret }), (req, res) =>
res.json({ zanii_user_id: req.zaniiUser!.id }),
);Next.js (App Router)
// app/auth/[...zanii]/route.ts
import { createAuthHandlers } from "@zanii-id/sdk/next";
export const { GET } = createAuthHandlers(zanii, { sessionSecret });getSession() reads the signed session cookie inside server components.
Agent activity
import { subjectTag, fetchVerifiedActivity } from "@zanii-id/sdk/activity";
// requires the optional peer: npm i @zanii/subject
const tag = await subjectTag(user.did, CLIENT_ID);
const entries = await fetchVerifiedActivity(tag);Every receipt is verified offline — signature, delegation chain, scope and tag match.
Entries that fail keep their place in the list with a flagReason instead of vanishing.
Lifecycle webhooks
Register a lifecycle_webhook_url on your client (console or /orgs/clients) and Zanii ID
POSTs account events to it, signed over the raw body with the same webhook_secret you
already hold: X-Zanii-Signature: sha256=<hmac> plus X-Zanii-Event. verifyLifecycleEvent
checks it in constant time and returns the parsed event. Give it the raw bytes, before any
JSON middleware touches them.
import { LifecycleSignatureError, verifyLifecycleEvent } from "@zanii-id/sdk";
app.post("/webhooks/zanii-id", express.raw({ type: "application/json" }), async (req, res) => {
let ev;
try {
ev = verifyLifecycleEvent(req.body, req.header("x-zanii-signature"), WEBHOOK_SECRET);
} catch (err) {
if (err instanceof LifecycleSignatureError) return res.sendStatus(401);
throw err;
}
if (ev.event === "user.deleted") await users.erase(ev.sub);
res.sendStatus(204);
});| event | what to do |
|---|---|
| user.deleted | erase or anonymise your copy of that sub |
| consent.revoked | drop the local session; the next login shows the consent screen again |
| user.password_changed | refresh tokens are already dead; drop the local session |
| user.email_changed | update the display email; data.email, data.email_verified |
Deliveries retry with backoff for about an hour (8 attempts). Answer 2xx quickly and do the
work afterwards. sub is the identifier you receive at login, so key on it as usual.
Two-step verification and amr
Every id_token carries acr (urn:zanii:mfa when the session has a second factor,
urn:zanii:pwd otherwise) and amr (["pwd"], ["pwd","otp"] or ["webauthn"]). To
require a second factor, start the flow with it:
import { ACR_MFA } from "@zanii-id/sdk";
const { url } = await client.getAuthorizationUrl({ acrValues: ACR_MFA }); // or prompt: "login", maxAge: 0With the Express router or the Next.js handlers, /auth/login?acr=mfa does the same.
Enrolled users are challenged in place; users with no second factor come back with
error=unmet_authentication_requirements - send them to ${issuer}/ui/account to enrol.
Next.js sessions and refresh
getSession() returns the session with accessTokenExpired: true once the 10-minute access
token has lapsed; redirect to /auth/refresh?next=/current/path and the route handler
rotates the tokens and rewrites the cookie. Add age to the scopes for age_over +
age_method + age_provider (a verified check) or age_declared (the sign-up gate only).
The default scope includes offline_access, which
is what a refresh token requires. JWKS fetches are memoised per issuer for the process.
Machines, agents and logout (0.3.0)
const cc = await client.clientCredentials({ scope: "agent", audience: "cli_other" });
const ex = await client.exchangeToken(userAccessToken, { actorToken: cc.access_token, audience: "cli_other" });
// AuthorizationError code "invalid_grant" = the audience's organization refuses this actor (receipted either way).
const device = await client.deviceAuthorize({ scope: "openid" }); // show device.user_code / verification_uri_complete
const tokens = await client.pollDeviceToken(device); // interval, slow_down, expires_in, Retry-After
import { generateDpopKey } from "@zanii-id/sdk";
const bound = new ZaniiClient({ dpopKey: await generateDpopKey() }); // DPoP proofs on token, userinfo, revoke
const keyed = new ZaniiClient({ clientAssertionKey: { privateKey, kid: "k1" } }); // private_key_jwt, no secret
const { url } = await client.getAuthorizationUrl({ par: true, acrValues: ACR_MFA }); // PAR: only client_id + request_uri
client.logoutUrl(idToken, "https://app.test/bye", "s1");
const lt = await client.verifyLogoutToken(logoutToken); // back-channel: end sessions for lt.sid
const sid = client.parseFrontchannelLogout(url.searchParams); // front-channel iframe ?iss=&sid=buildAuthRouter (Express) and createAuthHandlers (Next, now { GET, POST }) mount
POST .../backchannel-logout and GET .../frontchannel-logout when you pass
onBackchannelLogout: (sid) => ...; the hook ends every local session tied to that IdP session id.
Server-to-server: postReceiptEvent(issuer, event, { platformClientId, webhookSecret }) relays a
ledger receipt event to the user's activity page; new OrgClient(issuer, apiKey) wraps /orgs/*
(settings and the agent deny list, clients, lifecycle URLs, domain and workload binding, screenAgent).
Claims: ZaniiClaims (from verifyIdToken) and UserInfo type act, cnf and the age scope.
Cheap for the issuer: discovery and JWKS cached, timeoutMs (default 10 s) on every fetch,
reads retried once only when the server answered 429/502/503/504 (honouring Retry-After),
token grants never retried, device polling never faster than told.
Prove it (0.4.0)
const info = await client.introspect(accessToken); // RFC 7662 + Zanii: act, cnf, cst, kya, consent
if (info.active && info.consent) console.log(info.consent.commitment, info.consent.scope);The end-to-end verifier is the Python command zanii-id verify <did> (pip install 'zanii-id[verify]').
Notes
- Confidential clients authenticate with HTTP Basic; without a secret the client runs in
public mode and sends
client_idin the body. - Token requests are never retried.
- Session cookies are signed (HMAC-SHA256),
HttpOnly,SameSite=Lax. They are tamper-evident, not encrypted — keep sensitive values out of them.
Licence
Apache-2.0. See LICENSE.
