Compare commits

...
Author SHA1 Message Date
Antje Worring e5638f0772 fix(auth): org-agnostic password login — resolve real owner-org, not pinned brand
LoginForm no longer pins organization=<brand> on POST /v1/iam/login. It passes
the new (normally-unset) TenantConfig.loginOrg, and client.login() OMITS the
organization field when empty so IAM runs cross-org resolution and the session
encodes the user's REAL owner-org (GetOrganizationByUser), never the hint.

Fixes: global admins (z@/a@hanzo.ai, woo@lux.network — owner=admin) were
truncated to a 1-org 'hanzo' session via the UI because the pinned org made
GetUserByFields hit the colliding hanzo/<name> row first, so the cross-org
fallback to admin/<name> never ran. IAM IsGlobalAdmin()==(Owner=='admin') and
get-organizations returns all orgs only for a global admin. Verified live on
hanzo.id: z@hanzo.ai -> owner=admin, 45 orgs; brand-only user -> 1 org.

Boundaries preserved: signup still sends a concrete org; per-app SSO
(client_id+redirect_uri, type=code) still mints a code bound to the resolved
user; a brand can force single-org login via loginOrg in the catalog ConfigMap.
Contract locked in pkgs/auth/src/client.test.ts.

Bump 0.1.22 -> 0.1.23.
2026-06-23 15:24:12 -07:00
8 changed files with 214 additions and 13 deletions
+36
View File
@@ -1,5 +1,41 @@
# LLM.md — Hanzo ID
## Org-agnostic password login (fixed 0.1.23)
The portal login is now **org-agnostic**: it no longer pins
`organization=<brand>` on `POST /v1/iam/login`. `LoginForm` passes
`tenant.loginOrg` (a NEW, normally-UNSET `TenantConfig` field) as the
`organization`, and `client.login()` OMITS the field entirely when it is
empty/undefined. With no org posted, IAM runs its **cross-org resolution**
(`object.GetUserByFields``GetUserByFieldCrossOrg`) and the session encodes the
user's REAL owner-org (`GetOrganizationByUser`), never the posted hint.
Why this matters (the bug it fixes): IAM's `IsGlobalAdmin()` is
`user.Owner == "admin"` (it ignores the stored `isGlobalAdmin` column), and
`get-organizations` returns ALL orgs only for a global admin, else just the
caller's own org. The seeded superusers (`z@hanzo.ai`, `a@hanzo.ai`,
`woo@lux.network`) exist in BOTH the `admin` org (the global identity) AND their
brand org (`hanzo/lux`). Pinning `organization=hanzo` made `GetUserByFields`
hit the colliding `hanzo/z` row FIRST (in-org lookup succeeds → cross-org
fallback never runs), so an admin got a 1-org `hanzo` session via the UI even
though the API could reach the 45-org global session. Omitting the org makes the
in-org lookups miss → cross-org fallback → `admin/z` (global) for the colliding
hanzo-domain emails, while a brand-only identity (`z@lux.network`,
`major@hanzo.ai`, …) still resolves to its own org. Verified live on
`hanzo.id`: `z@hanzo.ai``owner=admin`, 45 orgs; a brand-only user → 1 org.
Boundaries (do NOT regress):
- **Signup** still sends a concrete `organization` (`tenant.orgId`) — you cannot
create a user in "no org". Only LOGIN omits it.
- **Per-app SSO** (console/chat/team pass their own `client_id` + `redirect_uri`)
is unaffected: `type=code` + the app's client_id still flow; the auth code is
bound to the cross-org-resolved user. Proven against live IAM with
`application=hanzo-console`.
- A brand that deliberately wants single-org portal login can set `loginOrg` in
its runtime catalog entry (`id-tenant-catalog` ConfigMap) — no rebuild.
- Contract locked in `pkgs/auth/src/client.test.ts` (omit-when-unset,
omit-when-empty, include-when-set, SSO still omits, signup still sends).
## PKCE on password login (fixed 0.1.13)
`client.login()` (POST `/v1/iam/login`) must forward `code_challenge`
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id",
"private": true,
"version": "0.1.22",
"version": "0.1.23",
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
"scripts": {
"build": "pnpm -r build",
+124
View File
@@ -0,0 +1,124 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createAuthClient } from './client.ts'
import type { TenantConfig } from '@hanzo/id-shared'
// A capturing fetch double: records the URL + parsed JSON body of the last call
// and returns a canned IAM "ok" response. No network.
function capturingFetch() {
const calls: { url: string; body: Record<string, unknown> }[] = []
const fetchImpl: typeof fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.toString()
let body: Record<string, unknown> = {}
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
calls.push({ url, body })
return new Response(JSON.stringify({ status: 'ok', data: 'AUTHCODE' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
return { calls, fetchImpl }
}
function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
return {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-console',
appName: 'hanzo-console',
publicOrigin: 'https://hanzo.id',
oauthCallbackOrigin: 'https://hanzo.id',
brandPackage: '@hanzo/brand',
...overrides,
}
}
// THE FIX: with loginOrg unset, the portal must NOT pin the brand org — it omits
// `organization` so IAM resolves the user cross-org (a global admin → the admin
// org / full session; a brand user → their own org). Pinning `hanzo` here is the
// live bug that truncates a global admin to one org.
test('login OMITS organization when loginOrg is unset (org-agnostic resolution)', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
await client.login({
identifier: 'z@hanzo.ai',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
// organization intentionally not provided (LoginForm passes tenant.loginOrg)
})
assert.equal(calls.length, 1)
assert.equal(
'organization' in calls[0]!.body,
false,
'organization must be absent from the body so IAM runs cross-org resolution',
)
// The identity + app still ride the request.
assert.equal(calls[0]!.body.username, 'z@hanzo.ai')
assert.equal(calls[0]!.body.application, 'hanzo-console')
})
// An empty-string org is treated the same as unset (defensive: a catalog might
// emit "").
test('login OMITS organization when it is an empty string', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
await client.login({
identifier: 'z@hanzo.ai',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
organization: '',
})
assert.equal('organization' in calls[0]!.body, false)
})
// A brand that DELIBERATELY scopes its portal to one org can still force it.
test('login INCLUDES organization when one is explicitly provided', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
await client.login({
identifier: 'someone',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
organization: 'hanzo',
})
assert.equal(calls[0]!.body.organization, 'hanzo')
})
// Per-app SSO: the downstream app's client_id + redirect_uri still flow through;
// `type` flips to `code` and the org is STILL omitted (resolution stays correct
// for the SSO path too).
test('app SSO (redirectUri present) uses type=code and still omits organization', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
await client.login({
identifier: 'z@hanzo.ai',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
state: 'xyz',
})
assert.equal(calls[0]!.body.type, 'code')
assert.match(calls[0]!.url, /type=code/)
assert.equal('organization' in calls[0]!.body, false)
})
// Signup MUST still carry a concrete org — you cannot create a user in "no org".
test('signup STILL sends organization (unchanged — create needs a concrete org)', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
await client.signup({
email: 'new@hanzo.ai',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
organization: 'hanzo',
})
assert.equal(calls[0]!.body.organization, 'hanzo')
})
+15 -9
View File
@@ -96,19 +96,25 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
}
url.searchParams.set('type', type)
// `organization` is an OPTIONAL lookup hint (see LoginRequest). Omit it when
// empty so IAM runs its cross-org resolution: a global-admin identity then
// resolves to the `admin` org (full multi-org session) instead of being
// pinned to — and truncated by — a colliding brand-org row. The session's
// org is always the resolved user's real owner, never this hint.
const body: Record<string, unknown> = {
type,
username: req.identifier,
password: req.password,
application: req.application,
signinMethod: 'Password',
autoSignin: true,
}
if (req.organization) body.organization = req.organization
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
type,
username: req.identifier,
password: req.password,
application: req.application,
organization: req.organization,
signinMethod: 'Password',
autoSignin: true,
}),
body: JSON.stringify(body),
})
return parseLoginResponse(res, req)
}
+19 -1
View File
@@ -3,7 +3,25 @@ export interface LoginRequest {
readonly password: string
readonly clientId: string
readonly application: string
readonly organization: string
/**
* Org-resolution anchor for the credential lookup. OPTIONAL by design.
*
* IAM resolves the user by (org, identifier); if the in-org lookup misses it
* falls back to a CROSS-ORG lookup by email/username and the session always
* encodes the user's REAL owner-org (`GetOrganizationByUser`), never this
* value. So this field is a lookup HINT, not the session's org.
*
* Leaving it empty/undefined makes login ORG-AGNOSTIC: every in-org lookup
* misses, the cross-org fallback runs, and an identity that lives in the
* global `admin` org (a global admin) resolves to `admin` (→ full multi-org
* session) while a brand-only identity resolves to its own brand org. This is
* why the portal does NOT pin the brand org here — pinning `hanzo` would
* resolve a colliding `hanzo/<name>` row and truncate a global admin to one
* org. Set it only to FORCE a specific tenant (e.g. a brand that deliberately
* scopes its portal to a single org). Signup, by contrast, MUST carry a
* concrete org (you cannot create a user in "no org").
*/
readonly organization?: string
readonly redirectUri?: string
readonly state?: string
readonly codeChallenge?: string
+6 -1
View File
@@ -30,7 +30,12 @@ export function LoginForm(props: LoginFormProps) {
password,
clientId: props.clientIdOverride ?? client.tenant.clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
// Org-agnostic by default: `loginOrg` is unset, so no `organization` is
// posted and IAM resolves the user cross-org by credentials. A global
// admin (identity in the `admin` org) lands in the global multi-org
// session; a brand-only user lands in their own org. Pinning the brand
// org here would truncate a global admin to one org (the live bug).
organization: client.tenant.loginOrg,
redirectUri: props.redirectUri,
state: props.state,
codeChallenge: props.codeChallenge,
+1 -1
View File
@@ -158,7 +158,7 @@ function hostSkeleton(host: string): TenantConfig {
function fromCatalog(entry: CatalogEntry | undefined): Partial<TenantConfig> {
if (!entry) return {}
const out: Record<string, string> = {}
for (const k of ['orgId', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage'] as const) {
for (const k of ['orgId', 'loginOrg', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage'] as const) {
const v = entry[k]
if (typeof v === 'string' && v.length > 0) out[k] = v
}
+12
View File
@@ -8,6 +8,18 @@
export interface TenantConfig {
/** Tenant org slug (matches the JWT `owner` claim and the IAM `<org>-<app>` namespace). */
readonly orgId: string
/**
* OPTIONAL org-resolution anchor for PASSWORD LOGIN only. Unset (the default)
* = org-agnostic: the SPA posts NO `organization`, IAM resolves the user
* cross-org by credentials, and the session encodes the user's REAL owner-org
* (a global admin → the `admin` org / full multi-org session; a brand user →
* their own org). Pinning `orgId` here would resolve a colliding brand-org row
* and truncate a global admin to a single org — so the portal leaves this
* unset. Set it ONLY for a brand that deliberately scopes its portal login to
* one tenant. Does NOT affect signup (which always targets `orgId`) or the
* apps launcher (which is brand-scoped by `orgId`).
*/
readonly loginOrg?: string
/** IAM (OIDC) backend origin, no trailing slash. */
readonly iamUrl: string
/** Pinned OIDC issuer claim. Defaults to iamUrl. */