Files
id/legacy-nextjs/lib/oauth.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

194 lines
5.1 KiB
TypeScript

/**
* OAuth2 / OIDC client with PKCE (RFC 7636)
*
* Works against Hanzo IAM backend via the tenant's iamOrigin.
*/
// --- PKCE helpers ---
function generateRandomString(length: number): string {
const array = new Uint8Array(length)
crypto.getRandomValues(array)
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('').slice(0, length)
}
async function sha256(plain: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder()
return crypto.subtle.digest('SHA-256', encoder.encode(plain))
}
function base64urlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let str = ''
for (const b of bytes) str += String.fromCharCode(b)
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export async function generatePKCE() {
const verifier = generateRandomString(64)
const hashed = await sha256(verifier)
const challenge = base64urlEncode(hashed)
return { verifier, challenge }
}
// --- Types ---
export interface TokenResponse {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
id_token?: string
scope?: string
}
export interface UserInfo {
sub: string
name?: string
displayName?: string
preferred_username?: string
email?: string
avatar?: string
permanentAvatar?: string
picture?: string
owner?: string
}
// --- Token storage (sessionStorage for PKCE, localStorage for session) ---
const PREFIX = 'hanzo_auth_'
export function storeSession(key: string, value: string) {
sessionStorage.setItem(PREFIX + key, value)
}
export function retrieveSession(key: string): string | null {
const val = sessionStorage.getItem(PREFIX + key)
sessionStorage.removeItem(PREFIX + key)
return val
}
// --- Core flows ---
/**
* Password login against IAM /api/login.
* Returns JWT token directly.
*/
export async function passwordLogin(params: {
iamUrl: string
org: string
username: string
password: string
application: string
clientId?: string
redirectUri?: string
}): Promise<{ token: string; code?: string }> {
const url = new URL('/v1/iam/login', params.iamUrl)
// If OAuth params provided, pass as query params (camelCase — IAM convention)
if (params.clientId && params.redirectUri) {
url.searchParams.set('clientId', params.clientId)
url.searchParams.set('responseType', 'code')
url.searchParams.set('redirectUri', params.redirectUri)
url.searchParams.set('scope', 'openid profile email')
}
const res = await fetch(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'token',
organization: params.org,
username: params.username,
password: params.password,
application: params.application,
...(params.clientId ? { clientId: params.clientId } : {}),
...(params.redirectUri ? { redirectUri: params.redirectUri } : {}),
}),
})
const data = await res.json()
if (data.status !== 'ok') {
throw new Error(data.msg || 'Login failed')
}
return { token: data.data }
}
/**
* Start OAuth authorize redirect with PKCE.
*/
export async function startAuthorize(params: {
iamUrl: string
clientId: string
redirectUri: string
scope?: string
}) {
const { verifier, challenge } = await generatePKCE()
const state = generateRandomString(32)
storeSession('pkce_verifier', verifier)
storeSession('oauth_state', state)
const url = new URL('/oauth/authorize', params.iamUrl)
url.searchParams.set('client_id', params.clientId)
url.searchParams.set('response_type', 'code')
url.searchParams.set('redirect_uri', params.redirectUri)
url.searchParams.set('scope', params.scope ?? 'openid profile email')
url.searchParams.set('state', state)
url.searchParams.set('code_challenge', challenge)
url.searchParams.set('code_challenge_method', 'S256')
window.location.href = url.toString()
}
/**
* Exchange authorization code for tokens.
*/
export async function exchangeCode(params: {
iamUrl: string
code: string
state: string
clientId: string
redirectUri: string
}): Promise<TokenResponse> {
const savedState = retrieveSession('oauth_state')
if (savedState !== params.state) {
throw new Error('OAuth state mismatch')
}
const verifier = retrieveSession('pkce_verifier')
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
client_id: params.clientId,
...(verifier ? { code_verifier: verifier } : {}),
})
const res = await fetch(`${params.iamUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
if (!res.ok) {
throw new Error(`Token exchange failed: ${res.status}`)
}
return res.json()
}
/**
* Fetch user info.
*/
export async function fetchUserInfo(iamUrl: string, accessToken: string): Promise<UserInfo> {
const res = await fetch(`${iamUrl}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) throw new Error(`Userinfo failed: ${res.status}`)
return res.json()
}