@hanzo/iam
v0.21.35
Published
TypeScript SDK for Hanzo IAM — OIDC auth, JWT validation, OAuth2 PKCE, user/org/project APIs
Readme
@hanzo/iam
The one-way TypeScript SDK for authenticating against Hanzo IAM — a a standards-first OIDC provider served per-brand from a configurable origin:
| Brand | serverUrl |
|-------|-------------|
| Hanzo | https://hanzo.id |
| Lux | https://lux.id |
| Zoo | https://zoolabs.id |
| Bootnode | https://id.bootno.de |
| Pars | https://pars.id |
The brand→origin map is exported as BRAND_SERVER_URLS /
serverUrlForBrand(brand) from @hanzo/iam (and @hanzo/iam/paths) — one
place, in code.
One OIDC path set. One name per thing. No backwards-compatibility shims.
The one way to read identity
const iam = { serverUrl: "https://hanzo.id", clientId: "my-app" };
const me = await getServerSession(req, iam); // Bearer/cookie + X-Org-Id; null → 401
// off HTTP: const r = await validateToken(token, iam, org); if (!r.ok) deny(r.reason);
me.sub; // opaque id — never parse it
me.org; // tenant: X-Org-Id if ∈ orgs, else orgs[0]; an app's owner
me.orgs; // signed membership set, home first
me.role; // coarse role in me.org — an ORG role
me.isSuperAdmin; // a person whose home org (orgs[0]) is `admin` — the only platform authority
me.app; // machine principal (client_credentials)IAM's sub is a user's UUID and a user token's owner is the app's org, so
sub.split("/")[0], claims.owner and ?? orgName all map every user to one
tenant. validateToken is the only reader:
issis pinned by config (issuer, defaultserverUrl; a list for several brands) — never the token's owniss, never discovery's. Every brand is signed by the same keys;issis what tells them apart.audis always enforced:clientId, or theaudienceallowlist.- User →
orgsclaim; app (type: "application") →owner. Noorgs→ reject. - A requested org (
X-Org-Id) must be ∈orgs→ elseiam_org_forbidden. - SuperAdmin ⟺ a person whose home org,
orgs[0], isadmin—superAdmin(claims), hanzoai/authzClaims.Sudoexactly. Anadminmembership further down the set,owner,isAdminandrole === "admin"are none of them platform authority.
Failure reasons: iam_token_missing, iam_issuer_unset, iam_audience_unset,
iam_discovery_failed, iam_token_expired, iam_issuer_invalid,
iam_audience_invalid, iam_signature_invalid, iam_subject_missing,
iam_org_missing, iam_org_forbidden.
lint/ (ast-grep, same rules as hanzoai/ci) rejects
the ways around it — ast-grep scan -c node_modules/@hanzo/iam/lint/sgconfig.yml:
sub.split, ?? orgName, .owner off a verify result, jwtVerify without
audience, an issuer taken from the token, X-Org-Id read outside this package.
The one-way model
IAM is adapted to each framework; frameworks keep their native auth
routes. NextAuth/Auth.js hard-mount /api/auth (or /auth); Passport, Remix,
Express, and Hono each have their own route conventions — this SDK never moves
them onto /v1. The SDK's only job is to hand each framework a
provider/adapter in its native shape whose authorize/token/userinfo point
at our canonical /v1/iam/oauth/*. That translation lives here, in code, once
(src/paths.ts); every adapter resolves endpoints through it and nowhere
hard-codes a path. PKCE-S256 is always on; confidential clients use
client_secret_basic.
The canonical paths
Every endpoint resolves through a single source of truth — OIDC_PATHS
(@hanzo/iam/paths). These are the only paths; there is no legacy
/oauth/* and no /api/login/*.
| Endpoint | Path |
|----------|------|
| discovery | /.well-known/openid-configuration |
| authorize | /v1/iam/oauth/authorize |
| token | /v1/iam/oauth/token |
| userinfo | /v1/iam/oauth/userinfo |
| jwks | /v1/iam/.well-known/jwks |
| logout | /v1/iam/oauth/logout |
Gotcha IAM serves a
200 text/htmlSPA for any unregistered path. A wrong path returns HTML, not a 404 — silent breakage. This SDK pins the exact paths and never lets discovery resolve to a wrong one: the OAuth adapters skip discovery entirely (explicit endpoints), and JWT verification degrades to the canonicaljwkspath when discovery fails or returns the HTML catch-all — so a discovery blip can never take down token validation.
PKCE (S256) is always on. Confidential clients use client_secret_basic.
Scopes are ["openid", "profile", "email"].
Install
pnpm add @hanzo/iamOne canonical usage per integration
Server (Next.js App Router / Node / Hono / Remix) — @hanzo/iam/server
The piece PaaS-class apps need: resolve the caller's identity from the bearer token or session cookie, verified against IAM's JWKS.
import { getServerSession, withSession } from "@hanzo/iam/server";
const iam = { serverUrl: "https://hanzo.id", clientId: process.env.IAM_CLIENT_ID! };
// Option A — read the session yourself
export async function GET(req: Request) {
const session = await getServerSession(req, iam);
if (!session) return new Response("unauthorized", { status: 401 });
return Response.json({ user: session.sub, org: session.org, email: session.email });
}
// Option B — guard the handler (401 before your code runs)
export const POST = withSession(iam, (req, session) =>
Response.json({ org: session.org }),
);getServerSession(req, config, options?) returns a Principal or null. It
reads Authorization: Bearer … first, then the hanzo_iam_access_token cookie
(override with options.cookieName), and acts in the X-Org-Id org, which must
be ∈ orgs (else null).
JWT validation — @hanzo/iam (or @hanzo/iam/auth)
import { validateToken } from "@hanzo/iam";
const result = await validateToken(accessToken, {
serverUrl: "https://hanzo.id",
clientId: "my-app",
}, org); // optional; must be ∈ orgs
if (result.ok) {
// result.sub, result.org, result.orgs, result.isSuperAdmin, result.app
}See the one way to read identity.
The JWKS URI comes from OIDC discovery when reachable and degrades to the
canonical /v1/iam/.well-known/jwks path otherwise (discovery 5xx,
timeout, or the 200 text/html SPA catch-all). The token signature is
always verified against the fetched JWKS — the fallback is not a bypass.
better-auth — @hanzo/iam/betterauth
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { iamProvider } from "@hanzo/iam/betterauth";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
iamProvider({
serverUrl: process.env.IAM_ENDPOINT!,
clientId: process.env.IAM_CLIENT_ID!,
clientSecret: process.env.IAM_CLIENT_SECRET!,
}),
],
}),
],
});iamProvider returns a real genericOAuth config entry
(providerId: "hanzo", pinned authorize/token/userinfo URLs, pkce:
true, authentication: "basic"). No discoveryUrl.
NextAuth.js / Auth.js — @hanzo/iam/nextauth
import NextAuth from "next-auth";
import { HanzoIamProvider } from "@hanzo/iam/nextauth";
export default NextAuth({
providers: [
HanzoIamProvider({
serverUrl: process.env.IAM_SERVER_URL!,
clientId: process.env.IAM_CLIENT_ID!,
clientSecret: process.env.IAM_CLIENT_SECRET,
}),
],
});The provider id defaults to "hanzo-iam". Endpoints are explicit —
authorization/token/userinfo/jwks_endpoint come straight from
OIDC_PATHS; there is no wellKnown/discovery round-trip (a discovery
fetch that fails or resolves wrong would hit the IAM HTML SPA catch-all —
silent breakage). issuer + jwks_endpoint are supplied so openid-client
still verifies the id_token without discovery. checks default to
["state", "pkce"]; token auth is client_secret_basic. IamProvider is a
stable alias for the same function; the exported profile type is
HanzoIamProfile.
SvelteKit / Auth.js core — @hanzo/iam/sveltekit
@auth/core (SvelteKit, SolidStart, @auth/express, …) consumes the same
provider shape as NextAuth, so the SvelteKit adapter is that provider under an
Auth.js-idiomatic name — one builder, the same explicit endpoints.
import { SvelteKitAuth } from "@auth/sveltekit";
import { HanzoIam } from "@hanzo/iam/sveltekit";
import { env } from "$env/dynamic/private";
export const { handle, signIn, signOut } = SvelteKitAuth({
providers: [
HanzoIam({
serverUrl: env.IAM_SERVER_URL,
clientId: env.IAM_CLIENT_ID,
clientSecret: env.IAM_CLIENT_SECRET,
}),
],
});Remix — @hanzo/iam/remix
Login via remix-auth + remix-auth-oauth2 (you construct the strategy, so
its version stays yours); guards via the framework-agnostic verifier.
// app/auth.server.ts — login strategy
import { Authenticator } from "remix-auth";
import { OAuth2Strategy } from "remix-auth-oauth2";
import { hanzoIamStrategyOptions } from "@hanzo/iam/remix";
export const authenticator = new Authenticator(sessionStorage);
authenticator.use(
new OAuth2Strategy(
hanzoIamStrategyOptions({
serverUrl: process.env.IAM_SERVER_URL!,
clientId: process.env.IAM_CLIENT_ID!,
clientSecret: process.env.IAM_CLIENT_SECRET!,
redirectURI: "https://app.hanzo.ai/auth/hanzo-iam/callback",
}),
async ({ tokens }) => tokens,
),
"hanzo-iam",
);// guarded loader/action
import { requireSession } from "@hanzo/iam/remix";
const iam = { serverUrl: "https://hanzo.id", clientId: process.env.IAM_CLIENT_ID! };
export async function loader({ request }: { request: Request }) {
const session = await requireSession(request, iam); // throws 401 Response if absent
return Response.json({ org: session.org });
}Express / Connect — @hanzo/iam/express
import express from "express";
import { requireAuth, getIamSession } from "@hanzo/iam/express";
const iam = { serverUrl: "https://hanzo.id", clientId: process.env.IAM_CLIENT_ID! };
const app = express();
app.get("/me", requireAuth(iam), (req, res) => {
const session = getIamSession(req); // typed, present after requireAuth
res.json({ user: session.sub, org: session.org });
});requireAuth(config) 401s tokenless requests and attaches the verified
session to req.iamSession. For optional-auth routes use
getSession(req, config) (returns null when absent).
Hono — @hanzo/iam/hono
import { Hono } from "hono";
import { requireAuth, getIamSession } from "@hanzo/iam/hono";
const iam = { serverUrl: "https://hanzo.id", clientId: process.env.IAM_CLIENT_ID! };
const app = new Hono();
app.use("/api/*", requireAuth(iam));
app.get("/api/me", (c) => {
const session = getIamSession(c);
return c.json({ user: session.sub, org: session.org });
});React (SPA) — @hanzo/iam/react
<Login> is the DEFAULT login UI — one component, mounted embedded in your
app. Every method stays IN-PLACE (the app window never navigates to the
hosted login page): social via popup + postMessage, wallet via inline SIWx,
email/password/OTP via a PKCE-bound code.
import { IamProvider, useIam, Login, completePopupSignin } from "@hanzo/iam/react";
function App() {
return (
<IamProvider config={{
serverUrl: "https://hanzo.id",
clientId: "my-app",
redirectUri: `${window.location.origin}/auth/callback`,
}}>
<Main />
</IamProvider>
);
}
function Main() {
const { user, isAuthenticated } = useIam();
if (!isAuthenticated)
return (
<Login
serverUrl="https://hanzo.id"
clientId="my-app"
redirectUri={`${location.origin}/auth/callback`}
state=""
providers={["google", "github", "wallet"]}
onSuccess={(token) => { /* signed in, in-place */ }}
/>
);
return <div>Welcome, {user?.displayName}</div>;
}Mount the popup callback bridge on your redirect_uri route — one line. It
hands the code back to the opener for popup sign-ins, and no-ops (returns
false) for a normal navigation so your usual handleCallback() runs:
// pages/auth/callback
import { completePopupSignin, handleCallback } from "@hanzo/iam/browser";
if (!completePopupSignin()) {
await handleCallback(); // normal top-level redirect callback
}useIam().login() (full-page signinRedirect) remains only as an explicit
standalone fallback — prefer <Login> or loginPopup so login stays
in-place.
Browser (framework-free PKCE) — @hanzo/iam (or @hanzo/iam/browser)
import { IAM, completePopupSignin } from "@hanzo/iam";
const iam = new IAM({
serverUrl: "https://hanzo.id",
clientId: "my-spa",
redirectUri: "https://myapp.com/auth/callback",
});
// In-place social (Google/GitHub can't be iframed → popup + postMessage):
const token = await iam.signinPopup({ additionalParams: { provider: "google" } });
// In-place wallet (SIWx / EIP-4361, no redirect, no loop):
const walletToken = await iam.loginWithWallet();
// Standalone fallback only — full-page redirect to the hosted page:
await iam.signinRedirect();
const t = await iam.handleCallback(); // on the callback route
await iam.logout();signinPopup verifies the callback message's origin, source tag, and
state before it will touch the code; completePopupSignin (mounted on the
redirect_uri route) posts back with targetOrigin pinned to its own origin,
never "*". A bare code is useless without the opener's PKCE verifier, which
never leaves the opener.
A sign-in IAM holds, finished on the app's own page
A first-party site draws its own sign-in and posts the credential to IAM's
login endpoint on its own origin (proxyBaseUrl), where IAM keeps its session
and challenge cookies. When IAM holds the sign-in, the credential call throws
what is owed, and the page finishes it there:
import { EmailRequired, IAM, Refused, SecondFactor } from "@hanzo/iam";
try {
location.assign(await iam.loginWithPassword(email, password));
} catch (err) {
if (err instanceof SecondFactor) err.factors; // [{ type: "app" | "email" | "sms", sent, remember }]
if (err instanceof EmailRequired) err.email; // where IAM sent a code, "" for none
if (err instanceof Refused) err.code; // "login_required", "email_code_sent", or ""
}
await iam.verifyFactor(held, { type: "app", passcode }); // or { recovery }
await iam.confirmEmail(held, code); // or changeEmail(held, address), then confirmEmail
await iam.resetPassword(email, code, password); // code from sendLoginCode(email)
await iam.acceptInvite(owner, inviteCode, emailCode); // with the signed-in access tokenresume() asks nothing while a sign-in IAM holds for this browser may still be
pending: a post with no credential is read as the answer to it, and would spend
a second factor's challenge.
A sign-in through Google or GitHub whose account has a second factor comes back
to /login/mfa on the host the provider returned to, with IAM's challenge in a
host-only cookie there. That page answers it and goes where IAM says:
location.assign(await iam.finishFederation({ type: "app", passcode })); // or { recovery }Another app's sign-in, finished on this page
Another app opens the site's own sign-in page with a standard authorize query:
a native app (a desktop window, a CLI, a phone) or a site on another origin keeps
its PKCE verifier, and a confidential server may send no challenge and redeem with
its secret. The page reads the request with asked (its nonce included) and
hands it to ask; every sign-in on the page then mints the code for that app and
returns its callback, which IAM checks against the client's registered ones. The
sign-in is made in that app's application and organization, as
/v1/iam/auth/application states them, and the address lookup, the methods and
the code sent are that app's too, so a person is found where the app signs people
in:
import { asked } from "@hanzo/iam";
iam.ask(asked(new URLSearchParams(location.search))); // null: the page's own
location.assign(await iam.loginWithPassword(email, password)); // hanzo://oauth/hanzo?code=…&state=…Drop-in login + onboarding views also live at @hanzo/iam/views (Login,
SocialButton, WalletButton, EmailPasswordForm, OTPStep,
OnboardingFlow, and the five onboarding step primitives).
Passport.js — @hanzo/iam/passport
import passport from "passport";
import { createIamPassportStrategy } from "@hanzo/iam/passport";
passport.use("iam", createIamPassportStrategy({
serverUrl: "https://hanzo.id",
clientId: "my-app",
clientSecret: process.env.IAM_CLIENT_SECRET!,
callbackUrl: "https://app.hanzo.ai/v1/sso/oidc/callback",
}));Validation — @hanzo/iam/validation
import { isValidPhone, isValidEmail } from "@hanzo/iam/validation";
isValidPhone("6178888888", "+1"); // true — E.164 +16178888888
isValidEmail("[email protected]"); // trueSubpath map
| Import | Surface |
|--------|---------|
| @hanzo/iam | IamClient, IamApiError, validateToken, clearJwksCache, IAM, toIAMToken, completePopupSignin, POPUP_WINDOW_NAME, generatePKCEChallenge, generateState, configureIam, startLogin, getLoginUrl, handleCallback, getSession, getUser, logout, OIDC_PATHS, IAM_PATHS, BRAND_SERVER_URLS, iamUrl, serverUrlForBrand, all types |
| @hanzo/iam/paths | OIDC_PATHS, IAM_PATHS, BRAND_SERVER_URLS, iamUrl, trimServerUrl, serverUrlForBrand |
| @hanzo/iam/session | configureIam, startLogin, getLoginUrl, handleCallback, getSession, getUser, logout, getIam |
| @hanzo/iam/server | getServerSession, withSession, getBearerToken, SESSION_COOKIE, ServerSession |
| @hanzo/iam/auth | validateToken, clearJwksCache |
| @hanzo/iam/browser | IAM (signinPopup, loginWithWallet, signinRedirect, handleCallback), completePopupSignin, POPUP_WINDOW_NAME, toIAMToken, IAMConfig, IAMUser, IAMToken, Eip1193Provider |
| @hanzo/iam/react | IamProvider, useIam, useOrganizations, useIamToken, OrgProjectSwitcher, Login, SocialButton, WalletButton, EmailPasswordForm, OTPStep, OnboardingFlow, useAuthMethods, completePopupSignin |
| @hanzo/iam/views | Login, SocialButton, WalletButton, EmailPasswordForm, OTPStep, OnboardingFlow, IdentityStep, DocumentsStep, BiometricStep, ScreenStep, SubmitStep, useAuthMethods |
| @hanzo/iam/betterauth | iamProvider |
| @hanzo/iam/nextauth | HanzoIamProvider, IamProvider (alias), HanzoIamProfile |
| @hanzo/iam/sveltekit | HanzoIam, HanzoIamProvider, IamProvider |
| @hanzo/iam/remix | hanzoIamStrategyOptions, requireSession, getSession |
| @hanzo/iam/express | requireAuth, getSession, getIamSession |
| @hanzo/iam/hono | requireAuth, getSession, getIamSession |
| @hanzo/iam/passport | createIamPassportStrategy |
| @hanzo/iam/types | all types |
| @hanzo/iam/validation | isValidPhone, isValidEmail |
Bare @hanzo/iam resolves to the browser build under a browser bundler
and the Node build under Node, via package exports conditions.
Configuration
| Option | Required | Description |
|--------|----------|-------------|
| serverUrl | Yes | IAM origin (e.g. https://hanzo.id) |
| clientId | Yes | OAuth2 client ID |
| clientSecret | No | Secret for confidential clients |
| orgName | No | Org for IamClient API calls — never read by validateToken |
| appName | No | Application name |
| issuer | No | Accepted iss (string or brand list); default serverUrl |
| audience | No | Accepted aud allowlist; default clientId; always enforced |
| jwksUri | No | JWKS endpoint, skipping discovery (in-cluster); set issuer too |
License
MIT — Hanzo AI
