No commit since 3a1920f has a CI run. GitHub Actions is off for this repo and
the forge has no copy of it, so .github/workflows/ci.yml ran nowhere; it also
installed with --no-frozen-lockfile and never ran the typecheck or the Go
tests.
.hanzo/workflows/ci.yml installs from the lockfile as written, then builds,
typechecks and tests on Node 22, 24 and 26, and runs go vet and go test in
go/, on linux-amd64 per RUNNERS.md. It runs once the forge mirrors this repo.
Co-authored-by: Hanzo Dev <[email protected]>
12 KiB
iam-sdk — AI Assistant Context
@hanzo/iam
The one-way TypeScript SDK for authenticating JS apps (Hanzo / Lux / Zoo /
Bootnode) against Hanzo IAM, a standards-first OIDC provider served
per-brand from a configurable serverUrl.
The law: one source of truth for paths
src/paths.ts exports OIDC_PATHS — the ONLY OIDC paths. Every module
references it; no path string is written anywhere else. There is no
legacy /oauth/* and no /api/login/*.
| key | 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 |
IAM_PATHS holds the non-OIDC IAM-app endpoints used by the embedded
views: authMethods = /v1/iam/auth/methods, onboarding =
/v1/iam/onboarding, login = /v1/iam/login, sendCode =
/v1/iam/send-verification-code, signup = /v1/iam/signup, and the SIWx
wallet pair web3Nonce = /v1/iam/web3/nonce, web3Verify =
/v1/iam/web3/verify.
BRAND_SERVER_URLS / serverUrlForBrand(brand) is the ONE brand→origin
map (white-label is host-based; one IAM serves all brands): hanzo →
iam.hanzo.ai, lux → lux.id, zoo → zoo.id, bootnode →
id.bootno.de, pars → pars.id. Adapters take a serverUrl; apps that
prefer a brand key resolve it here rather than hard-coding a hostname.
The one-way model (HIP-0111)
IAM is adapted TO each framework; frameworks keep their NATIVE auth routes
(NextAuth/Auth.js hard-mount /api/auth or /auth — never moved to
/v1; moving them caused a prod outage). Each adapter is the framework's
native provider/middleware shape whose authorize/token/userinfo resolve
through paths.ts to /v1/iam/oauth/*. Every endpoint in every adapter
is built with iamUrl(...) / OIDC_PATHS — grep-proven, no path string
elsewhere, no discoveryUrl-only config.
Gotcha that drives all of this: IAM returns a 200 text/html SPA for
any unregistered path. A wrong path yields HTML, not a 404 — silent
breakage. So: hit the exact paths, and never let a failed discovery fall
back to a wrong one. The hard-coded fallbacks in browser.ts use the
same canonical paths.
Rules baked into the SDK
- PKCE S256 always on.
src/pkce.tsverifier = base64url of 32crypto.getRandomValuesbytes (256-bit entropy), challenge = base64url SHA-256 (S256).generateStateis the same 256-bit primitive. validateTokenfails closed on audience.audmust equalclientId→ elseiam_audience_invalid. Opt out only withconfig.allowMissingAudience === true. No silent retry. Branches on jose error codes (ERR_JWT_EXPIRED,ERR_JWT_CLAIM_VALIDATION_FAILEDclaim === "aud"), not message strings.
- One name per thing. Canonical =
Iam*classes (IamClient,IamApiError,IamContext) +hanzo_iam_storage prefix + clean type names (Config,User,Organization, …). Exception: the NextAuth provider's canonical export isHanzoIamProvider(+HanzoIamProfiletype) because that is whatconsoleimports;IamProvideris a stable alias to the same function. The React provider component isIamProvider(different module, different thing). Do not reintroduce otherHanzo*aliases.
Subpath entries (tsup entry[] ↔ package.json exports)
index, paths, auth, browser, session, server, types, react,
views, nextauth, sveltekit, remix, express, hono, passport,
betterauth, validation. Dual ESM (.js) + CJS (.cjs) from tsup. The
declarations come from tsc -p tsconfig.build.json, a .d.ts per source
file, and scripts/cts.mjs copies each to a .d.cts whose relative imports
name .cjs, so a require consumer gets CommonJS types. The exports map has
no types conditions: TypeScript types a .js by the .d.ts beside it and a
.cjs by the .d.cts. tsup's own dts plugin cannot load on TypeScript 7,
which has no JS API. No billing — it was a throwing tombstone;
deleted from src, tsup, and exports. Billing lives in @hanzo/commerce.
Framework adapters (all thin, all → paths.ts, zero runtime framework
imports — they use structural types so no framework is a dependency):
-
src/nextauth.ts—HanzoIamProvider(opts)(aliasIamProvider) → NextAuth/Auth.jsOAuthConfigwith explicitauthorization/token/userinfo/jwks_endpoint(NOTwellKnown/discovery — that would risk the SPA catch-all), defaultid:"hanzo-iam",client_secret_basic,checks:["state","pkce"]. Exports typeHanzoIamProfile.consolespreads it and overridesid/name/profile. -
src/sveltekit.ts— re-exports the nextauth provider asHanzoIamfor@auth/corehosts (SvelteKit/SolidStart/@auth/express). Same builder. -
src/remix.ts—hanzoIamStrategyOptions(opts)(explicit endpoints +codeChallengeMethod:"S256") forremix-auth-oauth2'sOAuth2Strategy(peer, constructed by the app) +requireSession/getSessionloader guards overserver.ts. -
src/express.ts—requireAuth(config)middleware (401 + attachreq.iamSession),getSession(optional),getIamSession(req)(typed read). Connect/Express/raw-http req.headers record. -
src/hono.ts—requireAuth(config)middleware (401 +c.set),getSession,getIamSession(c). Usesc.req.rawWeb Request. -
src/server.ts— framework-agnostic SSR helper.getServerSession(req, config, opts?)→{ userId, owner, email?, claims } | null. ReadsAuthorization: Bearer …then thehanzo_iam_access_tokencookie.withSession(config, handler, opts?)guards a Web-Responseroute. Header reader duck-types both WebRequest(headers.get) and NodeIncomingMessage(headers record). -
src/views.tsx— REAL React components (built withcreateElement, no JSX syntax, so nojsxruntime needed at build).Loginrenders only the providers it is passed and is THE default login UI (also re-exported from the/reactentry). Every method stays IN-PLACE — no full-page redirect to the hosted page:SocialButton→IAM#signinPopup(popup +postMessage; Google/GitHub can't be iframed),WalletButton→IAM#loginWithWallet(inline SIWx/EIP-4361, no loop), email/password/OTP → PKCE-bound/v1/iam/logincode. All four converge on the SAME PKCEhandleCallback.signinRedirectis only an explicit standalone fallback. The prior file wasexport declare const-only → an empty runtime module; that regression is locked out byviews.test.ts. -
src/browser.tspopup+wallet (v0.13.5) —IAM#signinPopupcompletes over a one-shotpostMessagehandshake with THREE mandatory gates:event.origin=== theredirect_uriorigin (allowlist),data.source === "hanzo-iam", anddata.state=== the state THIS call generated (CSRF). It tears the listener + popup down after the first accepted message and never logs the code.completePopupSignin()is the popup callback-page half — mount it on theredirect_uriroute; it gates on the reservedwindow.name(hanzo_iam_login, set bysigninPopup, survives the provider hop) so it is inert on any ordinary same-origin page that merely carries?code+ anopener, thenpostMessages{code,state}to the opener withtargetOriginpinned to its OWN origin (never"*"), else returnsfalse.IAM#loginWithWalletruns GETweb3/nonce→ server message signed verbatim viapersonal_sign→ POSTweb3/verify(server RECOVERS the signer; the clientaddressis a hint, never trusted) → PKCE-bound code →handleCallback. -
PKCE transaction storage (v0.13.6 reconciliation) is BOTH durable AND per-attempt. It lives in
txStorage=localStorage(viaresolveTxStorage, probed at construct; falls back to the configuredstoragewhen localStorage is unavailable — SSR/privacy) so it survives the full-page OAuth redirect and Safari/WebKit ITP; and it is keyed per attempt ashanzo_iam_code_verifier:<state>(viastashPkce) so a double-click / racingsigninSilent/ re-run React effect can't stomp another attempt's verifier. Because localStorage persists across tabs/abandoned redirects, each slot value is stamped${issuedAtMs}.${verifier}andstashPkceopportunistically sweeps slots older thanVERIFIER_TTL_MS(15m) — a live concurrent sibling (seconds old) is NEVER swept;clearTokenssweeps ALL slots (both stores).handleCallbackresolves the verifier by the returned state fromtxStorage(thenstorageas a transition fallback), decoding the stamp — its presence structurally proves we issued that state (CSRF), and a MISSING saved state on the legacy path is itself a rejection (never a soft-skip). Redirect-survival + concurrent isolation + orphan-sweep all locked bybrowser.test.ts. -
validateToken(from main) honors explicitconfig.jwksUri+config.issueroverrides (in-cluster / split-horizon: pod can't reach the public IAM origin), bypassing discovery; otherwiseresolveJwksUridegrades to the canonical/v1/iam/.well-known/jwkson any discovery failure. Both preserved. -
src/betterauth.ts—iamProvider(config)returns a realgenericOAuthconfig entry:{ providerId:"hanzo", authorizationUrl, tokenUrl, userInfoUrl, clientId, clientSecret, scopes, pkce:true, authentication:"basic" }. Usage:genericOAuth({ config: [iamProvider({...})] }). NodiscoveryUrl.
Build / test
- Node 26 + pnpm 9; the package needs Node ≥ 22.12, the first release that
require()s an ES module unflagged (jose 6 is ESM only, and the CJS build requires it).pnpm installrunsprepare, which ispnpm build. pnpm build=rm -rf dist && tsup && pnpm declarations, each step chained with&&, so a failed or killedtscfails the build.pnpm devrunstsup --watch, which cleans nothing, and rerunspnpm declarationsafter every rebuild, so types never drop out.- CI:
.hanzo/workflows/ci.yml(install with a frozen lockfile, build, typecheck, test on Node 22/24/26;go vet+go testingo/). - Tests:
pnpm test→node --test --import tsx 'src/**/*.test.ts'.tsxis a devDependency (native Node TS strips types but does NOT rewrite./x.js→./x.ts, so the test runner needs tsx). pnpm typecheck(tsc --noEmiton typescript 7, the native compiler) must stay green (tsconfig.jsonsetsjsx: react-jsxso the.tsxtypechecks when imported from a test).- The tests cover: locked paths + brand map,
validateTokenmatrix (happy / expired / bad-aud-fails / missing-aud-fails / allowMissingAudience / tampered), PKCE ≥256-bit entropy, exactiamProvidershape, nextauth explicit-endpoints +hanzo-iamid +client_secret_basic, the four new adapters (sveltekit/remix/express/hono) endpoint pinning + guard 401s, passport/v1/iamURLs, server header extraction, views-are-real, and the shipped build (src/dist.test.ts, afterpnpm build): every file package.json names is built and in thepnpm packlist with its declarations, no test file is packed, every exports entry loads as ESM and as CJS, a.ctsand a.mtsconsumer typecheck against every subpath onmodule: node16, and the shipped nextauth provider has nowellKnown.
Repo
- Retired mirror.
@hanzo/iamis built and published fromhanzo-js/iam, which holds the npm line (0.21.x). package.json here is"private": true, so npm and pnpm refuse to publish it; never tag a release here either (v0.13.8exists as a tag and on npm already). - Remote:
hanzoai/iam-sdk. main is kept building and tested; a change that should reach npm goes tohanzo-js/iam. go/is a separate Go module (tenant helper); unrelated to the JS SDK.