Files
Hanzo AI c1a8831042 iam: zoo.vote signs in with the tenant's issuer
luxfi/vote's zoo tenant is an SPA client, PKCE in the browser like the others.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-04 09:15:42 -07:00

203 lines
13 KiB
YAML

# Zoo IAM provision document — POLICY / DATA (this is Zoo's app graph).
#
# The MECHANISM is brand-neutral and lives in the OSS server (hanzoai/iam,
# package internal/provision, exposed as `iam2 provision`). It derives
# client_id (<org>-<app>), redirect URIs and grant types from each app's
# `type`, then idempotently upserts every client through
# POST /v1/iam/admin/applications/upsert. One line per app; the OSS ships ZERO
# brands, Zoo owns its own app data HERE.
#
# APPLY IT (deterministic, re-runnable — this is the ONLY way Zoo's IAM state
# is changed; never hand-edit applications in a console):
#
# make iam-provision # converge zoolabs.id
# make iam-provision-plan # derive + print, write nothing
#
# Re-running is a no-op by construction: the upsert keys on <org>-<app> and a
# request that omits clientSecret PRESERVES the secret IAM already issued, so
# convergence never rotates a live credential. Secrets stay in KMS
# (kms.zoo.network, path zoo/iam/clients/<org>-<app>) — never in this file.
#
# app `type` → derived shape (authoritative table: internal/provision/provision.go):
# spa public browser client; https://<host>/auth/callback per host
# confidential server-side web app; same redirects + an IAM-issued client_secret
# cli loopback http://127.0.0.1/callback + http://localhost/callback
# desktop custom-scheme deep link <deepLinkScheme>://oauth/<app>
# service machine-to-machine, client_credentials only, no redirects
#
# /auth/callback is the ONE browser callback path, verified against what the
# deployed Zoo apps actually send (zoo.cloud, wallet.zoo.network and zoo.vote
# all build redirect_uri = `${window.location.origin}/auth/callback`). It is NOT
# /api/... — the /v1-only rule holds, and the browser callback is unversioned.
#
# client_id is ALWAYS <org>-<app>. HIP-0111.
#
# `cert` is NOT cosmetic and NOT optional. issueTokens resolves app.Cert to
# sign, so a client created without one cannot mint an id_token, and the login
# fails AFTER a successful code exchange — the "cannot get user information
# without id_token" class. The create path copies req.Cert verbatim
# (internal/bootstrap/bootstrap.go: `a.GrantTypes, a.RedirectUris, a.Cert =
# req.GrantTypes, req.RedirectUris, req.Cert`), so an omitted cert means an
# EMPTY cert on every newly created client — which is exactly what the first
# converge of this document produced. Measured on the live IdP:
# zoo-cloud cert=cert-zoo (created 2026-07-25, the one Zoo login that works)
# zoo-wallet / zoo-dao / zoo-explore / zoo-id cert=<EMPTY> (created 2026-07-26)
# The convention is cert-<org>, confirmed against the two sibling consoles:
# lux-cloud=cert-lux, hanzo-cloud=cert-hanzo. cert-zoo is published in the live
# JWKS (https://zoolabs.id/v1/iam/.well-known/jwks), so tokens signed under it
# verify today. Every Zoo app therefore names cert-zoo; for zoo-cloud that is a
# literal no-op (it already holds cert-zoo), which is what makes stating it
# uniformly safe rather than a gamble on the working login.
#
# ─── ISSUER: Zoo's canonical issuer is https://zoolabs.id ────────────────────
# The host id.zoo.network serves issuer "https://zoolabs.id". That is a real
# disagreement, and it is resolved in FAVOUR of zoolabs.id, not against it:
#
# 1. IAM already stamps it. IAM_ISSUER_MAP on deploy hanzo/iam maps all three
# of zoolabs.id, www.zoolabs.id AND id.zoo.network to https://zoolabs.id,
# so every token Zoo has ever issued carries iss=https://zoolabs.id.
# Re-pointing the issuer invalidates every live session and every relying
# party's cached config; re-pointing an ALIAS costs nothing.
# 2. Discovery already agrees at both hosts. issuer, authorization_endpoint,
# token_endpoint and jwks_uri all resolve to zoolabs.id whichever host you
# fetch /.well-known/openid-configuration from. There is one issuer
# identity; id.zoo.network is a second NAME for it, not a second issuer.
# 3. Fleet convention. Every sibling brand's issuer is its <brand>.id apex —
# hanzo.id, lux.id, pars.id, osage.id — with id.<brand>.network as the
# alias. id.lux.network serves issuer https://lux.id: the identical shape.
# Zoo's <brand>.id apex is zoolabs.id (Zoo Labs Foundation is the legal
# entity). `zoo.id` does NOT resolve, so it cannot be the issuer — and any
# Zoo surface still pointing at https://zoo.id is simply broken.
# 4. White-label rule holds: zoolabs.id is Zoo-owned. No Lux/Hanzo leak.
#
# The DEFECT that remains is the DISCOVERY DOCUMENT, not the issuer choice:
# https://id.zoo.network/.well-known/openid-configuration returns
# issuer=https://zoolabs.id, and OIDC Discovery 1.0 §4.3 requires the issuer
# value to be identical to the URL prefix the document was fetched from. Any
# strict client (oidc-client-ts, MSAL, go-oidc, Spring Security, Keycloak
# adapters) rejects that document. zoo.cloud only survives because its
# hand-rolled SDK never validates iss. The fix belongs to the FRONTENDS and the
# ingress, not to this file: point every Zoo client's iamDomain/authority at
# https://zoolabs.id so discovery host == issuer, and reduce id.zoo.network to a
# 301. Registration data below is unaffected either way — a client's redirect
# URIs are hosts of the APP, never of the issuer.
#
# ─── WHAT IS DECLARED, AND ON WHAT EVIDENCE ──────────────────────────────────
# Only surfaces measured to drive an OIDC authorization_code flow are declared.
# Every Zoo host was probed; the ones that carry ZERO OAuth wiring are listed at
# the bottom so the omission is a recorded decision, not an oversight.
orgs:
- name: zoo
displayName: Zoo
homepage: https://zoo.ngo
deepLinkScheme: zoo
apps:
# zoo.cloud is the LIVE console and the only Zoo client that predates this
# document — the live records show createdAt 2026-07-25 for zoo-cloud and
# 2026-07-26 (this document's first converge) for all four below.
# Observed in the shipped bundle: iamClientId=`zoo-cloud`,
# redirectUri=`${window.location.origin}/auth/callback`, and the
# code->token exchange posts code_verifier — i.e. PKCE IN THE BROWSER.
# Hence `spa`, NOT confidential: a browser cannot hold a secret, and IAM
# demands client auth whenever one is stored, which is what makes a
# mis-registered console die on `invalid_client` at the callback.
# (deploy hanzo/cloud still carries a legacy ZOO_CLOUD_CLIENT_SECRET env;
# it is vestigial — the live flow is public+PKCE and the audience
# `zoo-cloud` is already in GATEWAY_ALLOWED_AUDIENCES.)
#
# www.zoo.cloud is SERVED by ingress zoo-cloud/cloud but its callback was
# NOT registered — logging in from the www host failed. Declaring it here
# fixes that.
# cloud.zoo.network is NOT declared, and that is a deliberate change from
# the first converge, which did register it. It has an ingress
# (hanzo/bootnode-web-ingress) but NO DNS RECORD AT ALL — `dig` returns
# nothing for A at 1.1.1.1 and 8.8.8.8 — so no browser can ever land
# there, and a redirect URI for a host that cannot exist is precisely the
# drift this document exists to prevent. It is also the one host the old
# text both declared here AND listed as non-resolving at the bottom; the
# contradiction had to break one way, and it breaks toward the measurement.
# Contrast Lux, which keeps cloud.lux.network for the same role: that host
# DOES resolve (172.67.134.56 / 104.21.25.122), so its line is earned.
# This converge also DROPS http://localhost:3000/auth/callback, which was
# registered on zoo-cloud: a loopback redirect has no place on a browser
# SPA (that is what `type: cli` is for), and the converged lux-cloud
# reference does not carry one either.
- { app: cloud, type: spa, cert: cert-zoo, hosts: [zoo.cloud, www.zoo.cloud] }
# Zoo Wallet. wallet.zoo.network/brand.json declares
# iamClientId=`zoo-wallet` and the session chunk runs the same PKCE
# authorize/token pair against /v1/iam/oauth/*. The client did not exist
# in IAM, so Zoo Wallet login was dead on arrival — this line is the fix.
# NOTE two further wallet defects that this file CANNOT fix: brand.json
# sets iamIssuer=https://zoo.id (does not resolve — see ISSUER above) and
# appDomain=wallet.zoo.ngo (does not resolve). Both are wallet-side.
- { app: wallet, type: spa, cert: cert-zoo, hosts: [wallet.zoo.network] }
# Zoo DAO. zoo.vote ships a HARDCODED Lux config —
# issuer="https://lux.id", client_id:"lux-dao" — on a Zoo domain: a
# white-label violation, and dead anyway (`lux-dao` is registered
# nowhere). `zoo-dao` is the correct client for zoo.vote/www.zoo.vote
# (both served by ingress hanzo/zoo-vote). The frontend must be repointed
# at https://zoolabs.id + zoo-dao; registering the client first is what
# makes that repoint a one-line change.
# vote.zoo.ngo serves an OLDER DAO build with no OAuth wiring at all and
# is deliberately NOT declared.
- { app: dao, type: spa, cert: cert-zoo, hosts: [zoo.vote, www.zoo.vote] }
# Zoo Explorer. The shipped _app chunk builds
# `${serverUrl}/oauth/authorize?...&redirect_uri=<origin>/auth/callback`
# behind authProvider:"oidc", but its runtime envs.js carries no
# NEXT_PUBLIC_OIDC_* keys, so account sign-in is switched off at runtime.
# Registering the client is the half that belongs in IAM; setting
# NEXT_PUBLIC_OIDC_SERVER_URL=https://zoolabs.id and
# NEXT_PUBLIC_OIDC_CLIENT_ID=zoo-explore is the half that belongs to the
# deployment. Set them to EXACTLY that: the Lux explorer is live proof of
# the failure mode, shipping NEXT_PUBLIC_OIDC_CLIENT_ID=
# "lux-explore-client-id" while IAM holds `lux-explore`, so its sign-in
# 400s on unknown client_id today.
- { app: explore, type: spa, cert: cert-zoo, hosts: [explore.zoo.network] }
# Zoo ID portal. zoolabs.id / www.zoolabs.id / id.zoo.network all serve
# the Sign-in SPA (ingresses hanzo/id and hanzo/hanzo-login). Its
# compiled brand table holds hanzo.id, lux.id, pars.id, osage.id and
# www.osage.id — and NO Zoo entry, so on a Zoo host it falls back to
# clientId:"" and orgId:"" and cannot start a login of its own. This is
# the client that entry must name; adding it to the login app's table is a
# separate frontend change.
- { app: id, type: spa, cert: cert-zoo, hosts: [zoolabs.id, www.zoolabs.id, id.zoo.network] }
- { app: vote, type: spa, hosts: [zoo.vote] } # clientId zoo-vote — luxfi/vote (zoo tenant). spa/PKCE, never confidential
# ─── MEASURED AND DELIBERATELY NOT DECLARED ──────────────────────────────────
# Reachable Zoo surfaces with ZERO OAuth wiring — every first-party chunk was
# downloaded and searched for /v1/iam/oauth/authorize, iamClientId and iamIssuer:
# zoo.exchange, exchange.zoo.network, trade.zoo.exchange (367 chunks, 0 hits;
# wallet-based auth only)
# bridge.zoo.network, bridge.zoo.ngo (BRIDGE_CLIENT_ID unset in __ENV.js;
# bridge.lux.network ships it empty too — bridge is not an IAM surface)
# status.zoo.network, kms.zoo.network, zoo.ngo, explore.zoo.ngo (0 hits)
# Zoo hostnames served by SHARED Hanzo backends, not Zoo clients:
# console.zoo.cloud -> svc cloud (IAM_CLIENT_ID=hanzo-cloud); there is no
# `zoo-console` audience in GATEWAY_ALLOWED_AUDIENCES, unlike lux-console.
# Resolves (129.212.164.5), 200.
# admin.zoo.cloud -> svc console behind middleware admin-guard@file
# (audiences admin-console / hanzo-admin-guard). It DOES resolve
# (104.21.47.184) and answers 401 {"authentication required"} — that 401
# is the guard working, not a dead host. Two ingresses claim it
# (hanzo/console-admin-guard-zoo-cloud and hanzo/console-admin-zoo-cloud);
# that duplicate belongs to the console, not to this document.
# Resolves, but is NOT an IAM surface:
# docs.zoo.ngo A 185.199.109.153 (GitHub Pages); 200 only with TLS
# verification off — the served certificate does not name docs.zoo.ngo.
# build.zoo.network A 134.199.138.27; http 301, https 503. Reachable and
# broken, which is a deployment defect, not a missing OAuth client.
# Hosts with NO DNS record at all (checked A at 1.1.1.1 and 8.8.8.8), so
# nothing can be declared for them:
# app.zoo.ngo, zoo.id, wallet.zoo.ngo, wallet-api.zoo.ngo,
# auth.zoo.ngo (has ingress hanzo/iam-redirect-zoo, no DNS),
# cloud.zoo.network (has ingress hanzo/bootnode-web-ingress, no DNS),
# api.cloud.zoo.network (has ingress hanzo/bootnode-api-ingress, no DNS),
# dao.zoo.ngo, id.zoo.ngo, market.zoo.network, billing.zoo.network, zoo.build
# Third-party parked domains, NOT ours: zoo.market (2.57.91.91),
# zoo.finance (3.33.130.190 / 15.197.148.33)