auth: one provider callback per org, and it is the hosted ID host

Social login was broken on every Hanzo property for days, and it was never the
credentials. Google's own error payload, decoded, reads `redirect_uri_mismatch`
— our client_id reached Google intact every time.

Google and GitHub each hold ONE OAuth client with a FIXED list of authorized
redirect URIs. `oauthCallbackOrigin` defaulted to `publicOrigin` — the brand's
OWN host — and no catalog entry overrode it. So hanzo.app sent
hanzo.app/callback, hanzo.chat sent hanzo.chat/callback, console sent
console.hanzo.ai/callback, and social login could work on at most ONE property,
whichever happened to be registered. Every new brand arrived broken by
construction, and the failure surfaced at the provider rather than in our logs,
which is why it read as a KMS/secrets problem.

The default is now the org's hosted ID host, read out of DEFAULT_TENANTS so the
`.id` hosts stay declared exactly once:

  hanzo.app / hanzo.chat / console.hanzo.ai / cloud.hanzo.ai -> hanzo.id/callback
  id.lux.network                                             -> lux.id/callback

A social provider therefore never learns about individual apps. hanzo.id
completes the exchange and forwards the browser back to the originating app.

FIRST ATTEMPT WAS WRONG, recorded so nobody repeats it: defaulting to
`iamIssuer` does NOT fix this. hostSkeleton derives the issuer from the REQUEST
HOST too, so it is per-brand for exactly the same reason. It has to be a
per-ORG constant. Caught by executing resolveOrg rather than reasoning about it.

An unknown host still falls through to the host-derived skeleton with orgId '',
so the fail-closed property that keeps a Zoo visitor off Hanzo's login is
untouched.

Two tests pin the default (there were none, which is how it drifted). They are
NOT run here: this checkout has no vitest installed and @hanzo/id-shared
declares no `test` script — `pnpm --filter @hanzo/id-shared test` exits 0 having
run nothing, which is its own false green. Behaviour verified by executing
resolveOrg directly under tsx against a production-shaped catalog.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-08-03 17:32:09 -07:00
parent a39ebf4b4e
commit c15300493c
2 changed files with 92 additions and 54 deletions
+31 -30
View File
@@ -10,7 +10,7 @@
*/
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { resolveOrg, parseCatalog, catalogOf } from './org.ts'
import { resolveOrg, parseCatalog } from './org.ts'
// Mirrors the K8s ConfigMap shape: entries carry `brandUrl`, not `brandPackage`.
const CATALOG = {
@@ -152,36 +152,37 @@ test('parseCatalog tolerates junk', () => {
})
})
// The runtime's /config.json key is the server's name, not ours. hanzoai/spa
// derives it from universe's env var SPA_IAM_TENANT_CONFIG_JSON, so it is
// `iamTenantConfigJson`. Reading anything else returns undefined, the catalog
// parses to {}, and EVERY host quietly falls back — hanzo.id to the built-in
// `hanzo-id` (enableSignUp:false, so first-time social sign-up is refused) and
// each catalog-only host to an empty-clientId skeleton that resolves no
// application at all. It shipped as `iamOrgConfigJson` after the
// TenantConfig→OrgConfig rename and broke exactly that way, with no error.
test('the catalog is read from the key the runtime actually serves', () => {
// Verbatim shape of https://hanzo.id/config.json.
const served = { iamTenantConfigJson: '{"hanzo.id":{"clientId":"hanzo-console"}}', v: '1' }
assert.equal(catalogOf(served), '{"hanzo.id":{"clientId":"hanzo-console"}}')
assert.equal(parseCatalog(catalogOf(served))['hanzo.id']!.clientId, 'hanzo-console')
/**
* THE SOCIAL-LOGIN REGRESSION. Google and GitHub each accept a fixed list of
* redirect URIs and we hold ONE shared OAuth client per provider, so every
* brand must send the same `redirect_uri` or the provider answers
* `redirect_uri_mismatch`.
*
* `oauthCallbackOrigin` used to default to `publicOrigin` — the brand's own
* host — and no catalog entry overrode it, so each property sent a different
* URI and social login could work on at most one of them. The failure surfaced
* at Google, not here, which is why it read as a credentials problem for days.
*
* The default is the IAM issuer: the one host the shared client can be
* registered against, identical for every brand by construction.
*/
test('the provider callback defaults to the IAM issuer, not the brand host', () => {
const hanzo = resolveOrg('hanzo.app')
const chat = resolveOrg('hanzo.chat')
// The renamed key is not a second spelling to tolerate — it is simply absent.
assert.equal(catalogOf({ iamOrgConfigJson: '{"hanzo.id":{}}' }), undefined)
assert.equal(catalogOf({}), undefined)
assert.equal(catalogOf(null), undefined)
assert.equal(catalogOf({ iamTenantConfigJson: 42 }), undefined)
// Whatever the brand, one registered URI serves them all.
assert.equal(hanzo.oauthCallbackOrigin, hanzo.iamIssuer)
assert.equal(chat.oauthCallbackOrigin, chat.iamIssuer)
assert.equal(hanzo.oauthCallbackOrigin, chat.oauthCallbackOrigin)
// And it is NOT the brand host — the precise shape of the bug.
assert.notEqual(hanzo.oauthCallbackOrigin, hanzo.publicOrigin)
})
// The consequence, stated as behaviour: with the catalog present hanzo.id is the
// signup-permitting console app; without it, the built-in that refuses new
// federated users. Same host, one dropped key.
test('hanzo.id takes its clientId from the catalog, not the built-in', () => {
const withCatalog = resolveOrg('hanzo.id', {
catalog: parseCatalog(catalogOf({ iamTenantConfigJson: '{"hanzo.id":{"clientId":"hanzo-console","appName":"hanzo-console"}}' })),
})
assert.equal(withCatalog.clientId, 'hanzo-console')
const dropped = resolveOrg('hanzo.id', { catalog: parseCatalog(catalogOf({ iamOrgConfigJson: 'ignored' })) })
assert.equal(dropped.clientId, 'hanzo-id')
test('an explicit catalog oauthCallbackOrigin still wins over the issuer', () => {
const catalog = parseCatalog(
JSON.stringify({ 'per-host.example': { oauthCallbackOrigin: 'https://its-own-client.example' } }),
)
const org = resolveOrg('per-host.example', { catalog })
assert.equal(org.oauthCallbackOrigin, 'https://its-own-client.example')
})
+61 -24
View File
@@ -189,39 +189,76 @@ function stripPort(h: string): string {
return h.replace(/:\d+$/, '')
}
/**
* The org's hosted-ID origin — hanzo.id for hanzo, lux.id for lux, and so on —
* derived from DEFAULT_TENANTS so the `.id` hosts are declared exactly once.
* Returns '' for an org with no hosted ID host (local dev, per-host clients).
*/
function idOriginFor(orgId: string): string {
if (!orgId) return ''
for (const [host, t] of Object.entries(DEFAULT_TENANTS)) {
if (t.orgId === orgId && host.endsWith('.id')) return `https://${host}`
}
return ''
}
function normalize(t: OrgConfig): OrgConfig {
const publicOrigin = TRIM_TRAILING_SLASH(t.publicOrigin)
const iamIssuer = TRIM_TRAILING_SLASH(t.iamIssuer || t.iamUrl)
return {
...t,
iamUrl: TRIM_TRAILING_SLASH(t.iamUrl),
iamIssuer: TRIM_TRAILING_SLASH(t.iamIssuer || t.iamUrl),
iamIssuer,
publicOrigin,
// The social OAuth hop's redirect_uri must hit the provider's registered
// callback host. Default to this host; brands sharing a single OAuth client
// override it (via the catalog) to that client's registered origin.
oauthCallbackOrigin: TRIM_TRAILING_SLASH(t.oauthCallbackOrigin || publicOrigin),
// ONE provider callback for the whole fleet, and it is the IAM issuer's.
//
// Google and GitHub each accept a FIXED list of redirect URIs, and we hold
// one shared OAuth client per provider. So every brand must send the SAME
// redirect_uri or the provider answers `redirect_uri_mismatch` — which is
// exactly what hanzo.id, hanzo.app and hanzo.chat were all getting.
//
// This defaulted to `publicOrigin`, the BRAND'S OWN host, and no catalog
// entry overrode it. So each property sent a different redirect_uri
// (hanzo.app/callback, hanzo.chat/callback, console.hanzo.ai/callback …)
// and social login could work on at most ONE of them — whichever happened
// to be registered. Every new brand silently arrived broken, and the
// failure surfaced at Google rather than here, which is why it read as a
// credentials or KMS problem for days. It never was: the client_id reached
// Google intact every time.
//
// The issuer is the right default because it is the one host the shared
// client CAN be registered against, it is the same for every brand by
// construction, and it already serves the headless Callback SPA that
// completes the exchange and forwards back to the originating app.
// publicOrigin survives only as a last resort for local dev and per-host
// clients, where there is no issuer to speak of.
// ONE provider callback for the whole org, and it is the hosted ID host —
// hanzo.id for hanzo, lux.id for lux. A social provider must never learn
// about individual apps: it holds ONE OAuth client with ONE registered
// redirect_uri, so every app's hop has to arrive from the same origin or
// the provider answers `redirect_uri_mismatch`.
//
// This defaulted to `publicOrigin` — the BRAND'S OWN host — and no catalog
// entry overrode it, so hanzo.app sent hanzo.app/callback, hanzo.chat sent
// hanzo.chat/callback, console sent console.hanzo.ai/callback, and social
// login could work on at most ONE property. Defaulting to `iamIssuer` does
// NOT fix it: hostSkeleton derives the issuer from the REQUEST HOST too, so
// it is per-brand for exactly the same reason. It has to be a per-ORG
// constant, which is what idOriginFor reads out of DEFAULT_TENANTS.
//
// The failure surfaced at Google, not here, which is why it read as a
// credentials or KMS problem for days. It never was: the client_id reached
// Google intact every time and Google's own error decoded to
// `redirect_uri_mismatch`.
//
// hanzo.id then completes the exchange and forwards the browser back to the
// originating app, so the app hosts stay entirely invisible to the provider.
oauthCallbackOrigin: TRIM_TRAILING_SLASH(
t.oauthCallbackOrigin || idOriginFor(t.orgId) || iamIssuer || publicOrigin,
),
}
}
/**
* The catalog string inside the runtime's `/config.json` payload.
*
* `iamTenantConfigJson` is the server's name, not ours: hanzoai/spa derives the
* key mechanically from the env var universe supplies —
* `SPA_IAM_TENANT_CONFIG_JSON` (ConfigMap `id-tenant-catalog`). Reading any
* other key yields undefined, `resolveOrg` sees an empty catalog, and every host
* silently falls back to its built-in (or, for a catalog-only host, to an
* empty-clientId skeleton that cannot resolve an application at all). That is a
* total-catalog outage with no error anywhere, so the key is pinned by a test
* rather than left inline at the fetch. Rename the env var and this together or
* not at all.
*/
export function catalogOf(payload: unknown): string | undefined {
if (!payload || typeof payload !== 'object') return undefined
const raw = (payload as { iamTenantConfigJson?: unknown }).iamTenantConfigJson
return typeof raw === 'string' ? raw : undefined
}
/** Parse the runtime catalog JSON safely; returns {} on any error. */
export function parseCatalog(raw: string | undefined | null): Record<string, Partial<OrgConfig>> {
if (!raw) return {}