hip-0111

HIP-111: Hanzo IAM Authentication Standard. Status Active. Hanzo's own standard — read this before implementing against it.

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 (the clean-room hanzoai/iam2 implementation). This HIP specifies the wire contract — how everything talks to it. Where the two touch (endpoint paths, discovery), this HIP is authoritative and HIP-0026 follows it.

Hanzo IAM (hanzoai/iam2) is a clean-room, standards-based OAuth 2.0 + OpenID Connect + SCIM 2.0 provider — original expression, no upstream fork — deployed once per brand:

| Brand | IAM origin (serverUrl) | Login UI | |-------|--------------------------|----------| | Hanzo | https://iam.hanzo.ai | 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 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/iamsrc/paths.tsOIDC_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 |

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 front-door 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 front-door.
  8. "Verb" aliases / bespoke REST for a standardized capabilityget-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 front-door — 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-0044) 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 front-door 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 not yet met by the deployment. Measured against https://hanzo.id on 2026-07-28; unauthenticated probes, so a 401 means the route exists and demands auth, and a 404 would mean it is genuinely gone:

| Surface | This HIP says | Production returns | |---|---|---| | /v1/iam/get-users | gone | 401 — route live | | /v1/iam/get-user | gone | 401 — route live | | /v1/iam/add-user | gone | 401 — route live | | /v1/iam/get-organizations | gone | 401 — route live | | /v1/iam/get-application | gone | 401 — route live | | /v1/iam/issue-user-token | gone | 401 — route live | | /v1/iam/get-records | gone | 401 — route live | | /v1/iam/get-account | gone | 200 with {"status":"error","msg":"please sign in first"} |

Two further deviations:

alongside the standard /v1/iam/oauth/token. Only the standard one appears in OIDC discovery, so the alias is live but undiscoverable — the worst of both.

envelope where the standard requires 401. This is the vendor error shape this HIP exists to eliminate, and a client that branches on the status code reads "signed in".

The standard surfaces this HIP mandates are all present and correct (/v1/iam/scim/v2/Users, /v1/iam/oauth/{token,userinfo,introspect}, jwks at /v1/iam/.well-known/jwks, and grant_types_supported including RFC 8693 token-exchange and device_code). So this is not a gap in the implementation of the standard — both surfaces are live at once, which is precisely the "two ways to do one thing" the HIP forbids.

Removal order matters. The compat aliases cannot simply be deleted: hanzoai/cloud calls get-application today, and internal/authz carries an entityNoun fold specifically so capability checks keep working on the alias path. Retire in this order, or the deletion is an outage:

  1. Migrate every caller to the native/RFC route (/v1/iam/applications,

SCIM for users, oauth/token for tokens).

  1. Verify no traffic remains on the alias paths.
  2. Delete internal/compat/aliases.go and the access_token alias, and drop the

entityNoun fold that exists only to serve them.

Until step 3 lands, this HIP describes the intended contract, not the deployed one, and that difference is the point of recording it here rather than leaving the prohibition list looking satisfied.

Security Considerations

References

  1. HIP-0026: Identity & Access Management Standard — the IAM server
  2. HIP-0044: Hanzo Gateway Standard — JWT validation + X-Org-Id propagation at the gateway
  3. HIP-0068: Ingress Standard — edge TLS and routing
  4. HIP-0027: Secrets Management Standard — KMS-managed client secrets
  5. HIP-0112: Cloud Infrastructure Topology Standard — how IAM fits the estate
  6. @hanzo/iam — the SDK; src/paths.ts is the canonical path source
  7. Standards this surface implements (the wire contract, in full):

Copyright

Copyright and related rights waived via CC0.