fix: the consent screen named the wrong client, and we have orgs not tenants

APPROVING THE WRONG NAME. The device page read "You are about to authorize
hanzo-console" for a sign-in started by hanzo-cli, because appLabel was
tenant.appName — this PORTAL's own branding, a static string, not the client that
asked. The backend was never confused: approveDevice binds the pending row's own
application, and says so ("the portal app the browser happens to be on is
irrelevant to WHAT is being approved").

The page cannot honestly name the client, and must not learn it: the user_code is
40 bits and the one secret in this flow, so an endpoint answering "which app is
this code for" would be the oracle device.go refuses to be — it returns one opaque
refusal for unknown / expired / already-approved precisely so live codes cannot be
hunted. So the page stops naming a party it cannot vouch for and asks about the
thing it can: the code the human transcribed off their own device. A consent screen
that names the wrong party is worse than one that names none — it teaches people
the name means nothing.

ORGS, NOT TENANTS. There is no tenant concept here; the identifier has always been
the org (the JWT `owner` claim, the IAM `<org>-<app>` namespace). TenantConfig →
OrgConfig, resolveTenant → resolveOrg, tenant → org, and the module renamed to
match. 28 files, no residual `tenant`, typecheck clean, 145 tests pass.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-01 20:32:41 -07:00
co-authored by Hanzo Dev
parent 76c474a205
commit f6828998a1
28 changed files with 197 additions and 197 deletions
+13 -13
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { loadBrand, parseCatalog, resolveTenant, idBrandLabel, type BrandContract, type TenantConfig } from '@hanzo/id-shared'
import { loadBrand, parseCatalog, resolveOrg, idBrandLabel, type BrandContract, type OrgConfig } from '@hanzo/id-shared'
import { createAuthClient } from '@hanzo/id-auth'
import { Portal } from './pages/Portal'
import { Login } from './pages/Login'
@@ -10,19 +10,19 @@ import { Onboarding } from './pages/Onboarding'
import { DeviceApproval } from './pages/DeviceApproval'
/**
* Top-level wiring. Resolves tenant + brand once on mount, then routes via
* Top-level wiring. Resolves org + brand once on mount, then routes via
* `window.location.pathname`. No router lib needed — this app is 5 pages,
* `<a href>` is enough. Adding paths is a switch case.
*/
export function App() {
const [tenant, setTenant] = useState<TenantConfig | null>(null)
const [org, setOrg] = useState<OrgConfig | null>(null)
const [brand, setBrand] = useState<BrandContract | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function boot() {
// The runtime serves the per-host tenant catalog at /config.json
// The runtime serves the per-host org catalog at /config.json
// (templated from SPA_IAM_TENANT_CONFIG_JSON by the static server). Read
// it from there — NOT a `window.__ID_CATALOG__` global, which the runtime
// never injects (relying on it silently dropped every catalog-only host,
@@ -32,8 +32,8 @@ export function App() {
try {
const res = await fetch('/config.json', { cache: 'no-store' })
if (res.ok) {
const cfg = (await res.json()) as { iamTenantConfigJson?: string }
catalogRaw = cfg.iamTenantConfigJson
const cfg = (await res.json()) as { iamOrgConfigJson?: string }
catalogRaw = cfg.iamOrgConfigJson
}
} catch {
// network/parse error → fall back below
@@ -41,9 +41,9 @@ export function App() {
if (!catalogRaw) {
catalogRaw = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
}
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
const t = resolveOrg(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
if (cancelled) return
setTenant(t)
setOrg(t)
try {
const b = await loadBrand(t.brandPackage)
if (cancelled) return
@@ -61,10 +61,10 @@ export function App() {
}
}, [])
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
const client = useMemo(() => (org ? createAuthClient({ org }) : null), [org])
if (error) return <div className="hanzo-id-error">{error}</div>
if (!tenant || !brand || !client) return <div>Loading…</div>
if (!org || !brand || !client) return <div>Loading…</div>
const path = window.location.pathname
// Device-authorization approval (RFC 8628). Must precede the `/login` catch
@@ -74,7 +74,7 @@ export function App() {
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} />
if (path === '/signup' || path.startsWith('/signup/')) return <Signup client={client} brand={brand} />
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} />
if (path === '/callback' || path.startsWith('/callback/')) return <Callback tenant={tenant} brand={brand} />
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding tenant={tenant} brand={brand} />
return <Portal client={client} brand={brand} tenant={tenant} />
if (path === '/callback' || path.startsWith('/callback/')) return <Callback org={org} brand={brand} />
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding org={org} brand={brand} />
return <Portal client={client} brand={brand} org={org} />
}
+1 -1
View File
@@ -5,7 +5,7 @@
* visual essentials (name, logo, accent). The split-view login's marketing
* panel and the post-login apps launcher need richer, org-specific copy —
* ported verbatim from the frozen `legacy-nextjs` design (`staticBranding`
* content + `orgApps`). Keyed by `tenant.orgId` so it stays decoupled from
* content + `orgApps`). Keyed by `org.orgId` so it stays decoupled from
* hostname switches; unknown orgs fall back to `hanzo`.
*/
+5 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
import { createIam, createAuthClient, decodeState } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
@@ -36,7 +36,7 @@ function decodeProviderState(state: string | null): URLSearchParams | null {
return null
}
export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
export function Callback({ org, brand }: { org: OrgConfig; brand: BrandContract }) {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const search = new URLSearchParams(window.location.search)
@@ -45,7 +45,7 @@ export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: Brand
// Case (2): social provider return → exchange the provider code, then follow
// the continue-URL back into case (1).
if (providerState && search.get('code')) {
const client = createAuthClient({ tenant })
const client = createAuthClient({ org })
const oidcQuery = decodeState(search.get('state')!)
client
.providerLogin({
@@ -64,7 +64,7 @@ export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: Brand
}
// Case (1): the portal's own OIDC PKCE return.
const iam = createIam(tenant)
const iam = createIam(org)
iam
.handleCallback(window.location.href)
.then((tok) => {
@@ -83,7 +83,7 @@ export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: Brand
window.location.replace('/onboarding')
})
.catch((e) => setError(String(e)))
}, [tenant])
}, [org])
return (
<div className="hanzo-id-page hanzo-id-callback">
+5 -5
View File
@@ -64,8 +64,8 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
// victim being walked through a crafted link ticks it as readily as they
// click Approve. It bought nothing and cost every honest user a step.
const [error, setError] = useState<string | null>(null)
// NOT the client being approved. tenant.appName is this PORTAL's branding — a
// static per-tenant string — while the device authorization names its own
// NOT the client being approved. org.appName is this PORTAL's branding — a
// static per-org string — while the device authorization names its own
// application, which lives on the pending row and is what the backend actually
// approves (internal/oidc/device.go approveDevice: "the portal app the browser
// happens to be on is irrelevant to WHAT is being approved").
@@ -80,7 +80,7 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
// hanzo-cli. A consent screen that names the wrong party is worse than one that
// names none: it teaches people that the name means nothing. What this page CAN
// vouch for is the code the human transcribed, so that is what it asks about.
const portalLabel = client.tenant.appName
const portalLabel = client.org.appName
// Resolve the issuer session: signed in → confirm, else → sign-in form. Reads
// same-origin from `/v1/iam/get-account` (cookie session; the brand `*.id`
@@ -88,7 +88,7 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
useEffect(() => {
scrubUrl()
let alive = true
fetch(new URL('/v1/iam/get-account', client.tenant.iamUrl).toString(), {
fetch(new URL('/v1/iam/get-account', client.org.iamUrl).toString(), {
credentials: 'include',
headers: { Accept: 'application/json' },
})
@@ -108,7 +108,7 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
return () => {
alive = false
}
}, [client.tenant.iamUrl])
}, [client.org.iamUrl])
async function approve() {
setError(null)
+4 -4
View File
@@ -45,7 +45,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
const [mfa, setMfa] = useState<LoginResponse | null>(null)
const [challengeError, setChallengeError] = useState<string | null>(null)
const clientId = clientIdOverride ?? client.tenant.clientId
const clientId = clientIdOverride ?? client.org.clientId
useEffect(() => {
if (!canSilent) return
@@ -149,8 +149,8 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
mfaType: iamType,
passcode: code,
clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
application: client.org.appName,
organization: client.org.orgId,
redirectUri,
state,
codeChallenge,
@@ -181,7 +181,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
<main>
<h1>Sign in to {idBrandLabel(brand, client.tenant.orgId)}</h1>
<h1>Sign in to {idBrandLabel(brand, client.org.orgId)}</h1>
<SocialButtons
client={client}
clientIdOverride={clientIdOverride}
+6 -6
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
import { createIam } from '@hanzo/id-auth'
import { OnboardingFlow, createOnboardingService, type OnboardingState } from '@hanzo/id-onboarding'
import { getConnector } from '@hanzo/id-connect/connectors'
@@ -21,17 +21,17 @@ import { BrandHeader } from '../components/BrandHeader'
* On completion it lands on the portal home (`/`); a downstream app that
* wanted a token would have carried `redirect_uri` and never reached here.
*/
export function Onboarding({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
const iam = useMemo(() => createIam(tenant), [tenant])
export function Onboarding({ org, brand }: { org: OrgConfig; brand: BrandContract }) {
const iam = useMemo(() => createIam(org), [org])
const service = useMemo(
() =>
createOnboardingService({
iamUrl: tenant.iamUrl,
orgId: tenant.orgId,
iamUrl: org.iamUrl,
orgId: org.orgId,
getAccessToken: () => iam.getValidAccessToken(),
}),
[tenant, iam],
[org, iam],
)
function onComplete(_state: OnboardingState) {
+9 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import type { IamIdentity } from '@hanzo/iam/react'
import { UserMenu, resolveIdentity } from '@hanzo/iam/react'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
import type { AuthClient } from '@hanzo/id-auth'
import { Login } from './Login'
import { BrandHeader } from '../components/BrandHeader'
@@ -21,7 +21,7 @@ type Auth =
* - signed in → the apps launcher (the org's apps) + billing / sign-out.
*
* Auth is read same-origin from `/v1/iam/get-account` (cookie session;
* `tenant.iamUrl` is the brand's own `*.id` host, so this is first-party and
* `org.iamUrl` is the brand's own `*.id` host, so this is first-party and
* the session cookie rides along). The `?signed_in=1` marker set by the
* bare-login / onboarding-complete redirect is the authoritative "just
* authenticated" signal when the cookie read hasn't propagated yet.
@@ -29,18 +29,18 @@ type Auth =
export function Portal({
client,
brand,
tenant,
org,
}: {
client: AuthClient
brand: BrandContract
tenant: TenantConfig
org: OrgConfig
}) {
const [auth, setAuth] = useState<Auth>({ s: 'loading' })
useEffect(() => {
let alive = true
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
fetch(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
fetch(new URL('/v1/iam/get-account', org.iamUrl).toString(), {
credentials: 'include',
headers: { Accept: 'application/json' },
})
@@ -63,7 +63,7 @@ export function Portal({
return () => {
alive = false
}
}, [tenant.iamUrl])
}, [org.iamUrl])
if (auth.s === 'loading') {
return (
@@ -77,9 +77,9 @@ export function Portal({
if (auth.s === 'anon') return <Login client={client} brand={brand} />
// Signed in: the apps launcher.
const apps = appsFor(tenant.orgId)
const billingUrl = billingFor(tenant.orgId)
const logoutUrl = client.logout(undefined, `${tenant.publicOrigin}/login`)
const apps = appsFor(org.orgId)
const billingUrl = billingFor(org.orgId)
const logoutUrl = client.logout(undefined, `${org.publicOrigin}/login`)
return (
<div className="hanzo-id-page hanzo-id-portal">
+9 -9
View File
@@ -14,10 +14,10 @@
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import type { TenantConfig } from '@hanzo/id-shared'
import type { OrgConfig } from '@hanzo/id-shared'
import { createAuthClient, mfaChannelOf, MFA_TOTP } from './client.ts'
const TENANT: TenantConfig = {
const TENANT: OrgConfig = {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
iamIssuer: 'https://hanzo.id',
@@ -38,7 +38,7 @@ function mockFetch(body: unknown, calls: Call[]): typeof fetch {
test('login → RequiredMfa maps to an enroll signal (not a redirect)', async () => {
const calls: Call[] = []
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'RequiredMfa' }, calls) })
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'RequiredMfa' }, calls) })
const res = await client.login({
identifier: '[email protected]',
password: 'x',
@@ -62,7 +62,7 @@ test.each([
['mfa wins over data2', { status: 'ok', data: 'NextMfa', mfa: CHALLENGE, data2: [{ mfaType: 'email', enabled: true }] }],
])('login → NextMfa maps to a challenge signal and carries the allowed types (%s)', async (_spelling, body) => {
const calls: Call[] = []
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch(body, calls) })
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch(body, calls) })
const res = await client.login({
identifier: '[email protected]',
password: 'x',
@@ -77,7 +77,7 @@ test.each([
test('mfaInitiate puts owner/name/mfaType on the query string with an empty body', async () => {
const calls: Call[] = []
const data = { secret: 'BOUYRUSHJCEDDB33', url: 'otpauth://totp/Hanzo:x?secret=BOUYRUSHJCEDDB33', recoveryCodes: ['rc-1'] }
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data }, calls) })
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data }, calls) })
const setup = await client.mfaInitiate({ owner: 'hanzo', name: '[email protected]' })
assert.equal(setup.secret, 'BOUYRUSHJCEDDB33')
@@ -96,7 +96,7 @@ test('mfaInitiate puts owner/name/mfaType on the query string with an empty body
test('mfaVerify carries owner/name (for authz) + secret + passcode on the query', async () => {
const calls: Call[] = []
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '123456' })
assert.equal(r.ok, true)
const u = new URL(calls[0].url)
@@ -109,7 +109,7 @@ test('mfaVerify carries owner/name (for authz) + secret + passcode on the query'
test('mfaVerify surfaces an IAM error instead of throwing', async () => {
const calls: Call[] = []
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'error', msg: 'wrong passcode' }, calls) })
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'error', msg: 'wrong passcode' }, calls) })
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '000000' })
assert.equal(r.ok, false)
assert.equal(r.error, 'wrong passcode')
@@ -117,7 +117,7 @@ test('mfaVerify surfaces an IAM error instead of throwing', async () => {
test('mfaEnable echoes the recovery code back on the query', async () => {
const calls: Call[] = []
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
const r = await client.mfaEnable({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', recoveryCode: 'rc-1' })
assert.equal(r.ok, true)
const u = new URL(calls[0].url)
@@ -129,7 +129,7 @@ test('mfaEnable echoes the recovery code back on the query', async () => {
test('mfaChallenge re-POSTs /v1/iam/login with mfaType/passcode and NO username', async () => {
const calls: Call[] = []
// code flow: data is the freshly minted auth code
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'AUTHCODE' }, calls) })
const client = createAuthClient({ org: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'AUTHCODE' }, calls) })
const res = await client.mfaChallenge({
mfaType: 'app',
passcode: '654321',
+25 -25
View File
@@ -1,7 +1,7 @@
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { createAuthClient } from './client.ts'
import type { TenantConfig } from '@hanzo/id-shared'
import type { OrgConfig } 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.
@@ -47,7 +47,7 @@ function routingFetch(opts: { appOrg: string; sessionOwner: string | null; code?
return { calls, fetchImpl }
}
function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
function org(overrides: Partial<OrgConfig> = {}): OrgConfig {
return {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
@@ -77,7 +77,7 @@ function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
// and it fails with an HTTP 200 that reads like a wrong password.
test('login omits organization when the caller supplies none', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
await client.login({
identifier: '[email protected]',
@@ -102,7 +102,7 @@ test('login omits organization when the caller supplies none', async () => {
// emit "").
test('login omits organization when it is an empty string', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
await client.login({
identifier: '[email protected]',
password: 'pw',
@@ -116,7 +116,7 @@ test('login omits organization when it is an empty string', async () => {
// 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 })
const client = createAuthClient({ org: org(), fetchImpl })
await client.login({
identifier: 'someone',
password: 'pw',
@@ -132,7 +132,7 @@ test('login INCLUDES organization when one is explicitly provided', async () =>
// 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 })
const client = createAuthClient({ org: org(), fetchImpl })
await client.login({
identifier: '[email protected]',
password: 'pw',
@@ -149,7 +149,7 @@ test('app SSO (redirectUri present) uses type=code and still omits organization'
// 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 })
const client = createAuthClient({ org: org(), fetchImpl })
await client.signup({
email: '[email protected]',
password: 'pw',
@@ -165,7 +165,7 @@ test('signup STILL sends organization (unchanged — create needs a concrete org
// in a real sign-in, or the app that sent the user waits forever for a code.
test('signup COMPLETES the OIDC request — create, then sign in, then redirect back with the code', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const res = await client.signup({
email: '[email protected]',
password: 'pw',
@@ -199,7 +199,7 @@ test('signup COMPLETES the OIDC request — create, then sign in, then redirect
// the server does not read — it is what made this look like it worked.
test('signup does NOT post autoSignin (IAM has no such field)', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
await client.signup({
email: '[email protected]',
password: 'pw',
@@ -221,7 +221,7 @@ test('a refused signup surfaces the reason and never attempts a sign-in', async
headers: { 'Content-Type': 'application/json' },
})
}
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const res = await client.signup({
email: '[email protected]',
password: 'pw',
@@ -265,7 +265,7 @@ test('getAppLogin uses the nested provider record name, not the outer link label
},
],
})
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const app = await client.getAppLogin('hanzo-console')
assert.ok(app, 'app login resolved')
assert.equal(app!.providers.length, 1)
@@ -284,7 +284,7 @@ test('getAppLogin uses the nested provider record name, not the outer link label
test('providerLogin posts redirectUri from oauthCallbackOrigin (matches the hop), with the single provider + code', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({
tenant: tenant({ publicOrigin: 'https://hanzo.id', oauthCallbackOrigin: 'https://iam.hanzo.ai' }),
org: org({ publicOrigin: 'https://hanzo.id', oauthCallbackOrigin: 'https://iam.hanzo.ai' }),
fetchImpl,
})
const r = await client.providerLogin({
@@ -319,7 +319,7 @@ test('getAppLogin falls back to the outer name when no nested provider record',
organization: 'hanzo',
providers: [{ name: 'provider-google', canSignIn: true, canSignUp: true, provider: null }],
})
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const app = await client.getAppLogin('hanzo-console')
assert.ok(app)
assert.equal(app!.providers[0]!.name, 'provider-google')
@@ -332,7 +332,7 @@ test('getAppLogin falls back to the outer name when no nested provider record',
// the common case: a hanzo session signing into a hanzo app).
test('silentLogin (same-org session) mints the code and redirects, carrying NO credentials', async () => {
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: 'hanzo' })
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.silentLogin({
clientId: 'hanzo-console',
@@ -368,7 +368,7 @@ test('silentLogin (same-org session) mints the code and redirects, carrying NO c
// would confer owner=hanzo and shadow the fix). No mint leg runs; no redirect.
test('silentLogin (cross-org session) does NOT mint — falls back to the form', async () => {
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'hanzo' })
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.silentLogin({
clientId: 'hanzo-admin-guard',
@@ -386,7 +386,7 @@ test('silentLogin (cross-org session) does NOT mint — falls back to the form',
// signed in as admin/* silently re-enters the admin console.
test('silentLogin (same admin-org session) mints for the admin-guard', async () => {
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'admin' })
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.silentLogin({
clientId: 'hanzo-admin-guard',
application: 'hanzo-admin-guard',
@@ -401,7 +401,7 @@ test('silentLogin (same admin-org session) mints for the admin-guard', async ()
// falls back to the interactive form (never a dead end).
test('silentLogin returns empty (no mint) when there is no session (form fallback)', async () => {
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: null })
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.silentLogin({
clientId: 'hanzo-console',
application: 'hanzo-console',
@@ -414,11 +414,11 @@ test('silentLogin returns empty (no mint) when there is no session (form fallbac
// ── Device-authorization approval (RFC 8628) ─────────────────────────────────
// approveDevice rides the issuer SESSION (like silentLogin): NO credentials in
// the body, `type:device` + the userCode IAM keys its DeviceAuthMap on, plus the
// tenant application/organization for the app lookup. On {status:ok} the device
// org application/organization for the app lookup. On {status:ok} the device
// code is approved (UserSignIn=true) and the CLI's token poll succeeds.
test('approveDevice posts type=device + normalized userCode + tenant app/org, NO credentials', async () => {
test('approveDevice posts type=device + normalized userCode + org app/org, NO credentials', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.approveDevice('K7M4P2QH')
@@ -440,7 +440,7 @@ test('approveDevice posts type=device + normalized userCode + tenant app/org, NO
// TO uppercase so the lookup matches — case-insensitive entry, exact-match send.
test('approveDevice uppercases and strips spaces/dashes before sending', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
await client.approveDevice(' k7m4-p2qh ')
assert.equal(calls[0]!.body.userCode, 'K7M4P2QH')
})
@@ -448,7 +448,7 @@ test('approveDevice uppercases and strips spaces/dashes before sending', async (
// An empty/blank code never hits the network — fail fast with a clear message.
test('approveDevice rejects an empty code without calling fetch', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.approveDevice(' ')
assert.equal(calls.length, 0)
assert.equal(r.ok, false)
@@ -462,7 +462,7 @@ test('approveDevice surfaces the IAM error message', async () => {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.approveDevice('K7M4P2QH')
assert.equal(r.ok, false)
assert.equal(r.error, 'UserCode Expired')
@@ -476,7 +476,7 @@ test('approveDevice maps the consent-required branch to { required: true }', asy
status: 200,
headers: { 'Content-Type': 'application/json' },
})
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const r = await client.approveDevice('K7M4P2QH')
assert.equal(r.ok, false)
assert.equal(r.required, true)
@@ -497,7 +497,7 @@ test('getAppLogin sends the passed redirect_uri, and defaults to the portal call
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
// Cross-app read: the console's registered redirect_uri rides through verbatim.
await client.getAppLogin('hanzo-cloud', 'https://console.hanzo.ai/auth/callback')
+33 -33
View File
@@ -1,4 +1,4 @@
import type { TenantConfig } from '@hanzo/id-shared'
import type { OrgConfig } from '@hanzo/id-shared'
import type {
AppLogin,
AppProvider,
@@ -29,7 +29,7 @@ export function mfaChannelOf(iamType: string): MfaChannel {
*
* Stateless wrapper around the canonical IAM REST surface (paths under
* `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
* client instance per tenant. The portal creates one in `createRoot()`;
* client instance per org. The portal creates one in `createRoot()`;
* downstream pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()`
* directly.
*
@@ -46,7 +46,7 @@ export function mfaChannelOf(iamType: string): MfaChannel {
* past the redirect.
*/
export interface AuthClient {
readonly tenant: TenantConfig
readonly org: OrgConfig
login(req: LoginRequest): Promise<LoginResponse>
/**
* Silent single-sign-on: mint an authorization code from the EXISTING issuer
@@ -63,7 +63,7 @@ export interface AuthClient {
* issuer — this rides the SAME `iam_session_id` cookie as silent SSO
* (`credentials:'include'`, no credentials in the body). It POSTs
* `/v1/iam/login` with `type:'device'` + the `userCode` the device shows,
* plus the tenant's `application`/`organization`; IAM resolves the user from
* plus the org's `application`/`organization`; IAM resolves the user from
* the session, flips the device code's `UserSignIn=true`, and the CLI's token
* poll then succeeds. Returns `{required:true}` when the app needs consent
* first (rare for first-party apps), or `{error}` with the IAM message.
@@ -79,7 +79,7 @@ export interface AuthClient {
* `/v1/iam/get-app-login` — the canonical source of truth for which
* sign-in buttons (password / GitHub / Google / Web3) to render.
* Resolves to null when the endpoint is unreachable so callers can fall
* back to the tenant's declared default method set.
* back to the org's declared default method set.
*
* `redirectUri` is validated by IAM against the app's registered list. For a
* cross-app SSO read (e.g. console → hanzo.id, `clientId=hanzo-cloud`) pass the
@@ -141,18 +141,18 @@ export interface ProviderExchangeRequest {
}
export interface AuthClientOptions {
readonly tenant: TenantConfig
readonly org: OrgConfig
/** Override fetch impl (testing). Defaults to global fetch. */
readonly fetchImpl?: typeof fetch
}
export function createAuthClient(opts: AuthClientOptions): AuthClient {
const tenant = opts.tenant
const org = opts.org
const f = opts.fetchImpl ?? fetch
async function login(req: LoginRequest): Promise<LoginResponse> {
const type = req.redirectUri ? 'code' : 'login'
const url = new URL('/v1/iam/login', tenant.iamUrl)
const url = new URL('/v1/iam/login', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('responseType', 'code')
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
@@ -197,7 +197,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
// to a DIFFERENT org than the app being signed into.
async function sessionOwner(): Promise<string | null> {
try {
const res = await f(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
const res = await f(new URL('/v1/iam/get-account', org.iamUrl).toString(), {
credentials: 'include',
headers: { Accept: 'application/json' },
})
@@ -228,7 +228,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
const appOrg = app?.organization
if (appOrg && owner !== appOrg) return {}
const url = new URL('/v1/iam/login', tenant.iamUrl)
const url = new URL('/v1/iam/login', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('responseType', 'code')
url.searchParams.set('redirectUri', req.redirectUri)
@@ -258,7 +258,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
async function approveDevice(userCode: string): Promise<DeviceApprovalResult> {
const code = normalizeUserCode(userCode)
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
const url = new URL('/v1/iam/login', tenant.iamUrl)
const url = new URL('/v1/iam/login', org.iamUrl)
// IAM's device branch keys the cache off the `userCode` in the BODY; the
// `type` echo on the query mirrors the other login legs. NO credentials —
// the user is already signed in, so this rides the session cookie
@@ -267,12 +267,12 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
const body: Record<string, unknown> = {
type: 'device',
userCode: code,
application: tenant.appName,
application: org.appName,
}
// `organization` scopes the application lookup (FindApplicationByName); it
// does NOT resolve the user (that comes from the session), so pinning the
// tenant org here is safe — unlike password login, which omits it.
if (tenant.orgId) body.organization = tenant.orgId
// org org here is safe — unlike password login, which omits it.
if (org.orgId) body.organization = org.orgId
let res: Response
try {
res = await f(url.toString(), {
@@ -303,7 +303,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
}
async function signup(req: SignupRequest): Promise<LoginResponse> {
const url = new URL('/v1/iam/signup', tenant.iamUrl)
const url = new URL('/v1/iam/signup', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
const username = req.email.split('@')[0]
const res = await f(url.toString(), {
@@ -352,14 +352,14 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
}
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
const url = new URL('/v1/iam/send-verification-code', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('organization', req.organization)
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
applicationId: `admin/${tenant.appName}`,
applicationId: `admin/${org.appName}`,
organization: req.organization,
dest: req.identifier,
type: req.identifier.includes('@') ? 'email' : 'phone',
@@ -374,7 +374,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
}
function authorize(req: OAuthAuthorizeRequest): string {
const url = new URL('/v1/iam/oauth/authorize', tenant.iamUrl)
const url = new URL('/v1/iam/oauth/authorize', org.iamUrl)
url.searchParams.set('client_id', req.clientId)
url.searchParams.set('redirect_uri', req.redirectUri)
url.searchParams.set('response_type', req.responseType ?? 'code')
@@ -389,12 +389,12 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
}
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
const url = new URL('/v1/iam/oauth/token', tenant.iamUrl)
const url = new URL('/v1/iam/oauth/token', org.iamUrl)
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: tenant.clientId,
redirect_uri: `${tenant.publicOrigin}/callback`,
client_id: org.clientId,
redirect_uri: `${org.publicOrigin}/callback`,
})
if (codeVerifier) body.set('code_verifier', codeVerifier)
const res = await f(url.toString(), {
@@ -415,25 +415,25 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
}
function logout(idTokenHint?: string, postLogoutRedirectUri?: string): string {
const url = new URL('/v1/iam/oauth/logout', tenant.iamUrl)
const url = new URL('/v1/iam/oauth/logout', org.iamUrl)
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint)
url.searchParams.set(
'post_logout_redirect_uri',
postLogoutRedirectUri ?? `${tenant.publicOrigin}/login`,
postLogoutRedirectUri ?? `${org.publicOrigin}/login`,
)
return url.toString()
}
async function getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null> {
const id = clientId ?? tenant.clientId
const url = new URL('/v1/iam/get-app-login', tenant.iamUrl)
const id = clientId ?? org.clientId
const url = new URL('/v1/iam/get-app-login', org.iamUrl)
url.searchParams.set('clientId', id)
url.searchParams.set('responseType', 'code')
// Validate against the downstream app's OWN redirect_uri when the caller has
// one (the SSO authorize flow carries it); the portal's own /callback is not
// registered for another app, so IAM would reject the read and we'd surface
// no social buttons. Fall back to the portal callback for a bare/own read.
url.searchParams.set('redirectUri', redirectUri || `${tenant.publicOrigin}/callback`)
url.searchParams.set('redirectUri', redirectUri || `${org.publicOrigin}/callback`)
url.searchParams.set('scope', 'openid profile email')
url.searchParams.set('state', 'app-login')
let body: Record<string, unknown>
@@ -445,7 +445,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
return null
}
if (body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) return null
return parseAppLogin(body.data as Record<string, unknown>, tenant.appName, tenant.orgId)
return parseAppLogin(body.data as Record<string, unknown>, org.appName, org.orgId)
}
async function providerLogin(
@@ -463,7 +463,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
const appRedirectUri = oidc.get('redirect_uri') ?? ''
const appState = oidc.get('state') ?? ''
const url = new URL('/v1/iam/login', tenant.iamUrl)
const url = new URL('/v1/iam/login', org.iamUrl)
// OIDC params ride the QUERY — IAM's HandleLoggedIn reads them there first
// when minting the code. Forward exactly the app's request so the code
// carries its client_id, scope, nonce and — load-bearing — its
@@ -485,7 +485,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
// brand portals) — or the provider rejects the exchange `invalid_grant`.
// This is a DIFFERENT redirect_uri from the app's above: one drives the
// provider exchange (body), one binds the minted app code (query).
const callbackOrigin = tenant.oauthCallbackOrigin ?? tenant.publicOrigin
const callbackOrigin = org.oauthCallbackOrigin ?? org.publicOrigin
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -526,7 +526,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
}
async function getAccount(): Promise<MfaIdentity | null> {
const url = new URL('/v1/iam/get-account', tenant.iamUrl)
const url = new URL('/v1/iam/get-account', org.iamUrl)
let body: Record<string, unknown>
try {
const res = await f(url.toString(), { headers: { Accept: 'application/json' }, credentials: 'include' })
@@ -552,7 +552,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
* otherwise carries no identity — purely so that self-access check passes.
*/
async function mfaSetupPost(path: string, params: Record<string, string>): Promise<Record<string, unknown>> {
const url = new URL(`/v1/iam/mfa/setup/${path}`, tenant.iamUrl)
const url = new URL(`/v1/iam/mfa/setup/${path}`, org.iamUrl)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
const res = await f(url.toString(), { method: 'POST', credentials: 'include' })
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
@@ -603,7 +603,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
async function mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse> {
const type = req.redirectUri ? 'code' : 'login'
const url = new URL('/v1/iam/login', tenant.iamUrl)
const url = new URL('/v1/iam/login', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('responseType', 'code')
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
@@ -633,7 +633,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
}
return {
tenant,
org,
login,
silentLogin,
approveDevice,
+6 -6
View File
@@ -1,8 +1,8 @@
import type { TenantConfig } from '@hanzo/id-shared'
import type { OrgConfig } from '@hanzo/id-shared'
import { IAM } from '@hanzo/iam/browser'
/**
* One IAM browser-SDK instance per tenant, wired to the portal's own
* One IAM browser-SDK instance per org, wired to the portal's own
* `/callback` route. This is the single place that constructs the PKCE
* client — social/web3 sign-in (here) and the callback handler
* (`Callback.tsx`) share it so the PKCE verifier/state the SDK stores on
@@ -11,11 +11,11 @@ import { IAM } from '@hanzo/iam/browser'
* The portal is its own OIDC client (`clientId` = the brand `-id` app), so
* every flow it initiates lands back at `${publicOrigin}/callback`.
*/
export function createIam(tenant: TenantConfig, clientId?: string): IAM {
export function createIam(org: OrgConfig, clientId?: string): IAM {
return new IAM({
serverUrl: tenant.iamUrl,
clientId: clientId ?? tenant.clientId,
redirectUri: `${tenant.publicOrigin}/callback`,
serverUrl: org.iamUrl,
clientId: clientId ?? org.clientId,
redirectUri: `${org.publicOrigin}/callback`,
scope: 'openid profile email',
})
}
+2 -2
View File
@@ -17,7 +17,7 @@ export interface LoginRequest {
* 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
* org. Set it only to FORCE a specific org (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").
*/
@@ -153,7 +153,7 @@ export interface SignupRequest {
readonly clientId: string
readonly application: string
/**
* The tenant to create the user in. REQUIRED — unlike login's optional
* The org to create the user in. REQUIRED — unlike login's optional
* lookup hint, you cannot create a user in "no org", and IAM gates this
* against the application's own org.
*/
+2 -2
View File
@@ -20,8 +20,8 @@ export function ForgotForm(props: ForgotFormProps) {
try {
const res = await client.forgot({
identifier,
clientId: client.tenant.clientId,
organization: client.tenant.orgId,
clientId: client.org.clientId,
organization: client.org.orgId,
})
if (!res.ok) setError(res.error ?? 'send failed')
else {
+4 -4
View File
@@ -63,13 +63,13 @@ export function LoginForm(props: LoginFormProps) {
// override path already does, and what reaches admin/* for admin-org apps).
// A global admin is no longer resolved by omission; they reach the admin
// identity by signing into an admin-org app, which is the explicit path.
const app = await client.getAppLogin(props.clientIdOverride ?? client.tenant.clientId)
const application = app?.application ?? client.tenant.appName
const organization = app?.organization ?? client.tenant.loginOrg
const app = await client.getAppLogin(props.clientIdOverride ?? client.org.clientId)
const application = app?.application ?? client.org.appName
const organization = app?.organization ?? client.org.loginOrg
const res = await client.login({
identifier,
password,
clientId: props.clientIdOverride ?? client.tenant.clientId,
clientId: props.clientIdOverride ?? client.org.clientId,
application,
organization,
redirectUri: props.redirectUri,
+2 -2
View File
@@ -50,13 +50,13 @@ export function ProviderButtons(props: ProviderButtonsProps) {
)
if (usable.length === 0) return null
const redirectUri = props.redirectUri ?? `${client.tenant.publicOrigin}/callback`
const redirectUri = props.redirectUri ?? `${client.org.publicOrigin}/callback`
return (
<div className="hanzo-id-providers">
{usable.map((p) => {
const m = metaFor(p)
const href = client.authorize({
clientId: props.clientIdOverride ?? client.tenant.clientId,
clientId: props.clientIdOverride ?? client.org.clientId,
redirectUri,
state: props.state ?? mode,
provider: p.name,
+4 -4
View File
@@ -31,12 +31,12 @@ export function SignupForm(props: SignupFormProps) {
try {
// Register against the app the user CAME FROM, not this portal. IAM's
// signup resolves the application by clientId and then gates the org
// against that app's own tenant, so a downstream `client_id` must reach
// against that app's own org, so a downstream `client_id` must reach
// it or the account is created under the portal's app instead.
const clientId = props.clientIdOverride ?? client.tenant.clientId
const clientId = props.clientIdOverride ?? client.org.clientId
const app = await client.getAppLogin(clientId, props.redirectUri)
const application = app?.application ?? client.tenant.appName
const organization = app?.organization ?? client.tenant.orgId
const application = app?.application ?? client.org.appName
const organization = app?.organization ?? client.org.orgId
const session = await client.signup({
email,
+1 -1
View File
@@ -132,7 +132,7 @@ export function SocialButtons({
// The shared OAuth client is registered against the IAM backend's
// /callback (not this brand host), so the hop must return there or the
// provider rejects the redirect_uri. Catalog-driven; defaults to host.
client.tenant.oauthCallbackOrigin,
client.org.oauthCallbackOrigin,
)
}
+7 -7
View File
@@ -18,9 +18,9 @@ import {
type WalletSigner,
} from './web3.ts'
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
import type { TenantConfig } from '@hanzo/id-shared'
import type { OrgConfig } from '@hanzo/id-shared'
function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
function org(overrides: Partial<OrgConfig> = {}): OrgConfig {
return {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
@@ -91,7 +91,7 @@ function fakeSigner() {
test('fetches the nonce for the chosen chain, signs the returned challenge, POSTs the proof', async () => {
const { calls, fetchImpl } = capturingFetch()
const { seen, sign } = fakeSigner()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
@@ -133,7 +133,7 @@ test('fetches the nonce for the chosen chain, signs the returned challenge, POST
test('SSO flow (downstream redirectUri) sends type=code and returns the app redirect with the minted code', async () => {
const { calls, fetchImpl } = capturingFetch('CODE_XYZ')
const { sign } = fakeSigner()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(
client,
@@ -171,7 +171,7 @@ test('disabled chains are not offered and fail closed without any network or sig
signed = true
throw new Error('signer must not run for a disabled chain')
}
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(client, 'ton', {}, fetchImpl, sign)
assert.match(res.error ?? '', /not enabled/)
assert.equal(calls.length, 0)
@@ -183,7 +183,7 @@ test('a wallet rejection surfaces as { error } (not a throw), and verify is neve
const sign: WalletSigner = async () => {
throw new Error('User rejected the request')
}
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
assert.equal(res.error, 'User rejected the request')
// nonce was fetched (1 call) but verify was NOT (no 2nd call).
@@ -227,7 +227,7 @@ test('an IAM verify error is returned as { error }', async () => {
return new Response(JSON.stringify({ status: 'error', msg: 'web3: bad signature' }), { status: 200 })
}
const { sign } = fakeSigner()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const client = createAuthClient({ org: org(), fetchImpl })
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
assert.equal(res.error, 'web3: bad signature')
assert.equal(res.redirectUrl, undefined)
+11 -11
View File
@@ -17,7 +17,7 @@
* POST {iamUrl}/v1/iam/web3/verify body = SignedProof + routing fields
* → same success shape as /v1/iam/login (auth code | session cookie).
*/
import type { TenantConfig } from '@hanzo/id-shared'
import type { OrgConfig } from '@hanzo/id-shared'
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
import type { AuthClient } from './client'
import type { LoginResponse } from './types'
@@ -96,7 +96,7 @@ export function detectWalletChains(
/** Routing context for the verify POST — exactly what the password flow carries. */
export interface WalletLoginContext {
/** Override the OAuth client_id (downstream app); defaults to tenant.clientId. */
/** Override the OAuth client_id (downstream app); defaults to org.clientId. */
readonly clientId?: string
/** Downstream app `redirect_uri`; presence flips the flow to the auth-code (SSO) path. */
readonly redirectUri?: string
@@ -114,7 +114,7 @@ export interface WalletLoginContext {
* the UI can't act on; expected failures (user rejects, bad signature) come back
* as `{ error }`.
*
* `client.tenant.iamUrl` is the fetch base (HIP-0111 host-relative — the brand's
* `client.org.iamUrl` is the fetch base (HIP-0111 host-relative — the brand's
* own `*.id` host), matching every other AuthClient call.
*/
export async function loginWithWalletChain(
@@ -124,7 +124,7 @@ export async function loginWithWalletChain(
fetchImpl: typeof fetch = fetch,
sign: WalletSigner = defaultSigner,
): Promise<LoginResponse> {
const tenant = client.tenant
const org = client.org
if (!ENABLED_WALLET_CHAINS.includes(chain)) {
return { error: `wallet login not enabled for ${chain}` }
}
@@ -135,7 +135,7 @@ export async function loginWithWalletChain(
// from the SIGNED message, so there is no second round-trip to scope it.
let proof: SignedProof
try {
const challenge = await fetchNonce(tenant, chain, fetchImpl)
const challenge = await fetchNonce(org, chain, fetchImpl)
proof = await sign(chain, challenge)
} catch (err) {
return { error: errMessage(err) }
@@ -143,17 +143,17 @@ export async function loginWithWalletChain(
// 2. Verify the proof + routing at IAM. Type defaults to "login" (session
// cookie) server-side; a downstream redirectUri makes it the code flow.
const url = new URL('/v1/iam/web3/verify', tenant.iamUrl)
const url = new URL('/v1/iam/web3/verify', org.iamUrl)
const res = await fetchImpl(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
// routing
organization: tenant.loginOrg ?? '',
application: tenant.appName,
organization: org.loginOrg ?? '',
application: org.appName,
method: 'login',
clientId: ctx.clientId ?? tenant.clientId,
clientId: ctx.clientId ?? org.clientId,
redirectUri: ctx.redirectUri ?? '',
state: ctx.state ?? '',
scope: 'openid profile email',
@@ -177,11 +177,11 @@ export async function loginWithWalletChain(
/** GET the CAIP-122 challenge for (chain) from IAM; throws on a non-ok payload. */
async function fetchNonce(
tenant: TenantConfig,
org: OrgConfig,
chain: Chain,
fetchImpl: typeof fetch,
): Promise<LoginChallenge> {
const url = new URL('/v1/iam/web3/nonce', tenant.iamUrl)
const url = new URL('/v1/iam/web3/nonce', org.iamUrl)
url.searchParams.set('chain', chain)
const res = await fetchImpl(url.toString(), { headers: { Accept: 'application/json' } })
let body: Record<string, unknown> = {}
+1 -1
View File
@@ -30,7 +30,7 @@ export type IDVStatus =
export interface IDVSubject {
/** Stable subject identifier (typically the IAM user id). */
readonly subjectId: string
/** Tenant org slug (for multi-tenant providers). */
/** Org org slug (for multi-org providers). */
readonly orgId: string
/** Email + display name carried through for vendor pre-fill. */
readonly email?: string
+2 -2
View File
@@ -19,7 +19,7 @@
* FIRST org is refused there by construction (403), and with no bearer at all the
* Guard refuses before that (401). `onboard` is the door built for this: it
* resolves the caller from their own session or bearer and provisions the whole
* tenant — org stamped with them as Founder, them moved in as its owner, one
* org — org stamped with them as Founder, them moved in as its owner, one
* metered API key — under their own authority as its founder.
*
* Both credentials are offered on every call: `credentials: 'include'` for the
@@ -54,7 +54,7 @@ export interface OnboardingService {
}
export interface OnboardingServiceOptions {
/** IAM origin, no trailing slash (the tenant's `iamUrl`, i.e. hanzo.id). */
/** IAM origin, no trailing slash (the org's `iamUrl`, i.e. hanzo.id). */
readonly iamUrl: string
/** Owning org slug used as the default `owner` for new records. */
readonly orgId: string
+3 -3
View File
@@ -24,7 +24,7 @@ import type { OnboardingService } from '../service/onboarding'
*/
export interface OnboardingFlowProps {
readonly service: OnboardingService
/** Brand display name for headings (e.g. the resolved tenant brand). */
/** Brand display name for headings (e.g. the resolved org brand). */
readonly brandName: string
/**
* Host-supplied wallet connector. Returns the connected address (0x…) or
@@ -153,9 +153,9 @@ function OrgStep({
onNext({ orgName: res.value.name, orgCreated: true })
}
// Onboarding never lists other tenants' organizations — a brand-new user only
// Onboarding never lists other orgs' organizations — a brand-new user only
// ever creates their own org or skips. Listing the org directory would leak
// every tenant's name to anyone who signs up. Joining an existing org happens
// every org's name to anyone who signs up. Joining an existing org happens
// by invitation, handled outside this flow.
return (
<div className="hanzo-id-onboarding-body">
+2 -2
View File
@@ -1,14 +1,14 @@
{
"name": "@hanzo/id-shared",
"version": "0.1.1",
"description": "Shared types + tenant resolver for the Hanzo ID portal. No UI deps.",
"description": "Shared types + org resolver for the Hanzo ID portal. No UI deps.",
"license": "BSD-3-Clause",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./tenant": "./src/tenant.ts",
"./org": "./src/org.ts",
"./brand": "./src/brand.ts",
"./package.json": "./package.json"
},
+3 -3
View File
@@ -1,7 +1,7 @@
import type { BrandContract } from './types'
/**
* Resolve a BrandContract from a tenant's brand package.
* Resolve a BrandContract from a org's brand package.
*
* Each per-org brand pkg (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
* `@parsdao/brand`) ships a `brand.json` at the package root. This loader
@@ -50,7 +50,7 @@ export async function loadBrand(brandPackage: string): Promise<BrandContract> {
* Last-resort brand when the asset is unreachable after retries. Keeps the
* login form usable (a generic heading) instead of blanking the page. The
* display name is derived from the pkg scope (`@hanzo/brand` -> "Hanzo"); the
* few tenants whose scope differs from their display name are mapped.
* few orgs whose scope differs from their display name are mapped.
*/
function fallbackBrand(brandPackage: string): BrandContract {
const scope = brandPackage.replace(/^@/, '').split('/')[0] ?? 'hanzo'
@@ -80,7 +80,7 @@ export interface BrandRuntime {
* Neutral identity-portal label — always "<Brand> ID". `BrandContract.name`
* is meant to be the bare org display ("Lux"), but some brand packages ship
* the product name ("Lux Exchange" / "Zoo Exchange"), which leaks a sibling
* surface into the IAM portal heading + tab title. Prefer the tenant orgId
* surface into the IAM portal heading + tab title. Prefer the org orgId
* ("lux" → "Lux"); otherwise strip a trailing product word from the brand
* name. So id.lux.network reads "Lux ID", never "Lux Exchange".
*/
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './tenant'
export * from './org'
export * from './brand'
export * from './types'
@@ -1,5 +1,5 @@
/**
* Tenant-resolver tests — run with the Node built-in runner + native TS strip:
* Org-resolver tests — run with the Node built-in runner + native TS strip:
*
* pnpm --filter @hanzo/id-shared test
*
@@ -10,7 +10,7 @@
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { resolveTenant, parseCatalog } from './tenant.ts'
import { resolveOrg, parseCatalog } from './org.ts'
// Mirrors the K8s ConfigMap shape: entries carry `brandUrl`, not `brandPackage`.
const CATALOG = {
@@ -36,14 +36,14 @@ const CATALOG = {
}
test('a built-in host resolves to its own brand with no catalog', () => {
const t = resolveTenant('hanzo.id')
const t = resolveOrg('hanzo.id')
assert.equal(t.orgId, 'hanzo')
assert.equal(t.brandPackage, '@hanzo/brand')
assert.equal(t.iamUrl, 'https://hanzo.id')
})
test('a catalog entry overrides clientId/appName but keeps a consistent brand', () => {
const t = resolveTenant('lux.id', { catalog: CATALOG })
const t = resolveOrg('lux.id', { catalog: CATALOG })
assert.equal(t.orgId, 'lux')
assert.equal(t.clientId, 'lux-cloud')
assert.equal(t.brandPackage, '@luxfi/brand')
@@ -52,7 +52,7 @@ test('a catalog entry overrides clientId/appName but keeps a consistent brand',
})
test('a catalog-ONLY host does NOT leak the Hanzo brand (osage.id regression)', () => {
const t = resolveTenant('osage.id', { catalog: CATALOG })
const t = resolveOrg('osage.id', { catalog: CATALOG })
assert.equal(t.orgId, 'osage')
assert.equal(t.clientId, 'osage-id-portal')
// brandUrl is mapped onto brandPackage, and it is NOT Hanzo's.
@@ -65,7 +65,7 @@ test('a catalog-ONLY host does NOT leak the Hanzo brand (osage.id regression)',
})
test('a catalog host with NO brandUrl still does not leak Hanzo (id.bootno.de)', () => {
const t = resolveTenant('id.bootno.de', { catalog: CATALOG })
const t = resolveOrg('id.bootno.de', { catalog: CATALOG })
assert.equal(t.orgId, 'bootnode')
assert.equal(t.clientId, 'bootnode-platform')
// The interesting case: no brandUrl at all. It must resolve EMPTY so the
@@ -81,7 +81,7 @@ test('a catalog host with NO brandUrl still does not leak Hanzo (id.bootno.de)',
})
test('an unknown host FAILS CLOSED — it never inherits another brand', () => {
const t = resolveTenant('totally-unregistered.example', { catalog: CATALOG })
const t = resolveOrg('totally-unregistered.example', { catalog: CATALOG })
// Was: DEFAULT_TENANTS['hanzo.id'] — orgId hanzo, @hanzo/brand, and
// iamUrl https://hanzo.id. Empty clientId means the portal refuses rather
// than authenticating as some other brand's IAM application.
@@ -105,7 +105,7 @@ test('a catalog host with a FAILED catalog fetch does not leak Hanzo', () => {
'id.pars.network',
'id.bootno.de',
]) {
const t = resolveTenant(host) // no catalog — the fetch failed
const t = resolveOrg(host) // no catalog — the fetch failed
assert.notEqual(t.orgId, 'hanzo', `${host} leaked orgId hanzo`)
assert.notEqual(t.brandPackage, '@hanzo/brand', `${host} leaked the Hanzo mark`)
assert.notEqual(t.iamUrl, 'https://hanzo.id', `${host} would post credentials at hanzo.id`)
@@ -115,19 +115,19 @@ test('a catalog host with a FAILED catalog fetch does not leak Hanzo', () => {
})
test('zoo.id is gone — it is NXDOMAIN and must not be a built-in', () => {
const t = resolveTenant('zoo.id')
const t = resolveOrg('zoo.id')
assert.equal(t.orgId, '')
assert.equal(t.clientId, '')
})
test('pars built-in uses the working pars-console portal app (not the missing pars-id)', () => {
const t = resolveTenant('pars.id')
const t = resolveOrg('pars.id')
assert.equal(t.clientId, 'pars-console')
assert.equal(t.brandPackage, '@parsdao/brand')
})
test('osage built-in resolves to Osage even with NO catalog (fallback safety)', () => {
const t = resolveTenant('osage.id')
const t = resolveOrg('osage.id')
assert.equal(t.orgId, 'osage')
assert.equal(t.brandPackage, '@osage/brand')
assert.notEqual(t.brandPackage, '@hanzo/brand')
@@ -135,7 +135,7 @@ test('osage built-in resolves to Osage even with NO catalog (fallback safety)',
})
test('an unknown host keeps its own origin AND does not inherit an org', () => {
const t = resolveTenant('preview.example.com')
const t = resolveOrg('preview.example.com')
// This assertion used to be `orgId === 'hanzo'` — it pinned the cross-brand
// fallback as intended behaviour. Keeping its own origin is right; being
// handed Hanzo's org, mark and issuer is the defect that shipped behind it.
@@ -1,7 +1,7 @@
import type { TenantConfig } from './types'
import type { OrgConfig } from './types'
/**
* Resolve a TenantConfig by hostname.
* Resolve a OrgConfig by hostname.
*
* Resolution order (first hit wins):
* 1. `IAM_TENANT_CONFIG_JSON` runtime catalog (set in K8s ConfigMap, served
@@ -14,21 +14,21 @@ import type { TenantConfig } from './types'
* visitor on one brand's host being shown another brand's login and posting
* credentials there.
*
* No hardcoded hostname switches anywhere downstream. Adding a tenant
* No hardcoded hostname switches anywhere downstream. Adding a org
* means editing the runtime catalog, never editing source.
*/
const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
/**
* Built-in tenants for the four canonical identity hosts.
* Built-in orgs for the four canonical identity hosts.
*
* `iamUrl` is the per-brand OIDC ISSUER — the host that serves
* `/.well-known/openid-configuration` and the `/v1/iam/*` surface. Per
* HIP-0111 this is the brand's own `*.id` host (hanzo.id / lux.id / …),
* NOT `iam.hanzo.ai`: discovery must be host-relative so the SDK never
* resolves to the wrong origin (or the IAM SPA HTML catch-all). The IAM
* backend tenant-scopes on the `organization` body param; one backend
* backend org-scopes on the `organization` body param; one backend
* serves every brand behind its own issuer host.
*
* `clientId` is the brand `-id` app registered in `init_data.json`
@@ -36,7 +36,7 @@ const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
* same app whose enabled providers (password + GitHub + Google + Web3)
* `get-app-login` reports.
*/
const DEFAULT_TENANTS: Record<string, TenantConfig> = {
const DEFAULT_TENANTS: Record<string, OrgConfig> = {
'hanzo.id': {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
@@ -96,7 +96,7 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
* this module maps onto the code-facing `brandPackage`. All fields optional;
* whatever is present overrides the host-derived base.
*/
export type CatalogEntry = Partial<TenantConfig> & {
export type CatalogEntry = Partial<OrgConfig> & {
/** CDN URL of the brand package, e.g. `…/npm/@osage/brand@latest/brand.json`. */
readonly brandUrl?: string
}
@@ -106,19 +106,19 @@ export interface ResolveOptions {
readonly catalog?: Record<string, CatalogEntry>
}
export function resolveTenant(hostname: string, opts: ResolveOptions = {}): TenantConfig {
export function resolveOrg(hostname: string, opts: ResolveOptions = {}): OrgConfig {
const host = stripPort(hostname).toLowerCase()
const catalogEntry = opts.catalog?.[host]
const builtIn = DEFAULT_TENANTS[host]
if (catalogEntry || builtIn) {
// Base = the built-in tenant if one exists, else a skeleton derived from
// Base = the built-in org if one exists, else a skeleton derived from
// THIS host. Never another brand's config: a catalog-only host (osage.id,
// zoolabs.id) must not inherit Hanzo's issuer or brand package.
const base = builtIn ?? hostSkeleton(host)
const merged: TenantConfig = { ...base, ...fromCatalog(catalogEntry) } as TenantConfig
const merged: OrgConfig = { ...base, ...fromCatalog(catalogEntry) } as OrgConfig
return normalize(merged)
}
// Unknown host → derive from the host ITSELF. Never another brand's tenant.
// Unknown host → derive from the host ITSELF. Never another brand's org.
//
// This used to return DEFAULT_TENANTS[`${defaultOrg}.id`], i.e. Hanzo's. Eight
// real hosts have no built-in entry and live only in the runtime catalog —
@@ -133,18 +133,18 @@ export function resolveTenant(hostname: string, opts: ResolveOptions = {}): Tena
//
// The skeleton carries an empty clientId, so the portal fails closed rather
// than silently authenticating as some other brand's IAM application. A login
// page that cannot resolve its tenant must refuse, not guess.
// page that cannot resolve its org must refuse, not guess.
return normalize(hostSkeleton(host))
}
/**
* A host-derived tenant skeleton for a catalog-only host (no built-in entry).
* A host-derived org skeleton for a catalog-only host (no built-in entry).
* URLs point at the host itself so nothing leaks from another brand; the
* catalog entry spread over this supplies orgId / clientId / appName /
* brandPackage. brandPackage defaults empty → the brand loader falls back to a
* neutral wordmark rather than showing the wrong brand.
*/
function hostSkeleton(host: string): TenantConfig {
function hostSkeleton(host: string): OrgConfig {
return {
orgId: '',
iamUrl: `https://${host}`,
@@ -157,12 +157,12 @@ function hostSkeleton(host: string): TenantConfig {
}
/**
* Project a catalog entry onto a TenantConfig patch, mapping `brandUrl` →
* Project a catalog entry onto a OrgConfig patch, mapping `brandUrl` →
* `brandPackage` (the code-facing field) when an explicit `brandPackage` isn't
* given. Only defined string fields are emitted, so the host-derived base shows
* through for anything the entry omits.
*/
function fromCatalog(entry: CatalogEntry | undefined): Partial<TenantConfig> {
function fromCatalog(entry: CatalogEntry | undefined): Partial<OrgConfig> {
if (!entry) return {}
const out: Record<string, string> = {}
for (const k of ['orgId', 'loginOrg', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage'] as const) {
@@ -173,7 +173,7 @@ function fromCatalog(entry: CatalogEntry | undefined): Partial<TenantConfig> {
const pkg = brandPackageFromUrl(entry.brandUrl)
if (pkg) out.brandPackage = pkg
}
return out as Partial<TenantConfig>
return out as Partial<OrgConfig>
}
/**
@@ -189,7 +189,7 @@ function stripPort(h: string): string {
return h.replace(/:\d+$/, '')
}
function normalize(t: TenantConfig): TenantConfig {
function normalize(t: OrgConfig): OrgConfig {
const publicOrigin = TRIM_TRAILING_SLASH(t.publicOrigin)
return {
...t,
@@ -204,7 +204,7 @@ function normalize(t: TenantConfig): TenantConfig {
}
/** Parse the runtime catalog JSON safely; returns {} on any error. */
export function parseCatalog(raw: string | undefined | null): Record<string, Partial<TenantConfig>> {
export function parseCatalog(raw: string | undefined | null): Record<string, Partial<OrgConfig>> {
if (!raw) return {}
try {
const parsed = JSON.parse(raw)
+5 -5
View File
@@ -1,12 +1,12 @@
/**
* Per-tenant configuration resolved at runtime.
* Per-org configuration resolved at runtime.
*
* One image, many hosts. The portal resolves a TenantConfig for each
* One image, many hosts. The portal resolves a OrgConfig for each
* incoming request by hostname; the IAM backend, OAuth client id, and
* brand package are all wired from this single object.
*/
export interface TenantConfig {
/** Tenant org slug (matches the JWT `owner` claim and the IAM `<org>-<app>` namespace). */
export interface OrgConfig {
/** Org 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)
@@ -16,7 +16,7 @@ export interface TenantConfig {
* 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
* one org. Does NOT affect signup (which always targets `orgId`) or the
* apps launcher (which is brand-scoped by `orgId`).
*/
readonly loginOrg?: string