Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e3aed3e6a | ||
|
|
40c75c77ff |
@@ -1,11 +1,15 @@
|
||||
# Hanzo ID — runtime tenant catalog.
|
||||
#
|
||||
# Brand-neutral image: zero brand-specific data is bundled. This ConfigMap
|
||||
# carries the per-host overrides — `orgId`, `brandUrl`, `clientId`. The
|
||||
# image's hostname-derived defaults handle everything else (and would
|
||||
# handle the simple cases here too — `hanzo.id` → org=`hanzo` — but the
|
||||
# org-vs-npm-scope mismatches (`lux` → `@luxfi/brand`, `pars` → `@parsdao/brand`,
|
||||
# `zoolabs.id` → org=`zoo` (not `zoolabs`), etc.) require explicit overrides).
|
||||
# carries the per-host overrides — `orgId`, `brandUrl`, `clientId`, `appName`.
|
||||
# The image's hostname-derived defaults handle everything else.
|
||||
#
|
||||
# clientId/appName point at each brand's canonical IAM application (one app
|
||||
# per brand: `<org>-<app>`), which the brand console and the identity portal
|
||||
# share — the portal is the login UI for that brand's app, not a separate
|
||||
# OAuth client. (The earlier `<org>-id-portal` placeholders were never seeded
|
||||
# in IAM; the real apps are hanzo-console / lux-cloud / zoo-console /
|
||||
# pars-console.) Brand display still comes from `brandUrl` (npm `@<scope>/brand`).
|
||||
#
|
||||
# Downstream Hanzo-ID white-label deploys (ad.nexus, bootno.de, etc.)
|
||||
# ship their own ConfigMap with their own host map. The image never changes.
|
||||
@@ -21,15 +25,15 @@ metadata:
|
||||
data:
|
||||
SPA_IAM_TENANT_CONFIG_JSON: |
|
||||
{
|
||||
"hanzo.id": {"orgId":"hanzo","clientId":"hanzo-id-portal","appName":"hanzo-id","brandUrl":"https://cdn.jsdelivr.net/npm/@hanzo/brand@latest/brand.json"},
|
||||
"lux.id": {"orgId":"lux", "clientId":"lux-id-portal", "appName":"lux-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"id.lux.network": {"orgId":"lux", "clientId":"lux-id-portal", "appName":"lux-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"iam.lux.network": {"orgId":"lux", "clientId":"lux-id-portal", "appName":"lux-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"zoolabs.id": {"orgId":"zoo", "clientId":"zoo-id-portal", "appName":"zoo-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"www.zoolabs.id": {"orgId":"zoo", "clientId":"zoo-id-portal", "appName":"zoo-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"id.zoo.network": {"orgId":"zoo", "clientId":"zoo-id-portal", "appName":"zoo-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"pars.id": {"orgId":"pars", "clientId":"pars-id-portal", "appName":"pars-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@parsdao/brand@latest/brand.json"},
|
||||
"id.pars.network": {"orgId":"pars", "clientId":"pars-id-portal", "appName":"pars-id", "brandUrl":"https://cdn.jsdelivr.net/npm/@parsdao/brand@latest/brand.json"},
|
||||
"hanzo.id": {"orgId":"hanzo","clientId":"hanzo-console","appName":"hanzo-console","brandUrl":"https://cdn.jsdelivr.net/npm/@hanzo/brand@latest/brand.json"},
|
||||
"lux.id": {"orgId":"lux", "clientId":"lux-cloud", "appName":"lux-cloud", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"id.lux.network": {"orgId":"lux", "clientId":"lux-cloud", "appName":"lux-cloud", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"iam.lux.network": {"orgId":"lux", "clientId":"lux-cloud", "appName":"lux-cloud", "brandUrl":"https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json"},
|
||||
"zoolabs.id": {"orgId":"zoo", "clientId":"zoo-console", "appName":"zoo-console", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"www.zoolabs.id": {"orgId":"zoo", "clientId":"zoo-console", "appName":"zoo-console", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"id.zoo.network": {"orgId":"zoo", "clientId":"zoo-console", "appName":"zoo-console", "brandUrl":"https://cdn.jsdelivr.net/npm/@zooai/brand@latest/brand.json"},
|
||||
"pars.id": {"orgId":"pars", "clientId":"pars-console", "appName":"pars-console", "brandUrl":"https://cdn.jsdelivr.net/npm/@parsdao/brand@latest/brand.json"},
|
||||
"id.pars.network": {"orgId":"pars", "clientId":"pars-console", "appName":"pars-console", "brandUrl":"https://cdn.jsdelivr.net/npm/@parsdao/brand@latest/brand.json"},
|
||||
"osage.id": {"orgId":"osage","clientId":"osage-id-portal","appName":"osage-id","brandUrl":"https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json"},
|
||||
"www.osage.id": {"orgId":"osage","clientId":"osage-id-portal","appName":"osage-id","brandUrl":"https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json"}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Hanzo ID — white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hanzo/id",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
|
||||
+69
-24
@@ -12,9 +12,17 @@ import type {
|
||||
* Composable IAM client.
|
||||
*
|
||||
* Stateless wrapper around the canonical IAM REST surface (Casdoor-compat
|
||||
* paths under `/v1/iam/*` and the OIDC paths under `/oauth/*`). One client
|
||||
* instance per tenant. The portal creates one in `createRoot()`; downstream
|
||||
* pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()` directly.
|
||||
* paths under `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
|
||||
* client instance per tenant. The portal creates one in `createRoot()`;
|
||||
* downstream pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()`
|
||||
* directly.
|
||||
*
|
||||
* Wire contract (verified against live IAM): the auth fields — `type`,
|
||||
* `application`, `organization` — are read from the request BODY; the OAuth
|
||||
* params — `clientId`, `responseType`, `redirectUri`, `scope`, `state` — ride
|
||||
* on the query string. `type=code` (a client `redirectUri` is present) returns
|
||||
* an authorization code in `data`; `type=login` (bare portal sign-in)
|
||||
* establishes the session cookie.
|
||||
*
|
||||
* Token storage is intentionally NOT part of this client — the portal is a
|
||||
* white-label OIDC provider, so tokens are minted then immediately redirected
|
||||
@@ -42,40 +50,49 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
const f = opts.fetchImpl ?? fetch
|
||||
|
||||
async function login(req: LoginRequest): Promise<LoginResponse> {
|
||||
const type = req.redirectUri ? 'code' : 'login'
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('application', req.application)
|
||||
url.searchParams.set('organization', req.organization)
|
||||
if (req.redirectUri) url.searchParams.set('redirect_uri', req.redirectUri)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
url.searchParams.set('type', type)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
username: req.identifier,
|
||||
password: req.password,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
signinMethod: 'Password',
|
||||
language: 'en',
|
||||
autoSignin: true,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res)
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
async function signup(req: SignupRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/signup', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('application', req.application)
|
||||
url.searchParams.set('organization', req.organization)
|
||||
const username = req.email.split('@')[0]
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
username,
|
||||
name: username,
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
inviteCode: req.inviteCode,
|
||||
confirm: req.password,
|
||||
autoSignin: true,
|
||||
...(req.inviteCode ? { invitationCode: req.inviteCode } : {}),
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res)
|
||||
@@ -88,14 +105,23 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dest: req.identifier, type: 'login' }),
|
||||
body: JSON.stringify({
|
||||
applicationId: `admin/${tenant.appName}`,
|
||||
organization: req.organization,
|
||||
dest: req.identifier,
|
||||
type: req.identifier.includes('@') ? 'email' : 'phone',
|
||||
method: 'forget',
|
||||
checkUser: req.identifier,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: typeof body.msg === 'string' ? body.msg : 'failed' }
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function authorize(req: OAuthAuthorizeRequest): string {
|
||||
const url = new URL('/oauth/authorize', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/oauth/authorize', tenant.iamUrl)
|
||||
url.searchParams.set('client_id', req.clientId)
|
||||
url.searchParams.set('redirect_uri', req.redirectUri)
|
||||
url.searchParams.set('response_type', req.responseType ?? 'code')
|
||||
@@ -110,7 +136,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
}
|
||||
|
||||
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
|
||||
const url = new URL('/oauth/token', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/oauth/token', tenant.iamUrl)
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
@@ -136,7 +162,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
}
|
||||
|
||||
function logout(idTokenHint?: string, postLogoutRedirectUri?: string): string {
|
||||
const url = new URL('/oauth/logout', tenant.iamUrl)
|
||||
const url = new URL('/v1/iam/oauth/logout', tenant.iamUrl)
|
||||
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint)
|
||||
url.searchParams.set(
|
||||
'post_logout_redirect_uri',
|
||||
@@ -148,7 +174,10 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
return { tenant, login, signup, forgot, authorize, exchange, logout }
|
||||
}
|
||||
|
||||
async function parseLoginResponse(res: Response): Promise<LoginResponse> {
|
||||
async function parseLoginResponse(
|
||||
res: Response,
|
||||
req?: { redirectUri?: string; state?: string },
|
||||
): Promise<LoginResponse> {
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
@@ -158,14 +187,30 @@ async function parseLoginResponse(res: Response): Promise<LoginResponse> {
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
const data = (body.data ?? body) as Record<string, unknown>
|
||||
const data = body.data
|
||||
|
||||
// Authorization-code flow: a client redirectUri is present and `data` is the
|
||||
// freshly minted code — hand the SPA a fully-formed redirect back to the app.
|
||||
if (req?.redirectUri && typeof data === 'string' && data.length > 0) {
|
||||
const sep = req.redirectUri.includes('?') ? '&' : '?'
|
||||
return {
|
||||
redirectUrl: `${req.redirectUri}${sep}code=${encodeURIComponent(data)}&state=${encodeURIComponent(req.state ?? '')}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Bare portal sign-in: the IAM session cookie is now set; land on the portal.
|
||||
if (!req?.redirectUri) {
|
||||
return { redirectUrl: '/' }
|
||||
}
|
||||
|
||||
// Fallback: a nested token payload (future direct-token IAM responses).
|
||||
const d = (typeof data === 'object' && data ? data : body) as Record<string, unknown>
|
||||
return {
|
||||
accessToken: typeof data.access_token === 'string' ? data.access_token : undefined,
|
||||
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
|
||||
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
|
||||
expiresAt: typeof data.expires_at === 'number' ? data.expires_at : undefined,
|
||||
redirectUrl: typeof data.redirect_url === 'string' ? data.redirect_url : undefined,
|
||||
mfaRequired: data.mfa_required === true,
|
||||
mfaChannel: typeof data.mfa_channel === 'string' ? (data.mfa_channel as LoginResponse['mfaChannel']) : undefined,
|
||||
accessToken: typeof d.access_token === 'string' ? d.access_token : undefined,
|
||||
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
|
||||
idToken: typeof d.id_token === 'string' ? d.id_token : undefined,
|
||||
expiresAt: typeof d.expires_at === 'number' ? d.expires_at : undefined,
|
||||
mfaRequired: d.mfa_required === true,
|
||||
mfaChannel: typeof d.mfa_channel === 'string' ? (d.mfa_channel as LoginResponse['mfaChannel']) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user