Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5638f0772 |
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user