feat(web3): restore canonical @hanzo/iam SPA PKCE auth (HIP-0111)

The brand-neutral refactor regressed login to a hand-rolled OAuth authorize
URL (no PKCE, wrong path `${iamUrl}/oauth/authorize`) plus a backend
code-exchange POST to /v1/auth/oauth/callback — a route api-go does not
expose. Both break IAM login: the hand-rolled URL hits a path IAM serves
its 200 SPA catch-all for (silent breakage), and the callback 404s.

Restore the one canonical way per HIP-0111:
- lib/iam.ts: @hanzo/iam/browser IAM client, white-labelled by the active
  brand (serverUrl + clientId), PKCE S256, endpoints resolved via OIDC
  discovery. No hand-rolled authorize URL, no genericOAuth, no /api/auth.
- login: getIam().signinRedirect() in production (SDK owns PKCE + state).
- callback: getIam().handleCallback() does the PKCE token exchange
  client-side; the IAM access token becomes the session bearer.
- brand default: bootnode IAM is id.bootno.de / bootnode-platform (was
  hanzo.id / bootnode-web — wrong issuer + unregistered client).
- package.json: add @hanzo/iam ^0.11.0 (used but missing from deps).
- public/config.example.json: in-repo reference for the per-brand catalog
  (registered clientId per host) so the deploy provisions real clients.

Build + typecheck green.
This commit is contained in:
2026-06-25 13:50:12 -07:00
parent 7383c4ca24
commit 33849ab656
6 changed files with 104 additions and 58 deletions
+10 -47
View File
@@ -2,15 +2,14 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useRouter } from "next/navigation"
import { useAuth } from "@/lib/auth"
import { getBrand } from "@/lib/brand"
import { getIam, takeReturnUrl } from "@/lib/iam"
function CallbackContent() {
const [status, setStatus] = useState<"loading" | "success" | "error">("loading")
const [error, setError] = useState("")
const router = useRouter()
const searchParams = useSearchParams()
const { loginWithToken } = useAuth()
useEffect(() => {
@@ -19,53 +18,17 @@ function CallbackContent() {
async function handleCallback() {
try {
const code = searchParams.get("code")
const stateParam = searchParams.get("state")
const errorParam = searchParams.get("error")
if (errorParam) {
throw new Error(`OAuth error: ${errorParam}`)
}
if (!code) {
throw new Error("No authorization code received")
}
// Parse state to get return URL
let returnUrl = "/dashboard"
try {
const state = JSON.parse(decodeURIComponent(stateParam || "{}"))
returnUrl = state.returnUrl || "/dashboard"
} catch {
// State might be just the org name
}
// Exchange code for token via our API
const brand = getBrand()
const apiUrl = (typeof window !== "undefined" && window.location.hostname !== "localhost")
? brand.apiUrl
: (process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000")
const redirectUri = `${window.location.origin}/auth/callback`
const response = await fetch(`${apiUrl}/v1/auth/oauth/callback`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ code, state: stateParam, redirect_uri: redirectUri }),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.detail || `Authentication failed: ${response.statusText}`)
}
const { access_token } = await response.json()
await loginWithToken(access_token)
// Canonical PKCE exchange (HIP-0111): the IAM SDK validates state,
// exchanges the authorization code at the token endpoint with the stored
// PKCE verifier, and returns tokens. The resulting IAM access token is
// the session bearer the bootnode API verifies against IAM JWKS.
const tokens = await getIam().handleCallback()
await loginWithToken(tokens.accessToken)
setStatus("success")
// Redirect to dashboard after successful login
// Redirect to the stashed destination after successful login.
const returnUrl = takeReturnUrl()
setTimeout(() => {
router.push(returnUrl)
}, 1500)
+7 -8
View File
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Loader2 } from "lucide-react"
import { useAuth, HanzoLoginButton } from "@/lib/auth"
import { getIam, setReturnUrl } from "@/lib/iam"
import { BrandLogo, useBrand } from "@/components/brand-logo"
function LoginForm() {
@@ -32,17 +33,15 @@ function LoginForm() {
}
}, [user, authLoading, router, searchParams])
// Production mode: Redirect to brand-specific IAM
// Production mode: canonical IAM PKCE redirect (HIP-0111) via @hanzo/iam.
// The SDK generates the PKCE S256 challenge + CSRF state, resolves the
// authorize endpoint from the active brand's OIDC discovery, and redirects.
useEffect(() => {
if (isProduction && !authLoading && !user) {
const returnUrl = searchParams.get("returnUrl") || "/dashboard"
const callbackUrl = `${window.location.origin}/auth/callback`
const iamUrl = brand.iam.url
const clientId = brand.iam.clientId
const authRedirect = `${iamUrl}/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=openid+profile+email&state=${encodeURIComponent(returnUrl)}`
window.location.href = authRedirect
setReturnUrl(searchParams.get("returnUrl") || "/dashboard")
void getIam().signinRedirect()
}
}, [isProduction, authLoading, user, searchParams, brand.iam.url, brand.iam.clientId])
}, [isProduction, authLoading, user, searchParams])
async function handleSubmit(e: FormEvent) {
e.preventDefault()
+7 -3
View File
@@ -99,9 +99,13 @@ const BOOTNODE_DEFAULT: BrandConfig = {
},
social: {},
iam: {
url: "https://hanzo.id",
clientId: "bootnode-web",
domain: "hanzo.id",
// HIP-0111: bootnode's canonical OIDC issuer is id.bootno.de; the
// registered Casdoor client for bootno.de/auth/callback is bootnode-platform.
// (Per-brand catalogs for lux/zoo/pars/hanzo are injected at deploy time —
// see public/config.example.json for the registered clientId per brand.)
url: "https://id.bootno.de",
clientId: "bootnode-platform",
domain: "id.bootno.de",
},
footerLinks: [],
ecosystemApps: [],
+50
View File
@@ -0,0 +1,50 @@
// Canonical IAM auth (HIP-0111) via @hanzo/iam SPA PKCE.
//
// One source of truth for the browser IAM client, white-labelled per brand:
// serverUrl + clientId come from the active brand catalog (lib/brand.ts), which
// is injected per-deploy. Env overrides win for one-off local testing.
//
// HIP-0111: authenticate ONLY through @hanzo/iam against the canonical OIDC
// endpoints. The SDK resolves /v1/iam/oauth/* via live discovery (with a
// canonical-path fallback), drives PKCE S256, and owns `state` for CSRF. No
// hand-rolled authorize URLs, no genericOAuth({discoveryUrl}), no /api/auth.
"use client"
import { IAM } from "@hanzo/iam/browser"
import { getBrand } from "@/lib/brand"
// Stash the post-login destination across the IAM round-trip. The SDK owns
// `state` for CSRF; the app owns where to land after sign-in.
const RETURN_URL_KEY = "bootnode_return_url"
export function getIam(): IAM {
const brand = getBrand()
const serverUrl =
process.env.NEXT_PUBLIC_IAM_URL || brand.iam.url
const clientId =
process.env.NEXT_PUBLIC_IAM_CLIENT_ID || brand.iam.clientId
return new IAM({
serverUrl,
clientId,
redirectUri: `${window.location.origin}/auth/callback`,
scope: "openid profile email",
})
}
export function setReturnUrl(url: string): void {
try {
sessionStorage.setItem(RETURN_URL_KEY, url)
} catch {
/* sessionStorage unavailable — fall back to default destination */
}
}
export function takeReturnUrl(): string {
try {
const url = sessionStorage.getItem(RETURN_URL_KEY)
sessionStorage.removeItem(RETURN_URL_KEY)
return url || "/dashboard"
} catch {
return "/dashboard"
}
}
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hanzo/iam": "^0.11.0",
"@luxfi/logo": "^1.0.0",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
+29
View File
@@ -0,0 +1,29 @@
{
"_comment": "REFERENCE multi-brand catalog (HIP-0111). The deploy (universe / CF-Pages) provisions config.json (and/or window.__BRAND_CATALOG__) from a catalog of this shape, host-keyed. iam.url is the canonical OIDC issuer (serverUrl); iam.clientId is the REGISTERED Casdoor app for that host (<org>-web3 / bootnode-platform / pars-app) whose redirectUris include https://<host>/auth/callback. These are PUBLIC values (no secrets) — the @hanzo/iam SPA PKCE flow is a public client. Do NOT invent client IDs: an unregistered clientId returns 'Invalid client_id' and IAM serves its 200 SPA catch-all, which is silent breakage. Brand identity (logos/colors/links) is elided here; only the iam + host wiring is shown as the source of truth.",
"defaultKey": "bootnode",
"hosts": [
{ "match": "web3.hanzo.ai", "key": "hanzo" },
{ "match": "cloud.lux.network", "key": "lux" },
{ "match": "web3.lux.network", "key": "lux" },
{ "match": "web3.zoo.ngo", "key": "zoo" },
{ "match": "cloud.pars.network", "key": "pars" },
{ "match": "bootno.de", "key": "bootnode" }
],
"brands": {
"hanzo": {
"iam": { "url": "https://iam.hanzo.ai", "clientId": "hanzo-web3", "domain": "iam.hanzo.ai" }
},
"lux": {
"iam": { "url": "https://lux.id", "clientId": "lux-web3", "domain": "lux.id" }
},
"zoo": {
"iam": { "url": "https://zoo.id", "clientId": "zoo-web3", "domain": "zoo.id" }
},
"pars": {
"iam": { "url": "https://pars.id", "clientId": "pars-app", "domain": "pars.id" }
},
"bootnode": {
"iam": { "url": "https://id.bootno.de", "clientId": "bootnode-platform", "domain": "id.bootno.de" }
}
}
}