zaniiid

Zanii ID — TypeScript reference

@zanii-id/sdk (Node 20+) and @zanii-id/react (browser). Apache-2.0.

Subpath exports: @zanii-id/sdk (core), /express, /next, /activity.

Configuration

resolveConfig merges explicit options over ZANII_* env vars and validates with zod, naming the exact missing variables. An empty ZANII_CLIENT_SECRET counts as unset, so a public client can leave it blank in a shared .env.

import { ZaniiClient } from "@zanii-id/sdk";
const zanii = new ZaniiClient();                    // or new ZaniiClient({ issuer, clientId, ... })

The client caches the discovery document for the process lifetime, and refetches after a failure rather than caching the error. The JWKS is memoised per issuer for the process (0.2.0); before that every verifyIdToken refetched it and could hit the issuer's rate limit. The default scope is openid profile email offline_access. offline_access asks for the refresh token that requireAuth and /auth/refresh rely on — it does not guarantee one. Consent is per permission, so a user of a third-party client can untick it: read tokens.scope, and refresh_token is optional on Tokens for that reason. Without it there is nothing to refresh, and the session ends with the IdP session rather than sliding; decide whether that is a re-login prompt or a degraded mode in your app.

UserInfo carries given_name, family_name and org from 0.5.1, and has an index signature so a claim added later still type-checks. ZaniiClaims gained the same fields plus cst.

Express

import { buildAuthRouter, requireAuth } from "@zanii-id/sdk/express";

app.use("/auth", buildAuthRouter(zanii, {
  sessionSecret: process.env.ZANII_SESSION_SECRET!,  // >= 32 chars, enforced
  successRedirect: "/dashboard",
  postLogoutRedirectUri: "https://yourapp.com/",
  onUser: async (user) => {                          // JIT provisioning, awaited
    await db.user.upsert({ where: { zaniiUserId: user.id }, create: {...}, update: {...} });
  },
}));

app.get("/dashboard", requireAuth(zanii, { sessionSecret }), (req, res) => {
  res.json({ id: req.zaniiUser!.id, email: req.zaniiUser!.email, did: req.zaniiUser!.did });
});

requireAuth refreshes an expired access token once, rewrites the session cookie, and only then gives up. Set unauthorizedRedirect to bounce to a login page instead of 401.

Next.js (App Router)

// app/auth/[...zanii]/route.ts
import { createAuthHandlers } from "@zanii-id/sdk/next";
export const { GET } = createAuthHandlers(zanii, { sessionSecret, onUser });
// any server component
import { getSession } from "@zanii-id/sdk/next";
const session = await getSession({ sessionSecret });
if (!session) redirect("/auth/login");
if (session.accessTokenExpired) redirect("/auth/refresh?next=/dashboard");

getSession() returns the session with accessTokenExpired: true once the 10-minute access token has lapsed (it no longer returns null while a refresh token exists). Server components cannot write cookies, so the /auth/refresh route handler (part of createAuthHandlers) rotates the tokens and redirects to next (same-origin paths only). /auth/login?acr=mfa and ?prompt=login request step-up.

Manual flow (no framework helper)

const { url, state, nonce, codeVerifier } = await zanii.getAuthorizationUrl();
// persist state + nonce + codeVerifier in YOUR session, then redirect to url

// step-up: require a second-factor session, force a fresh login, or both
import { ACR_MFA } from "@zanii-id/sdk";
await zanii.getAuthorizationUrl({ acrValues: ACR_MFA, prompt: "login", maxAge: 0 });

const tokens = await zanii.exchangeCode({ code, state }, expectedState, codeVerifier);
const claims = await zanii.verifyIdToken(tokens.id_token!, nonce);   // sig + iss + aud + exp + nonce
const user   = await zanii.getUser(tokens.access_token);             // only if you need live data

exchangeCode throws StateMismatchError before any network call when state does not match. The client also keeps a small in-memory map of pending flows keyed by state, so codeVerifier can be omitted in single-instance deployments — but pass it explicitly in anything multi-instance, because the map does not survive a different process.

Sessions

@zanii-id/sdk ships signed-cookie helpers: signPayload, verifyAndParse, decodeSession, serializeCookie, readCookie.

Format is base64url(json).base64url(hmac-sha256)tamper-evident, not encrypted. Anyone holding the cookie can read the payload; they cannot alter it. Keep secrets out of it. Cookies are HttpOnly, SameSite=Lax (correct for the OAuth redirect) and Secure in production.

Finishing the SPA flow (@zanii-id/react)

useZaniiSpaAuth only starts the public-client flow. On the redirect page call completeZaniiSpaLogin({ issuer, clientId, redirectUri }) (0.2.0): it checks state, exchanges the code with client_id in the body, and verifies the id_token in the browser against the issuer's JWKS with WebCrypto (RS256 pinned, iss, aud, exp, nonce). Keep the returned tokens in memory; a browser gets no refresh token, so re-run login() when the access token expires.

The React button

import { ZaniiLoginButton } from "@zanii-id/react";
import "@zanii-id/react/styles.css";

// Server-rendered app: a plain link to YOUR backend, which owns the secret.
<ZaniiLoginButton href="/auth/login" />

// True SPA with no backend: the browser is a public client and runs PKCE itself.
<ZaniiLoginButton mode="spa" issuer="https://ids.zanii.agency"
                  clientId="cli_..." redirectUri="https://app.example.com/callback" />

useZaniiSpaAuth is the same logic without the markup. State and nonce go in sessionStorage; sign-in is a full-page redirect, never a popup or iframe.

The component throws on any prop matching /secret|password|credential/i — a secret in browser code is a deployment bug, so it fails loudly at development time.

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 with a flagReason instead of disappearing, and a truncated slice appends its own flagged entry, so a cut page never reads as complete.

Without the peer installed both functions throw an error naming the install command.

Lifecycle webhooks

verifyLifecycleEvent(rawBody, signature, webhookSecret) (0.1.3+) checks the HMAC in constant time and returns { event, sub, occurred_at, data }; signLifecycleBody exists for your tests. Give it the raw bytes: in Express mount express.raw({ type: "application/json" }) on that route only; in Next.js pass Buffer.from(await req.arrayBuffer()).

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);
});

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]').

Popup sign-in (React, 0.5.0)

@zanii-id/react can sign in inside a popup instead of redirecting the tab. Opt in per call; redirect stays the default.

<ZaniiLoginButton mode="spa" popup issuer={ISSUER} clientId={ID} redirectUri={CB}
                  onSignedIn={(s) => setUser(s.claims)} />

The callback page serves both modes: completeZaniiSpaLogin(...), then isZaniiPopupCallback() ? publishZaniiPopupResult(result) : useIt(result). The handoff is a same-origin BroadcastChannel (so a Cross-Origin-Opener-Policy header does not break it), a blocked popup falls back to a redirect, and a closed window rejects with PopupClosedError.

Errors

ZaniiError is the base. ConfigError (bad/missing config), AuthorizationError (.code, .description, .status from an OAuth error body), StateMismatchError, TokenValidationError, HTTPError.

Token requests are never retried. Network failures are wrapped in ZaniiError naming the origin, so a DNS or TLS problem does not read as an auth rejection.

The agent surfaces (0.6.0)

const z = new ZaniiClient();

// A token for one tool server, and a job the user approves rather than a blank cheque.
const { url, state } = await z.getAuthorizationUrl({
  loginHint: "alice@example.com",
  resource: "https://tools.example.com/mcp",            // registered first, see OrgClient
  task: { purpose: "Book one flight to Dubai", maxUses: 3, grantTtl: 3600, ceiling: { AED: 4000 } },
});

const tokens = await z.exchangeCode({ code }, state);
tokens.scope;                                            // read it: consent is per permission

// Stop and ask the person.
const approval = await z.createApproval(tokens.access_token, {
  action: "Pay invoice 4417",
  detail: { amount: "500 AED" },
});
const final = await z.awaitApproval(approval);
if (final.status !== "approved") throw new Error(`the human said no (${final.status})`);

// Hear about revocation instead of waiting out a token's ten minutes.
await z.configureStream({ delivery: "poll" });
const batch = await z.pollSignals();
for (const [jti, raw] of Object.entries(batch.sets)) {
  const event = await z.verifySecurityEvent(raw);        // verify before acting on it
  handle(Object.keys(event.events)[0], event.sub_id);
}
await z.pollSignals({ ack: Object.keys(batch.sets) });   // acknowledge only what you handled

OrgClient covers the rest: registerResource, listResources, deleteResource, inviteMember, listMembers, removeMember, setAgentModel, clearAgentModel, update({ require_member_mfa: true }) and registerDynamicClient (RFC 7591).

A malformed task throws ConfigError before the redirect, so the mistake surfaces in your own code rather than as invalid_request after the user has already left the page.

Still not in the SDK, deliberately: the /manage/v1 admin API. It is authenticated by the single global admin token, and an application SDK is the wrong place to invite that token.