npm.io
0.3.1 • Published 16h ago

zitadel-flows

Licence
MIT
Version
0.3.1
Deps
2
Size
46 kB
Vulns
0
Weekly
0

zitadel-flows

Headless passwordless auth for ZITADEL: register a user, send an email code, verify it, log in with an email OTP, and exchange the authenticated session for real ZITADEL OIDC tokens — a single typed API, no hosted UI, no returnCode required in production.

Runtime requirement

This package ships TypeScript source (.ts, no build step). Your runtime/bundler must transpile it. It works under:

  • Bun — any recent version
  • Denoimport … from "npm:zitadel-flows"
  • Bundlers / TS loaders that transpile dependencies — Vite, esbuild, tsx, Next.js/Turbopack

Plain node app.js does NOT work. Node refuses to strip types from files inside node_modules (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), even with --experimental-strip-types. Use Bun/Deno, or bundle/transpile with a tool that processes dependencies. If your bundler excludes node_modules by default (common with webpack/babel exclude: /node_modules/), allow-list zitadel-flows.

Install

bun add zitadel-flows
# or:  npm i zitadel-flows   /   pnpm add zitadel-flows

@zitadel/client and jose come as dependencies — nothing else to install, no .npmrc needed.

Configure

createZitadelAuth needs your instance URL, a service-account key, and the OIDC app credentials. The service-account user needs the IAM_LOGIN_CLIENT (sessions + OIDC finalize) and ORG_USER_MANAGER (create/verify users) roles.

import { createZitadelAuth } from "zitadel-flows";

const auth = createZitadelAuth({
  url: process.env.ZITADEL_URL!, // e.g. https://auth.example.com (also the OIDC issuer)

  // The service-account key: the parsed JSON object OR its JSON string. No base64.
  // Easiest from an env var holding the key file's JSON:
  serviceAccountKey: process.env.ZITADEL_SERVICE_ACCOUNT_KEY!,
  //   or load a file: JSON.parse(readFileSync("sa-key.json", "utf8"))

  oidc: {
    clientId: process.env.ZITADEL_OIDC_CLIENT_ID!,
    clientSecret: process.env.ZITADEL_OIDC_CLIENT_SECRET!,
    redirectUri: process.env.ZITADEL_OIDC_REDIRECT_URI!, // must match the OIDC app
  },
});

Examples

1. Register a new user (production — ZITADEL e-mails the code)

ZITADEL sends the verification code to the user; you never fetch it. Works even when the instance disables returning codes via the API. Requires an SMTP provider configured on the instance.

// Step 1 — create the user. ZITADEL e-mails a verification code.
const reg = await auth.startRegistration({ email: "alice@example.com" });
// reg.state === "needs_email_verification"; reg.userId is the new user id

// Step 2 — the user reads the code from their inbox and types it; submit what they entered.
// confirmEmail verifies the address AND enables email-OTP → the user is login-ready.
await auth.confirmEmail({ userId: reg.userId, code: codeFromUser });

// (optional) re-send if it expired / never arrived
await auth.resendEmailVerification(reg.userId);
2. Log in with an email code
// Step 1 — ZITADEL e-mails a one-time code.
const start = await auth.startLogin("alice@example.com");
if (start.state === "needs_email_verification") {
  // user exists but hasn't verified their e-mail — run the register step 2 (confirmEmail) first
  return;
}

// Step 2 — verify the code the user typed.
const done = await auth.verifyLogin({
  sessionId: start.sessionId,
  sessionToken: start.sessionToken,
  code: codeFromUser,
});

// Step 3 — exchange the authenticated session for real ZITADEL OIDC tokens (JWT access_token).
const tokens = await auth.finalizeOidc({
  sessionId: done.sessionId,
  sessionToken: done.sessionToken,
});
console.log(tokens.access_token, tokens.id_token);
3. Backend HTTP handlers (Hono example)

A typical split: one round-trip to send a code, one to submit it. The client only ever sends the e-mail and the user-typed code.

import { Hono } from "hono";

const app = new Hono();

// — Registration —
app.post("/auth/register", async (c) => {
  const { email } = await c.req.json();
  const reg = await auth.startRegistration({ email });
  return c.json({ userId: reg.userId, state: reg.state }); // "needs_email_verification"
});

app.post("/auth/register/confirm", async (c) => {
  const { userId, code } = await c.req.json();
  await auth.confirmEmail({ userId, code }); // verifies + enables otp_email
  return c.json({ ok: true });
});

// — Login —
app.post("/auth/login/start", async (c) => {
  const { email } = await c.req.json();
  const start = await auth.startLogin(email);
  if (start.state === "needs_email_verification") {
    return c.json({ state: start.state, userId: start.userId }, 409);
  }
  // keep sessionId/sessionToken server-side (e.g. signed cookie / cache); return an opaque ref
  return c.json({ state: start.state, sessionId: start.sessionId, sessionToken: start.sessionToken });
});

app.post("/auth/login/verify", async (c) => {
  const { sessionId, sessionToken, code } = await c.req.json();
  const done = await auth.verifyLogin({ sessionId, sessionToken, code });
  const tokens = await auth.finalizeOidc(done);
  return c.json(tokens); // { access_token, id_token, refresh_token?, ... }
});
4. Backfill existing users (add the email-OTP factor)

For users created outside this library that lack the otp_email factor:

// Already-verified e-mail → just enables otp_email:
await auth.backfillEmailOtp(userId);

// Unverified e-mail you trust (admin-verify server-side) — needs `returnCode` on the instance:
await auth.backfillEmailOtp(userId, { trustEmail: true, email: "alice@example.com" });

If the instance disables returnCode, backfillEmailOtp({ trustEmail }) throws ZitadelFlowsError with code CONFIG. Use the interactive flow instead — resendEmailVerification(userId) then confirmEmail({ userId, code }).

5. Automation / seeding (where returnCode is enabled)

On dev/test instances that allow returning codes via the API, register() does the whole create → verify → enable-OTP in one server-side call (no user interaction):

const user = await auth.register({ email: "test@example.com" }); // needs returnCode enabled
6. Test JWT for automated tests (no e-mail)

Two helpers give tests a real ZITADEL JWT without any e-mail round-trip. They need the dedicated test setup (a project + a machine user granted its roles + an OIDC app that issues JWT access tokens) — see the terraform zitadel-project-test.tf.

Lane A — machine token (no user, no e-mail). Fastest; the token carries the machine user's granted project roles, so it passes API / edge-gateway JWT validation and RBAC-by-claims.

import { machineToken } from "zitadel-flows";

// JWT-profile (a downloaded machine key) — mirrors terraform output `project_test_m2m_key`:
const { access_token } = await machineToken({
  url: process.env.ZITADEL_URL!,
  projectId: process.env.ZITADEL_TEST_PROJECT_ID!, // terraform: project_test_project_id
  serviceAccountKey: process.env.ZITADEL_TEST_M2M_KEY!, // key JSON (object or string)
});

// ...or client-credentials (clientId + clientSecret) instead of a key:
// await machineToken({ url, projectId, clientId, clientSecret });

const res = await fetch(`${API}/api/v1/ping`, {
  headers: { Authorization: `Bearer ${access_token}` },
});

With projectId the token gets scopes urn:zitadel:iam:org:project:id:<projectId>:aud + urn:zitadel:iam:org:project:roles, so its audience includes the project and the roles claim (urn:zitadel:iam:org:project:roles) lists the granted roles.

Lane B — throwaway human console user (returnCode). Exercises the real passwordless login path end-to-end and returns real OIDC tokens. Needs the login service account (IAM_LOGIN_CLIENT + ORG_USER_MANAGER) and returnCode allowed on the instance. Roles are NOT auto-granted — grant them separately if the test asserts on role claims.

import { getTestUserToken } from "zitadel-flows";

const { userId, email, tokens } = await getTestUserToken({
  url: process.env.ZITADEL_URL!,
  serviceAccountKey: process.env.ZITADEL_TEST_LOGIN_KEY!, // terraform: project_test_login_key
  oidc: {
    clientId: process.env.ZITADEL_TEST_CLIENT_ID!,
    clientSecret: process.env.ZITADEL_TEST_CLIENT_SECRET!,
    redirectUri: "http://localhost:5173/auth/callback", // must match the test OIDC app
  },
}); // { email: "test-xxxx@example.com" } to pin the address

// tokens.access_token is a real ZITADEL JWT (JWKS-verifiable, iss/aud set).

Env from terraform: terraform output -raw project_test_project_id, ... project_test_oidc_client_id/secret, ... project_test_m2m_key, ... project_test_login_key. Machine (Lane A) is the default for API/RBAC/gateway tests; use Lane B only when the login flow itself is under test.

The code e-mails contain an "Authenticate" button. By default ZITADEL points it at its own hosted login UI (/ui/v2/login/otp/email), which only works for sessions that UI created — for headless (Session-API) sessions it fails with "could not get user context". So either tell users to just type the code, or point the button at your "enter code" page with urlTemplate.

Set it once on the config, or per call (per-call wins). Placeholders: {{.Code}}, {{.UserID}}, {{.OrgID}}, {{.LoginName}}.

const auth = createZitadelAuth({
  url,
  serviceAccountKey,
  oidc,
  // default for every sendCode e-mail (login OTP, registration, resend):
  urlTemplate: "https://app.example.com/auth/otp?userID={{.UserID}}&code={{.Code}}",
});

// ...or per call (overrides the config default):
await auth.startLogin(email, { urlTemplate: "https://app.example.com/auth/otp?code={{.Code}}" });
await auth.startRegistration({ email }, { urlTemplate: "https://app.example.com/auth/verify?userID={{.UserID}}&code={{.Code}}" });
await auth.resendEmailVerification(userId, { urlTemplate: "https://app.example.com/auth/verify?code={{.Code}}" });

Applies only to sendCode flows. It has no effect on returnCode / register() (those send no e-mail).

Login flow states

startLogin() / verifyLogin() results carry a discriminated state:

state Meaning Next step
code_sent OTP e-mail sent (code in response only if returnCode: true) verifyLogin() with the code
needs_email_verification User exists but the e-mail isn't verified run confirmEmail() first
authenticated Session verified; ready for tokens finalizeOidc()

Error handling

import { ZitadelFlowsError } from "zitadel-flows";

try {
  await auth.confirmEmail({ userId, code });
} catch (e) {
  if (e instanceof ZitadelFlowsError) {
    console.error(e.code, e.message); // typed code + actionable message
  }
}

Error codes (ZitadelErrorCode): OTP_NOT_READY · EMAIL_NOT_VERIFIED · CONFIG · TRANSPORT.

startLogin() self-heals ZITADEL's COMMAND-JKLJ3/COMMAND-KLJ2d ("OTP not ready") automatically: it re-enables the otp_email factor when the e-mail is verified and retries; if it isn't verified it returns needs_email_verification instead of throwing.

API reference

createZitadelAuth(config): ZitadelAuth
Config field Type Description
url string Instance base URL (also the OIDC issuer)
serviceAccountKey ServiceAccountKey | string Parsed key object, or its JSON string (no base64)
oidc.clientId string OIDC application client ID
oidc.clientSecret string OIDC application client secret
oidc.redirectUri string OIDC redirect URI registered in ZITADEL
urlTemplate string? Default link template for sendCode e-mails (see "Verification e-mail links"). Placeholders {{.Code}} {{.UserID}} {{.OrgID}} {{.LoginName}}
ZitadelAuth methods
Method Description
startRegistration(input, opts?) Create a user; ZITADEL e-mails the verification code. → { userId, state: "needs_email_verification" }. opts.urlTemplate sets the e-mail link
confirmEmail({ userId, code }) Verify the user-entered code, then enable otp_email (user becomes login-ready)
resendEmailVerification(userId, opts?) Re-send the e-mail verification code (sendCode); opts.urlTemplate sets the e-mail link
register(input) One-shot create + verify + enable OTP server-side — requires returnCode enabled
startLogin(login, opts?) E-mail a login OTP and open a session (opts.returnCode for tests, opts.urlTemplate sets the e-mail link)
verifyLogin({ sessionId, sessionToken, code }) Check the OTP; confirm the session is authenticated
finalizeOidc({ sessionId, sessionToken }) Exchange the session for OIDC tokens (real ZITADEL JWT)
backfillEmailOtp(userId, opts?: { trustEmail?, email? }) Enable otp_email for an existing user; trustEmail admin-verifies the address (needs returnCode)

License

MIT

Keywords