Files
id/legacy-nextjs/middleware.ts
zeekayandGitHub 43090cdebf rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js) (#2)
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the ~/work/liquidity/id pattern.

Layout:
  apps/web/        Vite + React 19 SPA, embeds @hanzo/gui shell
    k8s/           Deployment(2) + Service + Ingress (4 hosts, 4 TLS)
  pkgs/shared/     @hanzo/id-shared — TenantConfig, resolveTenant,
                                       loadBrand (browser + node)
  pkgs/auth/       @hanzo/id-auth   — AuthClient (wraps @hanzo/iam REST)
                                       + LoginForm/SignupForm/ForgotForm/OTPForm
  pkgs/idv/        @hanzo/id-idv    — pluggable IDV (stub, persona,
                                       onfido, veriff) behind one
                                       IDVProvider interface
  legacy-nextjs/   Frozen — Next.js predecessor. Delete after v0.1.0 ships.
  Dockerfile       Two-stage: pnpm build → hanzoai/static:0.4.1 serves /spa
  README.md
  LLM.md           Architecture, dev, deploy, cutover plan

Tenant resolution: hostname → TenantConfig (orgId, iamUrl, clientId,
appName, publicOrigin, brandPackage). Built-in defaults for
hanzo.id/lux.id/zoo.id/pars.id; runtime override via
IAM_TENANT_CONFIG_JSON env (served as /config.json at pod startup).

Brand resolution: each per-org brand pkg (@hanzo/brand, @luxfi/brand,
@zooai/brand, @parsdao/brand) ships brand.json. The Vite plugin
brandJsonPlugin emits /brand/<pkg>/brand.json verbatim; the browser
fetches the right one based on the resolved tenant. No bundle bloat.

IDV: stub (default for dev), persona, onfido, veriff. Each adapter
implements `IDVProvider` from pkgs/idv/src/provider.ts. Swap providers
with one registration call at boot — portal code unchanged.

Cutover (separate ops PR — not in this commit):
  1. Tag + push image ghcr.io/hanzoai/id:0.1.0
  2. Apply k8s manifests, cert-manager issues TLS
  3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
  4. CF A records → 129.212.164.5 (hanzo ingress LB)
  5. Archive hanzo.id-worker repo
2026-05-24 06:00:20 -07:00

496 lines
16 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
/**
* Next.js middleware — the core proxy layer for Hanzo ID.
*
* Handles:
* 1. Multi-tenant hostname → org/IAM resolution
* 2. RFC 6749/OIDC path normalization (standard → IAM backend paths)
* 3. Social provider redirect with _oauth_ctx cookie
* 4. OIDC discovery body rewriting
* 5. Location header rewriting
*
* This is a white-label login portal. Any domain pointing here gets a
* working OIDC/OAuth2 provider experience. Configure via env vars for
* self-hosted deployments, or use the built-in tenant map.
*/
// --- Tenant configuration ---
interface TenantConfig {
org: string
iamOrigin: string
publicOrigin: string
}
const TENANTS: Record<string, TenantConfig> = {
'hanzo.id': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://hanzo.id',
},
'id.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo.ai',
},
'lux.id': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://lux.id',
},
'iam.lux.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://iam.lux.network',
},
'id.lux.cloud': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux.cloud',
},
'id.lux.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux.network',
},
'id.lux-dev.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux-dev.network',
},
'id.lux-test.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux-test.network',
},
'id-dev.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id-dev.hanzo.ai',
},
'id-test.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id-test.hanzo.ai',
},
'zoolabs.id': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://zoolabs.id',
},
'id.zoo.network': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.zoo.network',
},
'id.zoo-dev.network': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.zoo-dev.network',
},
'id.zoo-test.network': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.zoo-test.network',
},
'id.hanzo.network': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo.network',
},
'id.hanzo-dev.network': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo-dev.network',
},
'id.hanzo-test.network': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo-test.network',
},
'pars.id': {
org: 'pars',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://pars.id',
},
'id.pars.network': {
org: 'pars',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.pars.network',
},
'zen.id': {
org: 'zen',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://zen.id',
},
'id.ad.nexus': {
org: 'adnexus',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.ad.nexus',
},
'auth.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://auth.hanzo.ai',
},
}
function getTenant(hostname: string): TenantConfig {
const host = hostname.split(':')[0]
const tenant = TENANTS[host]
if (tenant) return tenant
// Env override for self-hosted / fork deployments
const envIamOrigin = process.env.IAM_ORIGIN || process.env.NEXT_PUBLIC_IAM_URL
return {
org: process.env.NEXT_PUBLIC_ORG || 'hanzo',
iamOrigin: envIamOrigin || 'https://iam.hanzo.ai',
publicOrigin: `https://${host}`,
}
}
// --- RFC path normalization ---
// PATH_REWRITES collapse RFC-standard OAuth/OIDC paths onto IAM's canonical
// surface. IAM exposes `/v1/iam/*` natively — `/api/*` is legacy and not
// part of the canonical surface. Every standard RFC alias funnels into a
// `/v1/iam/*` target, exactly one way.
const PATH_REWRITES: Record<string, string> = {
// NOTE: /oauth/authorize is handled explicitly in middleware() — NOT here.
// RFC 6749 — Token (exchange, refresh, client_credentials all use this)
'/oauth/token': '/v1/iam/login/oauth/access_token',
// RFC 7662 — Token Introspection
'/oauth/introspect': '/v1/iam/login/oauth/introspect',
// RFC 7009 — Token Revocation
'/oauth/revoke': '/v1/iam/login/oauth/revoke',
// OIDC Core — UserInfo
'/oauth/userinfo': '/v1/iam/userinfo',
// OIDC — Logout
'/oauth/logout': '/v1/iam/logout',
// RFC 8628 — Device Authorization
'/oauth/device': '/v1/iam/login/oauth/device',
// JWKS — standard /.well-known/jwks.json → IAM's /.well-known/jwks
'/.well-known/jwks.json': '/.well-known/jwks',
// RFC 8414 — OAuth metadata
'/.well-known/oauth-authorization-server': '/.well-known/openid-configuration',
}
// Paths to proxy to IAM backend (prefix match). `/v1/iam/` is the canonical
// IAM surface — everything else here is RFC-spec aliasing that lands at IAM
// after PATH_REWRITES normalization.
const IAM_PATH_PREFIXES = [
'/v1/iam/',
'/oauth/',
'/login/oauth/',
'/.well-known/',
'/cas/',
'/scim/',
]
// Paths handled by the Next.js app (login UI)
const APP_PATHS = [
'/login',
'/signup',
'/callback',
'/account',
'/forgot-password',
'/logout',
]
function shouldProxyToIAM(pathname: string): boolean {
// Don't proxy Next.js internal paths or our own API routes
if (pathname.startsWith('/_next/')) return false
if (pathname.startsWith('/api/auth/')) return false
if (pathname.startsWith('/api/logout')) return false
// Don't proxy paths handled by the Next.js app
// But DO proxy /login/oauth/* (IAM backend paths rewritten from /oauth/*)
if (pathname.startsWith('/login/oauth/')) return true
for (const p of APP_PATHS) {
if (pathname === p || pathname.startsWith(p + '/')) return false
}
return IAM_PATH_PREFIXES.some(p => pathname.startsWith(p))
}
// --- Social provider redirect handling ---
/**
* When /login/oauth/authorize or /oauth/authorize is called with a ?provider=
* param, we need to:
* 1. Resolve the app/org from the client_id
* 2. Set an _oauth_ctx cookie so the callback handler knows context
* 3. Proxy to IAM which redirects to the social provider
*/
async function handleSocialProviderRedirect(
request: NextRequest,
url: URL,
pathname: string,
tenant: TenantConfig,
): Promise<NextResponse | null> {
if (!url.searchParams.has('provider')) return null
const provider = url.searchParams.get('provider')!
const clientId = url.searchParams.get('client_id') || ''
const iamHost = new URL(tenant.iamOrigin).host
// Resolve app/org via IAM API, fall back to client map
let appName = ''
let appOwner = ''
if (clientId) {
// Dynamic import to keep middleware lean
const { resolveClient } = await import('@/lib/clients')
try {
const loginParams = new URLSearchParams({
clientId,
type: 'code',
responseType: url.searchParams.get('response_type') || 'code',
redirectUri: url.searchParams.get('redirect_uri') || `${url.origin}/callback`,
scope: url.searchParams.get('scope') || 'openid profile email',
state: url.searchParams.get('state') || '',
})
const appLoginRes = await fetch(`${tenant.iamOrigin}/v1/iam/get-app-login?${loginParams}`)
const appLoginData = await appLoginRes.json()
if (appLoginData?.status === 'ok' && appLoginData.data) {
appName = appLoginData.data.name || ''
appOwner = appLoginData.data.owner || appLoginData.data.organization || ''
}
} catch {}
if (!appName) {
const client = resolveClient(clientId)
if (client) {
appName = client.application
appOwner = client.organization
}
}
}
// Store OAuth context in cookie for the callback handler
const oauthContext = JSON.stringify({
application: appName,
organization: appOwner,
provider,
redirectUri: url.searchParams.get('redirect_uri') || `${url.origin}/callback`,
clientId,
})
const oauthContextCookie = `_oauth_ctx=${encodeURIComponent(btoa(oauthContext))}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`
// Proxy to IAM — it manages the full social OAuth flow
const iamUrl = new URL('/login/oauth/authorize' + url.search, tenant.iamOrigin)
const headers = new Headers(request.headers)
headers.set('Host', iamHost)
headers.delete('connection')
const iamResponse = await fetch(iamUrl.toString(), {
method: request.method,
headers,
body: request.body,
redirect: 'manual',
})
const responseHeaders = new Headers(iamResponse.headers)
responseHeaders.append('Set-Cookie', oauthContextCookie)
// Rewrite IAM redirects to our domain
const location = responseHeaders.get('location')
if (location) {
responseHeaders.set('location', location.replaceAll(tenant.iamOrigin, tenant.publicOrigin))
}
return new NextResponse(iamResponse.body, {
status: iamResponse.status,
statusText: iamResponse.statusText,
headers: responseHeaders,
})
}
// --- Callback routing ---
/**
* Route /callback to the right handler:
* - If ?code=&state= present and state looks like a social callback → server-side handler
* - Otherwise → let the Next.js callback page handle it (PKCE flow)
*/
function isSocialCallback(url: URL): boolean {
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
if (!code || !state) return false
// IAM social callbacks have base64-encoded state starting with "?"
try {
const decoded = atob(state)
if (decoded.startsWith('?') || decoded.includes('application=')) return true
} catch {}
return false
}
// --- Main middleware ---
export async function middleware(request: NextRequest) {
const url = new URL(request.url)
let pathname = url.pathname
const hostname = url.hostname
const tenant = getTenant(hostname)
// Route social callbacks to server-side handler
if (pathname === '/callback' && isSocialCallback(url)) {
const socialUrl = new URL('/api/auth/social-callback' + url.search, url.origin)
return NextResponse.rewrite(socialUrl)
}
// Handle /oauth/authorize and /login/oauth/authorize
if (pathname === '/oauth/authorize' || pathname === '/login/oauth/authorize') {
// Social login: proxy to IAM with ?provider= param
if (url.searchParams.has('provider')) {
const socialResponse = await handleSocialProviderRedirect(request, url, pathname, tenant)
if (socialResponse) return socialResponse
}
// If user already has an IAM session, try to proxy to IAM to complete OAuth authorize.
// IAM will auto-authorize and redirect (3xx) if the session is valid for this app.
// If IAM returns 200 (its built-in login page), fall through to our own login UI.
const sessionCookie = request.cookies.get('iam_session_id')?.value
if (sessionCookie) {
const iamUrl = new URL('/login/oauth/authorize' + url.search, tenant.iamOrigin)
const iamHost = new URL(tenant.iamOrigin).host
const headers = new Headers(request.headers)
headers.set('Host', iamHost)
headers.set('Cookie', `iam_session_id=${sessionCookie}`)
headers.delete('connection')
const iamResponse = await fetch(iamUrl.toString(), {
method: 'GET',
headers,
redirect: 'manual',
})
// Only use IAM's response if it's a redirect (auto-authorize succeeded).
// If IAM returns 200 (its login page), fall through to our own login UI.
if (iamResponse.status >= 300 && iamResponse.status < 400) {
const response = new NextResponse(iamResponse.body, {
status: iamResponse.status,
statusText: iamResponse.statusText,
headers: iamResponse.headers,
})
const location = response.headers.get('location')
if (location) {
response.headers.set(
'location',
location.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
)
}
return response
}
// IAM didn't auto-authorize — show our own login UI below
}
// Show our own login UI with OAuth context
// (Don't proxy to IAM's built-in SPA — hanzo.id IS the login UI)
const loginUrl = new URL('/login' + url.search, url.origin)
return NextResponse.redirect(loginUrl)
}
// Apply RFC path normalization
const rewrittenPath = PATH_REWRITES[pathname]
if (rewrittenPath) {
pathname = rewrittenPath
}
// Proxy IAM paths to backend
if (shouldProxyToIAM(pathname)) {
const iamUrl = new URL(pathname + url.search, tenant.iamOrigin)
const iamHost = new URL(tenant.iamOrigin).host
const headers = new Headers(request.headers)
headers.set('Host', iamHost)
headers.delete('connection')
const iamResponse = await fetch(iamUrl.toString(), {
method: request.method,
headers,
body: request.body,
redirect: 'manual',
})
// Rewrite OIDC discovery documents.
//
// IAM advertises a mix of canonical (`/v1/iam/login/oauth/*`, `/v1/iam/userinfo`)
// and OAuth2-spec (`/login/oauth/*`, `/oauth/*`) endpoints. The public RFC
// shape on this domain is `/oauth/*` — collapse both legacy `/api/*` and
// canonical `/v1/iam/*` rewrites onto `/oauth/*` so OIDC clients see the
// standard surface. PATH_REWRITES handles the inbound direction
// (RFC → canonical `/v1/iam/*` for proxying).
const isDiscovery = pathname === '/.well-known/openid-configuration'
|| pathname === '/.well-known/oauth-authorization-server'
if (isDiscovery && iamResponse.ok) {
const contentType = iamResponse.headers.get('content-type') || ''
if (contentType.includes('json')) {
try {
let body = await iamResponse.text()
// Rewrite IAM backend origin to public tenant origin
body = body.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
// Normalize IAM backend paths (both canonical /v1/iam/* and
// legacy /api/*) to RFC standard /oauth/* surface.
body = body.replaceAll('/v1/iam/login/oauth/authorize', '/oauth/authorize')
body = body.replaceAll('/v1/iam/login/oauth/access_token', '/oauth/token')
body = body.replaceAll('/v1/iam/login/oauth/refresh_token', '/oauth/token')
body = body.replaceAll('/v1/iam/login/oauth/introspect', '/oauth/introspect')
body = body.replaceAll('/v1/iam/login/oauth/revoke', '/oauth/revoke')
body = body.replaceAll('/v1/iam/login/oauth/device', '/oauth/device')
body = body.replaceAll('/v1/iam/userinfo', '/oauth/userinfo')
body = body.replaceAll('/v1/iam/logout', '/oauth/logout')
body = body.replaceAll('/login/oauth/authorize', '/oauth/authorize')
body = body.replaceAll('/login/oauth/logout', '/oauth/logout')
return new NextResponse(body, {
status: iamResponse.status,
headers: iamResponse.headers,
})
} catch {}
}
}
// Clone response and rewrite redirect Location headers
const response = new NextResponse(iamResponse.body, {
status: iamResponse.status,
statusText: iamResponse.statusText,
headers: iamResponse.headers,
})
const location = response.headers.get('location')
if (location) {
response.headers.set(
'location',
location.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
)
}
return response
}
return NextResponse.next()
}
export const config = {
matcher: [
// /v1/iam/* is the canonical IAM surface — this must match so the
// middleware proxies it to IAM_ORIGIN. Without this, CF Pages returns
// 405 for POST /v1/iam/login because the static SPA has no POST handler.
'/v1/iam/:path*',
'/oauth/:path*',
'/login/oauth/:path*',
'/.well-known/:path*',
'/callback',
'/cas/:path*',
'/scim/:path*',
],
}