hip-0111

HIP-111: Hanzo IAM Authentication Standard. Status Final. Hanzo architectural specification.

HIP-0111: Hanzo IAM Authentication Standard

Abstract

This is the one and only way an application authenticates a user, provisions an identity, or validates a token against Hanzo IAM. It defines the canonical IETF/RFC endpoint surface, the single approved client library (@hanzo/iam), the integration pattern for every supported framework, the application-registration rules, and the anti-patterns that are forbidden.

RFC-standard only — no vendor compat. Every wire contract on this surface is an IETF RFC or OpenID Connect standard. There are NO verb aliases (get-users, add-user, get-account, issue-user-token, …), no bespoke "verb" REST, and no backward-compat shims, on iam or on any client. Where a capability has a standard, the standard IS the surface: identity provisioning is SCIM 2.0 (RFC 7644/7643), delegated/on-behalf-of tokens are OAuth 2.0 Token Exchange (RFC 8693), account claims are OIDC UserInfo, token validation is Introspection (RFC 7662) + JWKS (RFC 7517). A client that needs a capability uses its RFC; if no RFC covers it, it is the authorization server's internal concern (§6), never a new public "verb".

HIP-0026 specifies the IAM server — the provider itself. This HIP specifies the wire contract — how everything talks to it. Where the two touch (endpoint paths, discovery, the token exchange), this HIP is authoritative and HIP-0026 follows it.

Hanzo IAM (hanzoai/iam) is a clean-room, standards-based OAuth 2.0 + OpenID Connect + SCIM 2.0 provider — original expression, no upstream fork — deployed once per brand. The Beego/xorm fork it replaced is retired to hanzoai/iam-v1 and is out of every graph; a HIP describing Beego, xorm or a Postgres schema is describing that repository, not this one. IAM is deployed once per brand:

| Brand | IAM origin (serverUrl) | Login UI | |-------|--------------------------|----------| | Hanzo | https://hanzo.id | hanzo.id | | Lux | https://lux.id | lux.id | | Zoo | https://zoo.id | zoo.id | | Bootnode | https://id.bootno.de | id.bootno.de | | Pars | https://pars.id | pars.id |

The two columns hold the same host on every row, and that is the rule, not a coincidence: the authorization server answers on the origin it names as issuer, so serverUrl is always the brand's identity host. iam.hanzo.ai is not that host — it serves the 200 text/html SPA on every path, /v1/iam/* included, so a client pointed there resolves the catch-all described below and fails on content type. Discovery is the check: https://hanzo.id/.well-known/openid-configuration returns application/json with issuer: https://hanzo.id, and so does the copy served from api.hanzo.ai.

The library is brand-agnostic. You select the brand by setting serverUrl; nothing else changes.

SDK: @hanzo/iam (npm, v0.11.0+) Source of truth for paths: @hanzo/iam → src/paths.ts → OIDC_PATHS

Motivation

Every authentication regression in the estate has had one of three root causes:

  1. Path drift — a client invented its own OIDC path (/oauth/authorize, /api/login/oauth/access_token, /api/...). IAM serves a 200 text/html SPA catch-all for any unregistered path, so a wrong path returns an HTML body with a 200, not a 404. The OAuth library then dies on content-type must be application/json and the failure looks like a server bug. It is not; it is a client hitting the wrong URL.
  1. Discovery drift — a client used raw better-auth genericOAuth({ discoveryUrl }). Discovery resolution landed on the SPA catch-all HTML and the client wired itself to garbage endpoints.
  1. Hand-rolled OAuth — a team reimplemented PKCE, token exchange, or JWKS validation and got the audience check, the S256 challenge, or the refresh rotation subtly wrong.

All three vanish if there is exactly one library that owns exactly one set of paths, and every application uses it. That is this standard.

Specification

1. The canonical OIDC endpoints

These are the only paths. There is no /oauth/, no /api/login/, no /api/ prefix. They are relative to the brand serverUrl.

| Purpose | Path | RFC / spec | |---------|------|------------| | OIDC discovery | /.well-known/openid-configuration | OIDC Discovery 1.0 | | AS metadata | /.well-known/oauth-authorization-server | RFC 8414 | | Authorize | /v1/iam/oauth/authorize | RFC 6749 §3.1 | | Token | /v1/iam/oauth/token | RFC 6749 §3.2 | | UserInfo | /v1/iam/oauth/userinfo | OIDC Core §5.3 | | Introspection | /v1/iam/oauth/introspect | RFC 7662 | | Revocation | /v1/iam/oauth/revoke | RFC 7009 | | JWKS | /v1/iam/.well-known/jwks | RFC 7517 | | Logout | /v1/iam/oauth/logout | OIDC RP-Initiated Logout | | Provisioning (SCIM) | /v1/iam/scim/v2/{Users,Groups,…} | RFC 7644/7643 (§8) |

The token endpoint (/v1/iam/oauth/token) dispatches ONLY standard grant_types — authorization_code (RFC 6749 §4.1, always PKCE-bound), refresh_token (§6, rotating), client_credentials (§4.4), password (§4.3, confidential first-party only), and urn:ietf:params:oauth:grant-type:token-exchange (RFC 8693, §7 — delegation / on-behalf-of). There is exactly one token endpoint and one spelling of it; the legacy access_token alias is gone (a client posts to token, never access_token).

Mandatory parameters, everywhere:

The discovery document MUST be self-consistent: issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, and jwks_uri all share one origin (host-relative to the brand). The IAM knob that controls this is originFrontend in app.prod.conf — it MUST be empty so discovery is host-relative. A split-origin discovery document breaks strict OIDC clients (openid-client, NextAuth) that pin the issuer.

2. The only integration: @hanzo/iam

JavaScript and TypeScript applications integrate only through @hanzo/iam. No application writes an OIDC path string. No application calls these endpoints by hand. The SDK holds the paths in one place (OIDC_PATHS) and every entry point reads from it; a failed discovery round-trip degrades to these same hard-coded values, so a client can never resolve to the SPA catch-all.

The SDK is split into per-environment entry points. Import the one that matches your runtime:

| Subpath | Surface | Use | |---------|---------|-----| | @hanzo/iam | IamClient, types | conditional Node/browser entry | | @hanzo/iam/server | validateToken, getServerSession | server-side JWT validation + session | | @hanzo/iam/betterauth | iamProvider | better-auth apps | | @hanzo/iam/nextauth | IamProvider | NextAuth / Auth.js apps | | @hanzo/iam/react | hooks, OrgProjectSwitcher | React SPAs | | @hanzo/iam/browser | IAM (PKCE client) | browser PKCE login | | @hanzo/iam/passport | createIamPassportStrategy | Node/Express + Passport |

Server-side token validation (any backend)

import { validateToken } from "@hanzo/iam/server";

const result = await validateToken(accessToken, {
  serverUrl: process.env.IAM_ENDPOINT!, // e.g. https://iam.hanzo.ai
  clientId: process.env.IAM_CLIENT_ID!,
});

if (!result.ok) return unauthorized(result.reason);
const { userId, email, owner } = result; // owner = org slug → scope every query to it

validateToken discovers JWKS from /.well-known/openid-configuration, caches the key set per issuer, and verifies signature, iss, aud, and exp. Scope all multi-tenant data access to owner.

Server session (App Router / RSC)

import { getServerSession } from "@hanzo/iam/server";

const session = await getServerSession({ serverUrl: process.env.IAM_ENDPOINT! });
if (!session) redirect("/login");

better-auth

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 config with explicit authorization, token, and userinfo endpoints (the canonical /v1/iam/oauth/* paths) — it never relies on discovery resolution. The registered redirect URI for this provider is https://<app-host>/api/auth/oauth2/callback/hanzo.

NextAuth / Auth.js

import { IamProvider } from "@hanzo/iam/nextauth";

export default NextAuth({
  providers: [
    IamProvider({
      serverUrl: process.env.IAM_ENDPOINT!,
      clientId: process.env.IAM_CLIENT_ID!,
      clientSecret: process.env.IAM_CLIENT_SECRET!,
      checks: ["state", "pkce"],
    }),
  ],
});

React SPA (PKCE)

import { IAM } from "@hanzo/iam/browser";

const iam = new IAM({
  serverUrl: "https://iam.hanzo.ai",
  clientId: "hanzo-myspa",
  redirectUri: `${location.origin}/auth/callback`,
});

await iam.signinRedirect();              // start
const token = await iam.handleCallback(); // on /auth/callback
const access = await iam.getValidAccessToken(); // auto-refresh
import { IamProvider, useIam } from "@hanzo/iam/react";

<IamProvider serverUrl="https://iam.hanzo.ai" clientId="hanzo-myspa">
  <App />
</IamProvider>;

The browser client uses PKCE S256, holds tokens in memory, and refreshes silently. Never persist access tokens in localStorage.

Node / Express + Passport

import passport from "passport";
import { createIamPassportStrategy } from "@hanzo/iam/passport";

passport.use("iam", createIamPassportStrategy({
  serverUrl: "https://iam.hanzo.ai",
  clientId: "hanzo-myservice",
  clientSecret: process.env.IAM_CLIENT_SECRET!,
  callbackUrl: "https://myservice.hanzo.ai/v1/sso/oidc/callback",
}));

3. Application registration

Every application is registered once per brand in IAM before it can authenticate.

| Framework | Registered redirect URI | |-----------|-------------------------| | better-auth (genericOAuth + iamProvider) | https://<app-host>/api/auth/oauth2/callback/hanzo | | NextAuth / Auth.js | https://<app-host>/api/auth/callback/iam | | React SPA (@hanzo/iam/browser) | https://<app-host>/auth/callback | | Passport | https://<app-host>/v1/sso/oidc/callback |

The first two carry an /api/ segment because better-auth and NextAuth mount their own handler there and the path is not ours to choose — it is a route on the app's host, in a third-party framework's namespace. The no-/api/ rule (§4.4, §4.7) is about surfaces we serve; it is not weakened by a callback we merely register. A redirect URI that does not match the framework's actual mount point byte for byte fails the flow, so this table records what the framework does, not what we would prefer.

4. Forbidden anti-patterns

These break in production and are not permitted under any circumstance:

  1. Raw better-auth genericOAuth({ discoveryUrl }) — discovery resolves to the SPA catch-all HTML and the client dies with content-type must be application/json. Use iamProvider(), which pins explicit endpoints.
  2. Hand-rolled OAuth / PKCE / JWKS — use the SDK. Reimplementation gets aud, S256, or refresh rotation wrong.
  3. Any per-app OIDC path string — no application writes /v1/iam/oauth/... (or, worse, /oauth/...) itself. The path lives in OIDC_PATHS inside the SDK; applications pass only serverUrl.
  4. Legacy paths — /oauth/, /api/login/oauth/, anything /api/-prefixed. Gone. No backward compatibility.
  5. Non-empty originFrontend in production — produces a split-origin discovery document that breaks strict clients.
  6. Per-app social OAuth clients — an app registering its own Google/GitHub (or Web3) OAuth client. Social providers are configured ONCE per network, org-level, and shared (§7). A per-app client re-creates the shared one N times and drifts.
  7. /api/ on the login entry point too — the IAM's own login UI / portal Worker uses the native login API under /v1/iam/* (§6), never /api/login, /api/get-app-login, /api/signup. The "no /api/" rule is absolute, including the login entry point.
  8. "Verb" aliases / bespoke REST for a standardized capability — get-users, get-user?id=, add-user, update-user, delete-user, get-organizations, get-records, issue-user-token, get-account, mint-user-keys, and every other non-standard verb are gone, on iam and on every client. Each has an RFC that IS the surface: identity provisioning → SCIM 2.0 (§8), delegated/on-behalf-of tokens → Token Exchange (§7), account claims → UserInfo (§1). A client that reaches for a verb is reaching for the wrong contract; there is no compat layer that will answer it.
  9. A duplicate spelling of a standard endpoint — one token endpoint, not token + access_token; one userinfo, not userinfo + get-account. An alias is two ways to do one thing; the standard path is the only one served.

5. Gotchas (call out explicitly)

6. The login entry point — the AS's own concern, not a client surface

OAuth 2.0 / OIDC deliberately do not specify how the authorization server authenticates the end user (the credential-entry step). That is the AS's internal concern. So the hosted login page (the per-brand portal at hanzo.id/lux.id/… and its Worker) has a small first-party API it — and ONLY it — calls, under the canonical /v1/iam/* prefix:

| Purpose | Path | |---------|------| | App/org resolution before login | /v1/iam/get-app-login | | Password login (mints the code) | /v1/iam/login | | Signup | /v1/iam/signup | | Verification code | /v1/iam/send-verification-code | | Logout | /v1/iam/oauth/logout (§1) |

This is NOT a client integration surface and NOT a set of "verbs" a client may call — it is the AS's own login UI talking to the AS. Account claims are NOT here: there is no get-account and no second userinfo — every consumer (including the gateway admin-guard, HIP-0118) reads the standard OIDC UserInfo (/v1/iam/oauth/userinfo, §1), which carries sub, owner/organization, email, email_verified, and the isAdmin claim the SuperAdmin predicate derives from. One account contract, and it is the RFC one.

Same rule as §1: /v1/iam/* only — no /api/, anywhere, including the login Worker. Client apps use only the standard surface (§1) through the SDK; the login API is internal to the AS.

7. Delegation / on-behalf-of — OAuth 2.0 Token Exchange (RFC 8693)

A trusted first-party backend that must call a downstream API as an end user (the console BFF forwarding a request on the signed-in user's behalf, the keyless AI proxy) obtains that token through RFC 8693 Token Exchange on the token endpoint — never a bespoke issue-user-token verb.

8. Identity provisioning — SCIM 2.0 (RFC 7644 / RFC 7643)

Creating, reading, updating, and deleting identities is SCIM 2.0 — the IETF standard for cross-domain identity management — under /v1/iam/scim/v2/. There are NO get-users/add-user/get-organizations verbs.

| Resource | Path | Maps to | |----------|------|---------| | Service provider config | /v1/iam/scim/v2/ServiceProviderConfig | supported features | | Schemas / resource types | /v1/iam/scim/v2/{Schemas,ResourceTypes} | discovery | | Users | /v1/iam/scim/v2/Users (+ /{id}) | the user entity | | Groups | /v1/iam/scim/v2/Groups (+ /{id}) | organizations, roles |

9. Social & Web3 — one shared provider, never per-app

Google, GitHub, and Web3 are configured once per network as org-level providers in IAM (admin/provider-google, admin/provider-github, …). Every app reuses them via a per-app canSignIn toggle — an application never registers its own social OAuth client (§4.6).

Conformance status

The prohibitions in §Anti-patterns are met by the deployment. Measured against https://hanzo.id on 2026-09-09; unauthenticated probes, so a 401 would mean the route still exists and demands auth, and 410 Gone means it was removed on purpose and the server says what replaced it:

| Surface | This HIP says | Production returns | |---|---|---| | /v1/iam/get-users | gone | 410 → {"successor":["/v1/iam/users"]} | | /v1/iam/get-user | gone | 410 → {"successor":["/v1/iam/users","/v1/iam/keys/principal"]} | | /v1/iam/add-user | gone | 410 → {"successor":["/v1/iam/users"]} | | /v1/iam/get-organizations | gone | 410 → {"successor":["/v1/iam/organizations"]} | | /v1/iam/get-application | gone | 410 → {"successor":["/v1/iam/applications"]} | | /v1/iam/issue-user-token | gone | 410 → {"successor":["/v1/iam/tokens/issue"]} | | /v1/iam/get-records | gone | 410 → {"successor":["/v1/iam/audit-logs"]} | | /v1/iam/get-account | gone | 410 → {"successor":["/v1/iam/account"]} |

The two further deviations are also closed. /v1/iam/oauth/access_token returns 404; the standard /v1/iam/oauth/token is the only spelling, and it answers a POST with 400 on an empty body rather than a 200 carrying an error envelope. The get-account shape that returned 200 with {"status":"error"} is gone with the rest.

410 rather than 404 is the stronger result, and the retirement order in this section is why it was reachable. A 404 says only that nothing is there; 410 with a successor says the route was removed deliberately and names the RFC route that replaced it, so a client still on an alias gets told where to go instead of guessing. The order held: callers moved to the native routes first, the aliases went second, and no deletion was an outage.

The standard surfaces this HIP mandates remain present and correct (/v1/iam/scim/v2/Users and /v1/iam/scim/v2/ServiceProviderConfig answer 401 as application/json; /v1/iam/.well-known/jwks serves an RS256 key set; /v1/iam/oauth/{authorize,token,introspect} are live routes, not the SPA; discovery is single-origin on https://hanzo.id and advertises RFC 8693 token-exchange and device_code). One surface, one spelling — which is what this HIP asked for.

The catch-all still discriminates, and that is how these probes are read: the uncanonical /scim/v2/Users returns 200 text/html, the canonical /v1/iam/scim/v2/Users returns 401 application/json. Content type, not status, is the test.

Security Considerations

References

  1. HIP-0026: Identity & Access Management Standard — the IAM server
  2. HIP-0519: One Identity Boundary — JWT validation + X-Org-Id minted once at the edge
  3. HIP-0068: Ingress Standard — edge TLS and routing
  4. HIP-0027: Secrets Management Standard — KMS-managed client secrets
  5. HIP-0134: One Process, One Socket, One Identity — how IAM fits the estate
  6. HIP-0118: SuperAdmin & Tenant Isolation Model — the reserved-org predicate IAM_ADMIN_TOKEN_EXCHANGE_APPS defends
  7. HIP-0136: One Secret, One Path — where a client secret is addressed
  8. HIP-0143: Egress — The Outbound Trust Boundary — the machine identity a caller presents in order to spend
  9. @hanzo/iam — the SDK; src/paths.ts is the canonical path source
  10. Standards this surface implements (the wire contract, in full):

Copyright

Copyright and related rights waived via CC0.