mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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: [],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user