zaniiid

Getting started

Central identity for the Zanii product ecosystem. One account works across every product, on unrelated domains, without third-party cookies.

Products never see a password. They redirect to ids.zanii.agency, get back an authorization code, and exchange it for tokens they validate offline against a published JWKS.


Why this exists

Running separate auth in every product means duplicated login screens, duplicated password storage, and no way to answer "which of my products has this user already signed up for." Centralising authentication solves that, but only if the centre is worth trusting:

  • Credential blast radius collapses. Registration happens on hosted pages here, so no product ever handles a password.
  • One immutable zanii_user_id. Products JIT-provision local rows keyed by it. Email is a mutable login handle, never the join key.
  • SSO across unrelated domains with no third-party cookies. /authorize is always a top-level navigation, so the session cookie is first-party by construction.

How a login works

sequenceDiagram
    autonumber
    participant U as User
    participant P as product-b.com
    participant I as ids.zanii.agency

    U->>P: Continue with Zanii ID
    P->>I: GET /authorize (PKCE S256 + state + nonce)
    Note over I: __Host- session cookie is first-party<br/>on this top-level navigation
    I-->>U: 302 back with ?code= (no login form if already signed in)
    U->>P: /auth/callback?code=..&state=..
    P->>I: POST /token (code + secret + verifier)
    I-->>P: id_token · access_token (10 min) · refresh_token
    P->>P: Verify offline, JIT-provision, set own session

Second product, same user, zero typing. Existing session or not, the only difference is whether a login form renders.

Quickstart

cd services/identity-server
pip install -e ".[dev]"
uvicorn app.main:app --port 8000

Dev defaults to SQLite and an in-memory rate limiter, so nothing else is required. For the full stack:

docker compose -f infrastructure/docker-compose.yml up --build

Register a product:

curl -X POST http://localhost:8000/manage/v1/clients \
  -H "X-Admin-Token: dev-admin-token" -H "Content-Type: application/json" \
  -d '{"client_name":"My Product","client_type":"confidential","first_party":true,
       "redirect_uris":["http://localhost:9001/auth/callback"]}'

The response carries client_secret and webhook_secret exactly once.

Registration on the hosted pages requires the 18+ confirmation; the ledger integration is off until an agent identity and API key are configured (see docs/configuration.md).

Integrating

Pythonpip install zanii-id

from zanii_id import ZaniiClient
from zanii_id.integrations.fastapi import build_auth_router, install_zanii, require_zanii_auth

zanii = ZaniiClient()                        # reads ZANII_* env vars, validated eagerly
install_zanii(app, zanii, session_secret=SECRET)
app.include_router(build_auth_router(zanii, session_secret=SECRET))

@app.get("/dashboard")
async def dashboard(user = Depends(require_zanii_auth)):
    return {"zanii_user_id": user.zanii_user_id}

Nodenpm install @zanii-id/sdk

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

const zanii = new ZaniiClient();
app.use("/auth", buildAuthRouter(zanii, { sessionSecret }));
app.get("/dashboard", requireAuth(zanii, { sessionSecret }), (req, res) =>
  res.json({ zanii_user_id: req.zaniiUser.id }),
);

Next.js gets createAuthHandlers() as a drop-in app/auth/[...zanii]/route.ts. The browser button is @zanii-id/react, which refuses to start if a client secret reaches it.

Agent activity

Every user gets a custodial did:key, exposed as a did claim. Products in the Zanii agent ecosystem stamp each receipt with a platform-scoped subject tag derived from it, so a user can audit everything agents did on their account — across every product — from one page.

Two lines on the product side:

from zanii_id.activity import subject_tag        # pip install 'zanii-id[subject]'
tag = subject_tag(user.did, client_id)           # pass to record(..., subject_tag=tag)

The user opens /ui/activity and gets a merged, chronological feed. Nothing on that page is taken on trust: every receipt is verified offline against its signature, its delegation chain, its scope, and the tag it was fetched under.

flowchart LR
    A["Platform stamps<br/>a receipt"] --> B{"HMAC<br/>webhook"}
    A --> C{"Subject slice<br/>pull"}
    B --> D["Signature ·<br/>delegation · scope"]
    C --> D
    D --> E["Merkle inclusion<br/>+ signed tree head"]
    E --> F["Custody verdicts<br/>in the browser"]
    F --> G["verified"]
    F --> H["flagged<br/>with a reason"]

A failed check never removes a row. It renders it flagged, with the reason, because silence would be indistinguishable from "nothing happened" — which is the exact claim the ledger exists to make checkable.

Users can also sign a portable "this slice is mine" claim, rotate their subject DID with a signed succession record, and inspect any agent's verified history.

docs/verification.md documents all ten verification layers, what each one guarantees, and — deliberately — what is not verified.

Security posture

Threat Mitigation
Code interception PKCE S256 required for every client, confidential included; 60 s single-use codes; replay revokes the downstream token family
Refresh token theft Rotation on every use, hashed at rest, whole-family revocation on reuse
Access token theft 10 minute TTL, jti denylist honoured at /userinfo, RFC 7009 revocation
Open redirect Literal URI registry, exact match, no wildcards
Session fixation Fresh session ID minted at every authentication
Credential stuffing Argon2id, per-account and per-IP throttling, constant-shape verification
Account enumeration Registration and password reset respond identically for known and unknown addresses
Alg confusion alg pinned from discovery; key selected only by a trusted kid

Third-party clients get a consent screen listing every requested scope and naming the organization that operates the app; grants are recorded per scope-set, so a newly requested scope re-prompts.

What users can do for themselves

/ui/account is the user's own control panel, no support ticket needed:

  • change name, move to a new email (confirmed at the new inbox before anything switches), change password (every other session and every app token is revoked; the current one stays);
  • two-step verification with any authenticator app, plus eight one-time recovery codes; it also gates password reset, and every id_token says whether it was used (amr);
  • see every connected app and the organization behind it, and revoke one - its tokens die and it has to ask again;
  • see and end sessions, or sign out everywhere else;
  • back up their identity key with a passkey: encrypted in the browser under a key the passkey derives (WebAuthn PRF), so Zanii never sees the file and the user can leave with their key;
  • have their age verified through a configured provider, and see exactly what apps get (age_over with the method, or only age_declared from the sign-up gate);
  • delete the account, which tells every product they signed in to.

For agents and machines

  • Client credentials for a service acting as itself.
  • Token exchange (RFC 8693): an app that holds a user's token can obtain one for another Zanii service on behalf of that user. The result names the actor in act, keeps the audience's pairwise sub, never carries a refresh token, and is receipted on the user's ledger tag. An AI agent presents its own client-credentials token as actor_token, so "who did this for whom" is in the token itself.
  • Device flow (RFC 8628) for CLIs, TVs and headless agents: the user approves a short code at /device from any signed-in browser.
  • DPoP (RFC 9449): tokens bound to the client's key; a stolen token is useless.
  • PAR (RFC 9126) and private_key_jwt: no parameters through the browser, no shared secrets for clients that register a JWK Set.
  • Back-channel and front-channel logout: relying parties are told when a session ends, server-to-server or through the user's browser.
  • Know-your-agent: an organization can refuse named agents; token exchange screens the actor against the audience's list before minting, and receipts the decision either way. /orgs/agents/screen answers about any counterparty DID with its ledger history.
  • Agent-to-agent login: two agents from two companies authenticate to each other with a mutual, receipted screening; the token says kya: {ok, mutual} and no human is involved.
  • Proof-carrying consent: every token names the ledger receipt of the consent that authorised it (cst); /introspect returns that consent; zanii-id verify <did> checks an agent's whole history against the rulebooks it was stamped with, offline.
  • A public page per agent at /agents/{did}: verified history next to who registered it.
  • Verifiable credentials (OID4VCI): a user can carry a signed ZaniiIdentityCredential in any OpenID4VC wallet, bound to their key and naming their subject DID.

Discovery at /.well-known/openid-configuration advertises all of it; the credential issuer metadata lives at /.well-known/openid-credential-issuer. The SDKs (0.3.0) cover every grant above, DPoP, PAR, private_key_jwt, both logout channels, the receipt relay and the organization API, and are built to be cheap for the issuer: cached discovery and keys, a timeout on every call, token grants never retried.

Built on the ledger's own primitives

Zanii ID does not just write receipts; it uses the ecosystem's shapes so anyone can verify them with the published packages. Consent is a consent.granted receipt, deletion a data.retention.deleted attestation plus a retention hold, every receipt names the deployed commit (runtime_hash) and the signed operating rules (manifest_hash) that forbid an operator from ever setting an email or reading a password. Organizations hold a DID, sign their terms acceptance with it, and can bind it to their domain, which the consent screen shows. Users can download a bilingual court-ready evidence pack of what Zanii ID did on their account. A sentinel watches the IdP's own receipts under a separate key. The IdP's key has a post-quantum binding anchored on the ledger.

Step-up, passkeys, recovery

  • Passkeys. Users add Touch ID, Windows Hello or a security key on their account page and sign in without a password. A passkey session carries amr: ["webauthn"].
  • Step-up. Send acr_values=urn:zanii:mfa on a sensitive route and Zanii ID guarantees the returned session has a second factor, challenging the user in place if they are enrolled and telling your app (unmet_authentication_requirements) if they are not. prompt=login and max_age force a fresh login; prompt=none never interacts.
  • Hijack protection. An email change notifies the old address with a link that reverts it and signs out every device.
  • Recovery. A user who loses both authenticator and recovery codes is restored by an operator through an audited, receipted reset, never by a hidden backdoor.

Lifecycle webhooks

Register a lifecycle_webhook_url and Zanii ID POSTs user.deleted, user.password_changed, user.email_changed and consent.revoked to it, signed with the same webhook_secret you already verify. Deliveries are queued in the same transaction as the change and retried with backoff, so a deletion is never lost because your endpoint was down for a minute. The sub in the payload is the one you know the user by.

Receipts a platform relays back are matched to the user through an index written at token issuance, so ingestion stays one lookup however many users the platform has.

Using Zanii ID from outside the ecosystem

Any OIDC library works - the SDKs are convenience, not a requirement. An outside company registers itself and manages its own clients without involving Zanii:

curl -X POST https://ids.zanii.agency/orgs/signup \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme","contact_email":"dev@acme.example","accept_terms":true}'   # -> api_key, shown once

curl -X POST https://ids.zanii.agency/orgs/clients \
  -H "Authorization: Bearer zid_org_..." -H "Content-Type: application/json" \
  -d '{"client_name":"Acme App","client_type":"confidential",
       "redirect_uris":["https://app.acme.example/auth/callback"]}'

Clients registered this way are pairwise: every client in the same organization sees one stable sub for a user, and no other organization can derive it - so two unrelated companies cannot join their user tables to correlate a person. Zanii's own products keep the global zanii_user_id. Pairwise clients do not receive the did claim, for the same reason.

Prefer a screen to curl? https://ids.zanii.agency/console does the same thing: register, log in with the org key, create apps, see secrets once, rotate them, set the lifecycle URL. Standard OIDC libraries can also self-register through POST /register (RFC 7591) with the org key as the initial access token. Every organization starts with a quota of ten apps and thirty token requests a minute across them; Zanii raises it on request. The terms, privacy policy and DPA are at /legal/* and docs/legal/, published as drafts until counsel has reviewed them.

Production

Production mode is enforced rather than advisory. With ZANII_ID_ENVIRONMENT=production the server refuses to start unless all of the following hold, and reports every problem at once:

Variable Requirement
ZANII_ID_APP_SECRET not the dev default, ≥ 32 chars — encrypts signing keys and custodial DID keys
ZANII_ID_ADMIN_TOKEN not the dev default
ZANII_ID_ISSUER https://
ZANII_ID_DATABASE_URL not SQLite
ZANII_ID_REDIS_URL required — the in-memory limiter is per-worker
ZANII_ID_RESEND_API_KEY or ZANII_ID_SMTP_HOST a mail transport is required — without one, verification and password reset silently do nothing. Prefer the HTTPS key: many cloud hosts block outbound SMTP
ZANII_ID_COOKIE_SECURE not false — it would drop the __Host- prefix

Each of these fails quietly rather than loudly if left wrong, which is why they are start-up errors instead of documentation.

Traces are opt-in: ZANII_ID_OTEL_ENDPOINT exports OTLP/HTTP with one span per ledger receipt, in the same vocabulary the products' zanii.otel wrapper uses.

Schema is owned by Alembic in production (alembic upgrade head); create_all runs only outside it. Rotate signing keys roughly every 90 days — retired keys keep validating outstanding tokens through the JWKS window.

Layout

services/identity-server    FastAPI IdP — authorize · token · userinfo · logout · JWKS · hosted pages
packages/python-sdk         zanii-id        → PyPI, with FastAPI integration
packages/typescript-sdk     @zanii-id/sdk       → Node, Express, Next.js
packages/react-components   @zanii-id/react → browser button, public client only
examples/                   FastAPI · Express · Next.js reference apps
infrastructure/             docker compose (postgres · redis · server)

Tests

cd services/identity-server && python -m pytest tests -q   # 255 tests: protocol, consent, MFA, passkeys, lifecycle, console, ledger, agents
cd packages/python-sdk      && python -m pytest tests -q   # 45 tests, SDK flows against a stub IdP
cd packages/typescript-sdk  && npm test && npx tsc --noEmit # 82 tests: client, express, next, session, jwt, activity, verdicts
python scripts/gen_docs.py --check                          # configuration.md and openapi.json match the code

CI additionally verifies that migrations match the models, that the committed browser widget is reproducible from source, and that the container actually boots.

Documentation

Start at docs/README.md, the index. Highlights:

Licence

The three client SDKs (zanii-id, @zanii-id/sdk, @zanii-id/react) are Apache-2.0, matching the rest of the Zanii ecosystem. Everything else in this repository — the identity server above all — is proprietary. See LICENSE.