Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da19c5f208 | ||
|
|
a87b7500e5 | ||
|
|
23d0c0e7a8 | ||
|
|
436931bb53 | ||
|
|
2ceecf49a5 | ||
|
|
f5d2f13499 | ||
|
|
f8640af7bc | ||
|
|
f4e7c0d145 | ||
|
|
0a97192506 | ||
|
|
d928ecdfe3 | ||
|
|
87d2da9c74 | ||
|
|
218528569e | ||
|
|
6df3f160b8 | ||
|
|
648cbe8e65 | ||
|
|
db509e068a | ||
|
|
b865303473 | ||
|
|
f483ee6791 | ||
|
|
7757aa1c74 | ||
|
|
f0b9750325 | ||
|
|
064ea5666b | ||
|
|
9d906eda2a | ||
|
|
140a000794 | ||
|
|
1c1d6873af | ||
|
|
5de04a2b46 | ||
|
|
7360334996 | ||
|
|
b116ea118d | ||
|
|
80ced70133 | ||
|
|
1c060e6886 | ||
|
|
0b144a53e5 | ||
|
|
616a1131a6 | ||
|
|
a6b37b02d7 | ||
|
|
4a8d73b0fb | ||
|
|
369b0702b9 | ||
|
|
52bb873f87 | ||
|
|
af8819ba60 | ||
|
|
04b2a0324d | ||
|
|
7c67359f6f | ||
|
|
03217a97be | ||
|
|
324c698dd5 | ||
|
|
ba9363cf7a | ||
|
|
584e6cd679 | ||
|
|
fe30228bdc | ||
|
|
d0863532d4 | ||
|
|
68c33527b9 | ||
|
|
6af816ac9c | ||
|
|
84a529cd0a | ||
|
|
bfbca725f2 | ||
|
|
6265944602 | ||
|
|
3e31fe7373 | ||
|
|
8163c3c2a8 | ||
|
|
8cc5428e97 | ||
|
|
d402732ebf | ||
|
|
9590dc319d | ||
|
|
475d0291b4 | ||
|
|
ab4f666a2b | ||
|
|
5372a45fc7 | ||
|
|
bf6acb79d8 | ||
|
|
60f914894d | ||
|
|
7503329462 | ||
|
|
4186888067 | ||
|
|
8fce0ef2f2 | ||
|
|
1e43cfe72a | ||
|
|
dd9addbb94 | ||
|
|
d4540a08f8 | ||
|
|
99b619bcaa | ||
|
|
9cc264b867 | ||
|
|
4a7eacdb64 | ||
|
|
7c00afb201 | ||
|
|
9f184bb20a | ||
|
|
c4c2760374 | ||
|
|
f118890485 | ||
|
|
ca8dd4ab74 | ||
|
|
bdb08d0d98 | ||
|
|
70a14dc902 | ||
|
|
1dbc79387b | ||
|
|
74d41880b5 | ||
|
|
7853644811 | ||
|
|
b1ed54303c | ||
|
|
d3dbc1e132 | ||
|
|
f211991862 | ||
|
|
3e774999da | ||
|
|
8545a85d5d | ||
|
|
396b0700f5 | ||
|
|
dbaf3079f5 | ||
|
|
293787a9ff | ||
|
|
0dd2a82d24 | ||
|
|
6957e9a07d | ||
|
|
aaf43491ab | ||
|
|
63ddef635d | ||
|
|
8ac9f1e2a4 | ||
|
|
2fae3831be | ||
|
|
b8cba121a9 | ||
|
|
1de63b9cc3 | ||
|
|
3e85378bc2 | ||
|
|
cbae8df738 |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="id">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">id</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Hosted login pages for Hanzo IAM - configurable per organization</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,78 +0,0 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm i -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build for Cloudflare Pages
|
||||
run: |
|
||||
# First pass: builds Next.js via Vercel, may fail on _not-found Node.js function
|
||||
npx @cloudflare/next-on-pages 2>&1 || true
|
||||
# Patch out _not-found (Next.js 15 generates it as Node.js even with edge runtime)
|
||||
node scripts/patch-not-found.mjs
|
||||
# Second pass: convert pre-built output (skip build step)
|
||||
npx @cloudflare/next-on-pages --skip-build
|
||||
# Verify functions were actually generated (catches silent build failures)
|
||||
if ! ls .vercel/output/static/_worker.js/__next-on-pages-dist__/functions/*.func.js 1>/dev/null 2>&1; then
|
||||
echo "::error::Build failed — no edge functions generated. Check for TypeScript errors above."
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Functions verified:"
|
||||
ls .vercel/output/static/_worker.js/__next-on-pages-dist__/functions/
|
||||
|
||||
- name: Fetch CF credentials from KMS
|
||||
id: kms
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
HANZO_API_KEY: ${{ secrets.HANZO_API_KEY }}
|
||||
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
|
||||
run: |
|
||||
# Auth: Universal Auth (preferred) or legacy API key
|
||||
if [ -n "${KMS_CLIENT_ID}" ] && [ -n "${KMS_CLIENT_SECRET}" ]; then
|
||||
HANZO_API_KEY=$(curl -sf "${KMS_ENDPOINT}/api/v1/auth/universal-auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" | jq -r '.accessToken')
|
||||
fi
|
||||
response=$(curl -sf "${KMS_ENDPOINT}/api/v3/secrets/raw?workspaceId=e1359bf4-31b4-4dfa-bb90-323e2c298ad8&secretPath=/deploy&environment=prod" \
|
||||
-H "Authorization: Bearer ${HANZO_API_KEY}" 2>/dev/null || echo "")
|
||||
if [ -n "$response" ]; then
|
||||
cf_token=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="CLOUDFLARE_API_TOKEN") | .secretValue // empty')
|
||||
cf_account=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="CLOUDFLARE_ACCOUNT_ID") | .secretValue // empty')
|
||||
fi
|
||||
if [ -z "${cf_token:-}" ]; then
|
||||
echo "::error::CF credentials not found in KMS."
|
||||
exit 1
|
||||
fi
|
||||
echo "::add-mask::${cf_token}"
|
||||
echo "::add-mask::${cf_account}"
|
||||
echo "cf_token=${cf_token}" >> "$GITHUB_OUTPUT"
|
||||
echo "cf_account=${cf_account}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ steps.kms.outputs.cf_token }}
|
||||
accountId: ${{ steps.kms.outputs.cf_account }}
|
||||
packageManager: npm
|
||||
command: pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true
|
||||
@@ -1,38 +1,59 @@
|
||||
name: Docker
|
||||
|
||||
# Self-contained build on Hanzo self-hosted runners. The shared reusable
|
||||
# workflow (hanzoai/.github docker-build.yml@main) is currently failing graph
|
||||
# validation for every caller (org-wide startup_failure), so this repo builds
|
||||
# its own image directly. Cluster nodes are linux/amd64 → build that arch.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
push:
|
||||
branches: [main, dev, test]
|
||||
tags: ['v*']
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
jobs:
|
||||
build-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
docker:
|
||||
# Target the ARC scale set by NAME (the org convention, cf. hanzoai/iam).
|
||||
# A label array like [self-hosted, linux, amd64] matches the OFFLINE classic
|
||||
# `evo-*` runners instead of the live ephemeral scale set, so every run
|
||||
# queued forever. ARC scale-set jobs route on the exact scaleSetName.
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
# The ARC runner image ships git + docker but NO node, so read the
|
||||
# version from package.json with portable shell (sed), not `node -p`.
|
||||
SHA="sha-$(git rev-parse --short HEAD)"
|
||||
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -1)"
|
||||
if [ -z "$VER" ]; then echo "could not parse version from package.json" >&2; exit 1; fi
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "ver=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Tags: $SHA, $VER"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/hanzoai/hanzo-login
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=,suffix=,format=short
|
||||
type=semver,pattern={{version}}
|
||||
- uses: docker/build-push-action@v6
|
||||
|
||||
- name: Build and push (linux/amd64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/hanzoai/id:${{ steps.tags.outputs.sha }}
|
||||
ghcr.io/hanzoai/id:${{ steps.tags.outputs.ver }}
|
||||
cache-from: type=gha,scope=hanzoai-id
|
||||
# mode=min (not max): the ARC runner builds via DinD on a 32G node;
|
||||
# exporting every intermediate layer (mode=max) ballooned the DinD
|
||||
# store past the kubelet eviction threshold and killed builds with
|
||||
# "no space left". min caches only the final image layers.
|
||||
cache-to: type=gha,scope=hanzoai-id,mode=min
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
name: Workflow Sanity
|
||||
on:
|
||||
pull_request:
|
||||
paths: ['.github/workflows/**']
|
||||
jobs:
|
||||
sanity:
|
||||
uses: hanzoai/.github/.github/workflows/workflow-sanity.yml@main
|
||||
@@ -7,3 +7,8 @@ out/
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
# Vite + pnpm monorepo
|
||||
apps/*/dist
|
||||
pkgs/*/dist
|
||||
**/tsconfig.tsbuildinfo
|
||||
**/node_modules
|
||||
|
||||
@@ -1,54 +1,29 @@
|
||||
FROM node:22-alpine AS base
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Hanzo ID — Vite SPA built once, served by hanzoai/spa.
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /build
|
||||
ENV PNPM_HOME=/pnpm PATH=$PNPM_HOME:$PATH
|
||||
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
COPY pnpm-workspace.yaml package.json tsconfig.base.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY pkgs/shared/package.json pkgs/shared/
|
||||
COPY pkgs/auth/package.json pkgs/auth/
|
||||
COPY pkgs/connect/package.json pkgs/connect/
|
||||
COPY pkgs/idv/package.json pkgs/idv/
|
||||
COPY pkgs/onboarding/package.json pkgs/onboarding/
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
|
||||
# --- Build ---
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build args become env vars at build time (for white-label forks)
|
||||
ARG NEXT_PUBLIC_IAM_URL
|
||||
ARG NEXT_PUBLIC_ORG
|
||||
ARG NEXT_PUBLIC_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_APP_NAME
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
# --- Production ---
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
COPY apps apps
|
||||
COPY pkgs pkgs
|
||||
RUN pnpm --filter @hanzo/id-web build
|
||||
|
||||
# SPA server stage — hanzoai/spa is the correct base for a Vite SPA:
|
||||
# history-API fallthrough for client-side routes AND a SPA-safe CSP.
|
||||
# hanzoai/static defaults to `Content-Security-Policy: default-src 'none'`
|
||||
# (built for static assets, not an app that loads its own bundle), which
|
||||
# blocks the SPA's own scripts and leaves a blank page. hanzoai/spa serves
|
||||
# index.html for all routes with a sane CSP. Defaults: PORT=3000, ROOT=/public.
|
||||
FROM ghcr.io/hanzoai/spa:1.2.0
|
||||
COPY --from=build /build/apps/web/dist /public
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Runtime env vars for white-label configuration:
|
||||
# IAM_ORIGIN — IAM backend URL (default: https://iam.hanzo.ai)
|
||||
# NEXT_PUBLIC_IAM_URL — Same, for client-side
|
||||
# NEXT_PUBLIC_ORG — Organization name (default: hanzo)
|
||||
# NEXT_PUBLIC_CLIENT_ID — Default app client ID
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,38 +1,456 @@
|
||||
# LLM.md - Hanzo Id
|
||||
# LLM.md — Hanzo ID
|
||||
|
||||
## Overview
|
||||
White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC
|
||||
## Provider-hint auto-federation — click GitHub/Google downstream, land straight in the provider (0.2.6)
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: TypeScript/JavaScript
|
||||
Clicking "Continue with GitHub/Google" on a downstream app (console.hanzo.ai)
|
||||
used to bounce the user to the hanzo.id login FORM — the portal ignored the
|
||||
provider the user already chose. Now it launches that provider immediately.
|
||||
Three fixes, all reusing the EXISTING `social.ts` hop (no duplicated IdP config);
|
||||
verified live in-browser (`?provider_hint=provider-github` → github.com,
|
||||
`provider-google` → accounts.google.com, both `redirect_uri=iam.hanzo.ai/callback`,
|
||||
`method=signup`, single `provider=`).
|
||||
|
||||
1. **`Login.tsx` honors `provider_hint`.** The console SDK already appends
|
||||
`&provider_hint=provider-github` (the IAM record `name`) to the authorize
|
||||
redirect. A new `federate` phase: after a silent-SSO miss, if the hint is
|
||||
present it renders a HEADLESS `<SocialButtons autoStart={hint}>` that
|
||||
auto-runs the same hop the button runs — no form flash — and drops to the
|
||||
form only if the hint matches no configured provider. Honor ONLY
|
||||
`provider_hint`, never bare `provider=` (that carries the SSO SDK's
|
||||
`<org>-iam` IDP hint — see the single-provider-state note below).
|
||||
`matchProviderHint` (`social.ts`, pure + tested) maps the hint to a provider
|
||||
by record name / key (`provider-github` or `github`, case-insensitive).
|
||||
|
||||
2. **`getAppLogin` validates against the DOWNSTREAM app's own redirect_uri.**
|
||||
`getAppLogin(clientId, redirectUri?)` now sends the incoming OIDC
|
||||
`redirect_uri` (read from the query), not the hardcoded
|
||||
`${publicOrigin}/callback`. IAM validates it against the app's registered
|
||||
list: `hanzo-cloud` (the console's client_id) registers
|
||||
`console.hanzo.ai/auth/callback` but NOT `hanzo.id/callback`, so the old
|
||||
hardcode made `CheckOAuthLogin` answer `status:error` ("Redirect URI …
|
||||
doesn't exist in the allowed list") and the SPA dropped the whole response
|
||||
(`status!=='ok'`→null) → NO providers resolved. This blocked the social
|
||||
buttons for EVERY cross-app SSO read, not just auto-federation. `SocialButtons`
|
||||
reads `redirect_uri` from `window.location.search` (same pattern as the wallet
|
||||
path); absent (bare portal / device flow) it defaults to the portal callback.
|
||||
|
||||
3. **The interactive hop uses `method=signup` (find-or-create-login).** IAM runs
|
||||
find-or-create only under `signup` (`controllers/auth.go:1041`: existing
|
||||
3rd-party identity → sign in, else create). `signin` is the account-LINK
|
||||
branch (`auth.go:1257`: `GetSessionUsername()==''` → `ResponseError("user
|
||||
doesn't exist")`) — it needs a live session and errors on a fresh "Continue
|
||||
with GitHub". The old `intent==='signup'?'signup':'signin'` sent `signin` from
|
||||
the sign-in page; both intents now use `signup` (intent only changes button
|
||||
copy). Latent bug — never exercised end-to-end before this.
|
||||
|
||||
Contracts locked in `pkgs/auth/src/{social,client}.test.ts`. Deploy: image
|
||||
`ghcr.io/hanzoai/id:0.2.6`, operator CR `universe/infra/k8s/operator/crs/id.yaml`
|
||||
(the `id` CR did not exist in-cluster before — id ran off the raw
|
||||
`deployment.yaml`; applying the CR handed the Deployment to the operator, safe
|
||||
because id carries no env, only `envFrom: id-tenant-catalog`). `id` is NOT in the
|
||||
gitops-reconcile allowlist → apply the CR by hand. Rides on 0.2.5 forced-MFA.
|
||||
|
||||
## Silent SSO must be org-scoped — admin-guard god-mode fix (fixed 0.2.2 → 0.2.3)
|
||||
|
||||
`admin.hanzo.ai` (global-admin console) sits behind admin-guard, a Traefik
|
||||
ForwardAuth that allows ONLY `owner == admin` tokens. Login rides
|
||||
`client_id=hanzo-admin-guard` (org=admin). The 0.2.2 credential-form fix
|
||||
(`LoginForm` posts `app.organization` from `get-app-login` → org=admin →
|
||||
IAM resolves `admin/z`, owner=admin) is CORRECT and verified: posting
|
||||
`organization=admin` to `/v1/iam/login` resolves the admin/* row and
|
||||
`get-account` returns owner=admin.
|
||||
|
||||
But it "didn't take effect end-to-end" because `Login.tsx` (0.2.2) added a
|
||||
`silentLogin` SSO fast-path (`canSilent = client_id && redirect_uri`) that,
|
||||
on mount, minted an auth code from the AMBIENT `iam_session_id` session
|
||||
REGARDLESS of that session's org. Real operators carry a hanzo/* session
|
||||
(from hanzo.chat/console), so silentLogin minted an owner=hanzo code and
|
||||
the guard bounced them to console.hanzo.ai — the org-scoped form was never
|
||||
shown. Silent SSO shadowed the form fix.
|
||||
|
||||
Fix (`pkgs/auth/src/client.ts`, `silentLogin`): silent SSO may reuse the
|
||||
ambient session ONLY when its user org == the app's org. `silentLogin` now
|
||||
resolves the app org (`get-app-login`) + session owner (`get-account`,
|
||||
new internal `sessionOwner()` helper) before minting; on no session or an
|
||||
org mismatch it returns `{}` so `Login.tsx` falls back to the interactive
|
||||
form (which authenticates in the app's own org). Same-org SSO (the common
|
||||
case: hanzo session → hanzo app) still mints silently — no UX change.
|
||||
Cross-org (hanzo session → admin-guard) → form → org=admin → owner=admin →
|
||||
god-mode. Non-admins (e.g. Dave) never reach god-mode: the guard validates
|
||||
`owner==admin` server-side, so this is availability (admins get IN), not a
|
||||
privilege boundary — the fix is client-side and cannot admit a non-admin.
|
||||
IAM (`iam:v1.31.14`) is unchanged; the SSO-ATO exact-match redirect fix
|
||||
(d7648965) is untouched.
|
||||
|
||||
## Social login (GitHub/Google) — single-provider state + matched redirect_uri (fixed 0.1.24 → 0.1.25)
|
||||
|
||||
The social hop used to fail at the IAM `/callback` exchange — GitHub with
|
||||
**"The provider: hanzo-iam does not exist"**, Google with "password or code is
|
||||
incorrect". TWO independent bugs, both in the SPA's provider-name handling; the
|
||||
IAM backend, the OAuth creds, and the registered redirect_uri were all fine.
|
||||
|
||||
**Bug A — wrong provider IDENTITY (fixed 0.1.24).** `parseAppLogin`
|
||||
(`pkgs/auth/src/client.ts`) read the **outer** IAM app-provider LINK `name`
|
||||
as the provider identity. `get-app-login` returns each provider as a link object
|
||||
`{name, canSignIn, …, provider:{name, type, clientId, …}}`; the REAL identity is
|
||||
the nested `provider.name` (e.g. `provider-github`), the name the backend
|
||||
resolves with `GetProvider(admin/<name>)`. The outer link `name` can be a
|
||||
per-app label. Fix: derive from the **nested** `rec.provider.name`, falling back
|
||||
to the outer `rec.name` only when there is no nested record.
|
||||
|
||||
**Bug B — TWO `provider=` in the state (fixed 0.1.25).** The console→hanzo.id
|
||||
SSO SDK appends `provider=hanzo-iam` (its per-org IDP hint) to the upstream
|
||||
`/login/oauth/authorize` query. `social.ts::buildProviderAuthUrl` then appends
|
||||
the REAL social `provider=provider-google`, so the base64 `state` carried BOTH.
|
||||
`Callback.tsx` recovers the provider with `URLSearchParams.get('provider')` —
|
||||
which returns the **FIRST** match (`hanzo-iam`), NOT the last — so the exchange
|
||||
POSTed `provider=hanzo-iam` and the backend rejected it. (The earlier "backend
|
||||
reads the LAST param" note was WRONG: the SPA reads the first.) Fix:
|
||||
`buildProviderAuthUrl` strips any pre-existing `provider=` from the upstream
|
||||
query (`baseQ.delete('provider')`) before appending the real one, so the state
|
||||
carries **exactly ONE** `provider=`. One provider, one source of truth — no
|
||||
reliance on parameter ordering. Locked in `pkgs/auth/src/social.test.ts`
|
||||
("a pre-existing provider= … is stripped — state carries exactly ONE provider").
|
||||
|
||||
**redirect_uri consistency (hardened 0.1.25).** The hop builds the provider
|
||||
`redirect_uri` from `tenant.oauthCallbackOrigin` (the provider's REGISTERED
|
||||
callback host, `https://iam.hanzo.ai`, shared across brand portals).
|
||||
`client.providerLogin` POSTs that redirect_uri to IAM, which forwards it
|
||||
**verbatim** to the provider's token endpoint (`auth.go` →
|
||||
`GetIdProvider(idpInfo, authForm.RedirectUri)` → `Config.Exchange`); a mismatch
|
||||
→ `invalid_grant`. It now derives from the SAME `oauthCallbackOrigin` (was
|
||||
`publicOrigin`, the brand host — equal on the callback host today, but they
|
||||
diverge whenever a brand shares the iam.hanzo.ai client). Locked in
|
||||
`client.test.ts` ("providerLogin posts redirectUri from oauthCallbackOrigin").
|
||||
|
||||
**RC#2 verified live (no Google account needed).** A junk-code probe of the live
|
||||
social path — `POST iam.hanzo.ai/v1/iam/login` with `provider=provider-google` +
|
||||
a fake code — returns `oauth2: "invalid_grant"`, NOT `invalid_client`. That
|
||||
proves Google **accepted** the client_id + client_secret and the
|
||||
`https://iam.hanzo.ai/callback` redirect_uri (client auth passed; only the fake
|
||||
code was rejected). So the Google clientId/secret in the running IAM are real
|
||||
and valid, the provider resolves + is enabled for `hanzo-console`, and the
|
||||
redirect_uri matches — the exchange will complete with a real Google code. No
|
||||
OAuth developer-console change is needed. The only thing not exercisable without
|
||||
a real Google/zoo.ngo account is the final code→user round-trip itself.
|
||||
|
||||
## Silent single sign-on — auto-continue authorize from the issuer session (0.1.26)
|
||||
|
||||
Sign in ONCE at the portal; every other app that authenticates through the same
|
||||
issuer host then logs in with NO form and NO credential re-entry. The IAM
|
||||
backend already supported this — its `Login` handler
|
||||
(`hanzoai/iam controllers/auth.go`) has an "already signed in to IAM" branch:
|
||||
when the request carries the `iam_session_id` cookie but NO username/password
|
||||
and NO provider, `GetSessionUsername()` is non-empty so it mints an
|
||||
authorization code via `HandleLoggedIn` straight from the session. The missing
|
||||
leg was the SPA: `Login.tsx` always rendered the login form, even with a live
|
||||
session.
|
||||
|
||||
The fix wires the SPA leg. `Login.tsx` now, when an app sends the user to the
|
||||
authorize page (`client_id` + `redirect_uri` on the query), first calls
|
||||
`client.silentLogin(...)` — a credential-less POST to `/v1/iam/login`
|
||||
(`type:code`, `application`, NO username/password, `credentials:'include'`). If a
|
||||
live issuer session exists IAM returns the code and the SPA redirects straight
|
||||
back to the app (`redirect_uri?code=…&state=…`); if there is no session IAM
|
||||
answers `status:error` and the SPA falls back to the interactive form — never a
|
||||
dead end. A bare portal visit (no `client_id`/`redirect_uri`) has nowhere to
|
||||
redirect, so it shows the form immediately as before.
|
||||
|
||||
In Hanzo IAM the OAuth `client_id` IS the application name (`hanzo-console`,
|
||||
`hanzo-chat`, …), so the SPA passes `application = client_id` with no extra
|
||||
lookup. The silent leg rides the SAME `HandleLoggedIn` as password/social, so it
|
||||
inherits the social-login `client_id`-resolution fix (`iam` ≥ v1.25.3) — without
|
||||
it the silently-minted code would carry an empty `client_id` and fail the
|
||||
downstream token exchange with `invalid_client`.
|
||||
|
||||
Proven end-to-end against live IAM (no UI needed): a session-cookie-only POST to
|
||||
`/v1/iam/login` returns `status:ok` + a code, and that code exchanges at
|
||||
`/v1/iam/oauth/access_token` (with the PKCE verifier) for a real
|
||||
`access_token`+`id_token`+`refresh_token` — i.e. an existing session silently
|
||||
yields a usable token for another app. Contract locked in
|
||||
`pkgs/auth/src/client.test.ts` ("silentLogin posts NO credentials and redirects
|
||||
with the minted code"; "returns { error } when there is no session").
|
||||
|
||||
Issuer-host note: SSO shares the `iam_session_id` cookie across apps only when
|
||||
they funnel auth through the SAME login host. Today both console
|
||||
(`IAM_SERVER_URL=iam.hanzo.ai`, which 302s to hanzo.id) and chat
|
||||
(`OPENID_ISSUER=hanzo.id`) land on the hanzo.id SPA, where the cookie lives — so
|
||||
the session is shared. `chat` has `OPENID_AUTO_REDIRECT=false`, so the user
|
||||
clicks "Log in with Hanzo" once (provider selection, not a credential prompt);
|
||||
set it `true` for fully zero-click entry into chat.
|
||||
|
||||
## Org-wide unified login — declare the provider set once per org (iam)
|
||||
|
||||
Enabling the same social/SSO set across an org's many apps is now ONE place:
|
||||
the org-level `Organization.DefaultProviders` (hanzoai/iam
|
||||
`object/organization.go`). Any application whose own `Providers` list is EMPTY
|
||||
inherits the org's `DefaultProviders` — resolved in the single app-read path
|
||||
`extendApplicationWithProviders` (`object/application.go`), so every app shares
|
||||
one provider set without repeating it. An app may still pin its own `Providers`
|
||||
to override. `init_data` adopts `DefaultProviders` onto an existing org
|
||||
additively (`initDefinedOrganization`, `initDataNewOnly` — like languages),
|
||||
never overwriting. The seed (`universe/infra/k8s/iam/init_data.json`) declares
|
||||
`defaultProviders` once per org and leaves per-app `providers: []`.
|
||||
|
||||
**Enable unified login for a NEW org/tenant:** add the org to `init_data.json`
|
||||
with `defaultProviders: [provider-github, provider-google, provider-web3,
|
||||
provider-apple]` (or any subset) and leave each app's `providers: []`. All apps
|
||||
inherit automatically — no per-app reconfiguration.
|
||||
|
||||
## Org-agnostic password login (fixed 0.1.23)
|
||||
|
||||
The portal login is now **org-agnostic**: it no longer pins
|
||||
`organization=<brand>` on `POST /v1/iam/login`. `LoginForm` passes
|
||||
`tenant.loginOrg` (a NEW, normally-UNSET `TenantConfig` field) as the
|
||||
`organization`, and `client.login()` OMITS the field entirely when it is
|
||||
empty/undefined. With no org posted, IAM runs its **cross-org resolution**
|
||||
(`object.GetUserByFields` → `GetUserByFieldCrossOrg`) and the session encodes the
|
||||
user's REAL owner-org (`GetOrganizationByUser`), never the posted hint.
|
||||
|
||||
Why this matters (the bug it fixes): IAM's `IsGlobalAdmin()` is
|
||||
`user.Owner == "admin"` (it ignores the stored `isGlobalAdmin` column), and
|
||||
`get-organizations` returns ALL orgs only for a global admin, else just the
|
||||
caller's own org. The seeded superusers (`z@hanzo.ai`, `a@hanzo.ai`,
|
||||
`woo@lux.network`) exist in BOTH the `admin` org (the global identity) AND their
|
||||
brand org (`hanzo/lux`). Pinning `organization=hanzo` made `GetUserByFields`
|
||||
hit the colliding `hanzo/z` row FIRST (in-org lookup succeeds → cross-org
|
||||
fallback never runs), so an admin got a 1-org `hanzo` session via the UI even
|
||||
though the API could reach the 45-org global session. Omitting the org makes the
|
||||
in-org lookups miss → cross-org fallback → `admin/z` (global) for the colliding
|
||||
hanzo-domain emails, while a brand-only identity (`z@lux.network`,
|
||||
`major@hanzo.ai`, …) still resolves to its own org. Verified live on
|
||||
`hanzo.id`: `z@hanzo.ai` → `owner=admin`, 45 orgs; a brand-only user → 1 org.
|
||||
|
||||
Boundaries (do NOT regress):
|
||||
- **Signup** still sends a concrete `organization` (`tenant.orgId`) — you cannot
|
||||
create a user in "no org". Only LOGIN omits it.
|
||||
- **Per-app SSO** (console/chat/team pass their own `client_id` + `redirect_uri`)
|
||||
is unaffected: `type=code` + the app's client_id still flow; the auth code is
|
||||
bound to the cross-org-resolved user. Proven against live IAM with
|
||||
`application=hanzo-console`.
|
||||
- A brand that deliberately wants single-org portal login can set `loginOrg` in
|
||||
its runtime catalog entry (`id-tenant-catalog` ConfigMap) — no rebuild.
|
||||
- Contract locked in `pkgs/auth/src/client.test.ts` (omit-when-unset,
|
||||
omit-when-empty, include-when-set, SSO still omits, signup still sends).
|
||||
|
||||
## PKCE on password login (fixed 0.1.13)
|
||||
|
||||
`client.login()` (POST `/v1/iam/login`) must forward `code_challenge`
|
||||
/`code_challenge_method` on the QUERY string, exactly like `authorize()`.
|
||||
IAM's Login handler (`hanzoai/iam` `controllers/account.go`) reads
|
||||
`code_challenge` from the query first, body fallback, then threads it into
|
||||
`GetOAuthCode` so the minted code stores the challenge. Omitting it makes
|
||||
IAM store an EMPTY challenge; the downstream public SPA client (e.g.
|
||||
`hanzo-platform`) then fails token exchange with
|
||||
`token.CodeChallenge: empty` → `invalid_client` (it falls back to a
|
||||
client_secret check the public client can't satisfy — see
|
||||
`object/token_oauth.go:809-844`: with a non-empty stored challenge AND no
|
||||
client_secret sent, the secret check is bypassed). Social login was never
|
||||
affected (it rides `authorize()`). Plumb the URL's `code_challenge`
|
||||
through Login page → LoginForm → `client.login()`; do NOT default a
|
||||
challenge — only forward what the downstream OAuth request put on the URL.
|
||||
|
||||
Known SEPARATE blocker (NOT this repo): after a 200 token exchange,
|
||||
platform's `/v1/iam/session` (`hanzo/platform` `pkg/platform/src/lib/iam.ts`
|
||||
→ `@hanzo/iam/server` `getServerSession`) returns 401 "Invalid IAM token"
|
||||
for a valid `aud:[hanzo-platform]` RS256 JWT signed by `cert-hanzo` (key IS
|
||||
in hanzo.id JWKS; the duplicate `cert-hanzo` entry is identical, harmless).
|
||||
That is a platform/SDK-side verification bug, tracked separately.
|
||||
|
||||
## What this is
|
||||
|
||||
White-label login + identity verification portal. Vite SPA, served from
|
||||
the Hanzo K8s cluster, white-labels per hostname.
|
||||
|
||||
Replaces:
|
||||
- `~/work/hanzo/hanzo.id-worker` (Cloudflare Worker) — being decommissioned
|
||||
- `~/work/hanzo/id/legacy-nextjs/` (Next.js 15) — frozen, kept for diff
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
DNS → hanzo cluster ingress (129.212.164.5)
|
||||
hanzo.id ──┐
|
||||
lux.id ──┤ ─ TLS → ingress ─ K8s ─→ Service id ─→ Deployment id (2 replicas)
|
||||
zoo.id ──┤ (Traefik) ghcr.io/hanzoai/id:vX.Y.Z
|
||||
pars.id ──┘ │
|
||||
│ on boot:
|
||||
│ resolveTenant(hostname)
|
||||
│ loadBrand(tenant.brandPackage)
|
||||
▼
|
||||
@hanzo|luxfi|zooai|parsdao /brand
|
||||
│
|
||||
▼
|
||||
createAuthClient(tenant)
|
||||
│
|
||||
▼
|
||||
per-brand OIDC issuer host: hanzo.id / lux.id /
|
||||
zoo.id / pars.id (serves /.well-known + /v1/iam/*;
|
||||
same Hanzo IAM backend, tenant-scoped by org)
|
||||
│
|
||||
▼
|
||||
iam-* postgres in hanzo namespace
|
||||
```
|
||||
|
||||
`iamUrl` is the brand's OWN `*.id` host, NOT `iam.hanzo.ai` (HIP-0111:
|
||||
discovery must be host-relative or the SDK resolves to the IAM SPA HTML
|
||||
catch-all). One backend serves every brand behind its issuer host.
|
||||
|
||||
## Auth methods — the full set
|
||||
|
||||
Email/password + email/SMS code + GitHub + Google + Web3 (wallet). The enabled
|
||||
set is read LIVE from `/v1/iam/get-app-login` (`AuthClient.getAppLogin`), which
|
||||
mirrors each `-id` app's provider config in
|
||||
`universe/infra/k8s/iam/init_data.json`. Password sign-in goes through the IAM
|
||||
REST `login` and returns an auth code directly. Both honor a downstream
|
||||
`redirect_uri`.
|
||||
|
||||
### Social providers — render only when configured; redirect via the "hop"
|
||||
|
||||
`SocialButtons` renders ONLY providers IAM holds a REAL credential for
|
||||
(`AppProvider.configured` = non-placeholder clientId). With the seed's
|
||||
placeholders every social button is hidden, so a user never hits a dead-end;
|
||||
they reappear automatically once real creds land. Clicking a configured OAuth
|
||||
provider runs the **hop** (`social.ts::startProviderLogin`), which redirects
|
||||
straight to the provider with a base64 `state` that round-trips the original
|
||||
authorize request — matching the Hanzo IAM `getAuthUrl` contract. The
|
||||
provider returns to `/callback`; `Callback.tsx` detects the provider state and
|
||||
calls `client.providerLogin` to exchange the code at the IAM backend, then
|
||||
follows the continue-URL (which re-enters `/callback` as the normal OIDC code).
|
||||
(NOT `@hanzo/iam` `signinRedirect` — that loops back to the login page.)
|
||||
|
||||
**To ENABLE real social login (the only remaining work):**
|
||||
1. Register an OAuth app per provider (GitHub/Google) with callback
|
||||
**`https://<brand>/v1/iam/callback`** AND the app authorize redirect
|
||||
`https://<brand>/callback` (per brand host: hanzo.id, lux.id, pars.id …).
|
||||
2. Put the client id/secret in KMS at **project `hanzo-iam`, env `prod`**, keys
|
||||
`IAM_GITHUB_CLIENT_ID` / `IAM_GITHUB_CLIENT_SECRET` (and `IAM_GOOGLE_*`). The
|
||||
`iam-kms-sync` KMSSecret (`universe/infra/k8s/iam/secret.yaml`) syncs that
|
||||
path into `iam-secrets`; init_data.json substitutes `${IAM_GITHUB_CLIENT_ID}`
|
||||
at deploy. The whole sync + env-ref chain already exists — today those keys
|
||||
just hold placeholder values, so providers read as unconfigured (buttons
|
||||
hidden). Replace the values; nothing else to wire.
|
||||
3. The buttons appear automatically (no portal change). **Live-verify** the
|
||||
round-trip reaches the provider and completes — the hop + exchange are wired
|
||||
and unit-tested (`pkgs/auth/src/social.test.ts`) but can only be exercised
|
||||
end-to-end once real creds exist.
|
||||
|
||||
## Workspace
|
||||
|
||||
```
|
||||
apps/
|
||||
web/ @hanzo/id-web — Vite + React 19 + @hanzo/gui SPA
|
||||
pkgs/
|
||||
shared/ @hanzo/id-shared — TenantConfig, resolveTenant, loadBrand
|
||||
auth/ @hanzo/id-auth — composable login/signup/OTP/forgot forms +
|
||||
SocialButtons (GitHub/Google/Web3) +
|
||||
AuthClient (wraps @hanzo/iam REST + SDK PKCE)
|
||||
onboarding/ @hanzo/id-onboarding — post-login org → project → wallet flow.
|
||||
domain (serializable step machine) / service
|
||||
(IAM-backed writes) / ui (self-contained flow).
|
||||
Tests: `pnpm --filter @hanzo/id-onboarding test`
|
||||
(Node built-in runner, no test-framework dep).
|
||||
idv/ @hanzo/id-idv — pluggable identity verification
|
||||
(Persona, Onfido, Veriff, stub)
|
||||
legacy-nextjs/ Frozen predecessor. Delete after v0.1.0 ships.
|
||||
```
|
||||
|
||||
## Why this layout (and not just Next.js)
|
||||
|
||||
1. **Vite > Next.js for an SPA.** No SSR needed; auth is post-load only.
|
||||
~200kb gzip vs ~600kb. Build is 3s vs 90s.
|
||||
2. **@hanzo/gui v7 is the canonical UI.** Same shell as every other
|
||||
Hanzo admin surface. No bespoke components.
|
||||
3. **Per-org brand packages.** `@hanzo/brand`, `@luxfi/brand`,
|
||||
`@zooai/brand`, `@parsdao/brand` already ship `brand.json` files;
|
||||
we fetch them at runtime. Adding a brand = `pnpm add @newco/brand`
|
||||
+ one line in `pkgs/shared/src/tenant.ts` (or a runtime catalog
|
||||
entry — no rebuild).
|
||||
4. **Provider-pluggable IDV.** Same surface for Persona, Onfido, Veriff,
|
||||
custom backends. No vendor lock-in at the portal layer.
|
||||
5. **Mirrors downstream tenant id-app forks.** Same `apps/` + `pkgs/`
|
||||
pattern, same `@hanzo/gui` shell, same per-tenant brand resolution.
|
||||
|
||||
## Local dev
|
||||
|
||||
## Build & Run
|
||||
```bash
|
||||
pnpm install && pnpm build
|
||||
pnpm test
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:5173 → defaults to hanzo brand
|
||||
```
|
||||
|
||||
## Structure
|
||||
For multi-tenant preview:
|
||||
```
|
||||
id/
|
||||
Dockerfile
|
||||
LICENSE
|
||||
README.md
|
||||
app/
|
||||
components/
|
||||
config/
|
||||
lib/
|
||||
middleware.ts
|
||||
next-env.d.ts
|
||||
next.config.ts
|
||||
package.json
|
||||
pnpm-lock.yaml
|
||||
postcss.config.mjs
|
||||
public/
|
||||
scripts/
|
||||
echo "127.0.0.1 lux.id zoo.id pars.id" | sudo tee -a /etc/hosts
|
||||
```
|
||||
then visit `http://lux.id:5173` etc.
|
||||
|
||||
## Build + deploy
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
# Deploy is managed in hanzoai/universe (infra/k8s/id/): operator/kustomize
|
||||
# applies it; CI image bump flows via universe. App repo ships the
|
||||
# brand-neutral image + Dockerfile only — no deploy/brand config here.
|
||||
```
|
||||
|
||||
## Key Files
|
||||
- `README.md` -- Project documentation
|
||||
- `package.json` -- Dependencies and scripts
|
||||
- `Dockerfile` -- Container build
|
||||
## Adding a new brand
|
||||
|
||||
1. `pnpm add -F @hanzo/id-web @newco/brand`
|
||||
2. Add `TenantConfig` for the hostname in `pkgs/shared/src/tenant.ts`
|
||||
(or put it in `IAM_TENANT_CONFIG_JSON` at runtime — no rebuild).
|
||||
3. Add the hostname to `BRAND_PACKAGES` in `apps/web/vite.config.ts`.
|
||||
4. Add host + TLS secret to `apps/web/k8s/ingress.yaml`.
|
||||
5. DNS → cluster ingress IP.
|
||||
|
||||
## Plugging an IDV provider
|
||||
|
||||
```ts
|
||||
import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
|
||||
registerProvider(createPersonaProvider({ templateId, apiKey, environment: 'production' }))
|
||||
```
|
||||
|
||||
## Pre-built providers
|
||||
|
||||
| id | source | docs |
|
||||
|---|---|---|
|
||||
| `stub` | `@hanzo/id-idv/providers/stub` | in-memory, dev-only |
|
||||
| `persona` | `@hanzo/id-idv/providers/persona` | https://docs.withpersona.com |
|
||||
| `onfido` | `@hanzo/id-idv/providers/onfido` | https://documentation.onfido.com |
|
||||
| `veriff` | `@hanzo/id-idv/providers/veriff` | https://developers.veriff.com |
|
||||
|
||||
Custom providers: implement the `IDVProvider` interface in
|
||||
`pkgs/idv/src/provider.ts` and register it.
|
||||
|
||||
## Cutover from the CF Worker
|
||||
|
||||
1. Build + push image (PR scaffolds; image bump comes after CI green).
|
||||
2. `kubectl apply -k apps/web/k8s` — Deployment + Service + Ingress live.
|
||||
3. cert-manager issues TLS for all 4 hosts.
|
||||
4. Remove the Cloudflare Worker routes for the 4 identity hosts.
|
||||
5. Repoint CF A records: `hanzo.id`, `lux.id`, `zoo.id`, `pars.id` →
|
||||
`129.212.164.5` (hanzo cluster ingress LB), CF-proxied.
|
||||
6. Verify each host loads its own brand.
|
||||
7. Archive `hanzo.id-worker` repo.
|
||||
|
||||
## Backend
|
||||
|
||||
The Go IAM backend lives at `~/work/hanzo/iam` (Hanzo IAM, module
|
||||
`github.com/hanzoai/iam`, image `ghcr.io/hanzoai/iam`). All paths are under
|
||||
the `/v1/iam` prefix — no legacy `/oauth/*`, no `/api/`. This portal talks
|
||||
to it via:
|
||||
|
||||
- auth (`pkgs/auth/src/client.ts`): `/v1/iam/login` `/v1/iam/signup`
|
||||
`/v1/iam/send-verification-code` `/v1/iam/get-app-login`, and the OIDC
|
||||
PKCE endpoints `/v1/iam/oauth/{authorize,token,userinfo,logout}` (via the
|
||||
`@hanzo/iam` SDK).
|
||||
- onboarding (`pkgs/onboarding/src/service/onboarding.ts`):
|
||||
`/v1/iam/get-organizations` (allowed for any signed-in user, scoped to
|
||||
their memberships server-side), `/v1/iam/add-organization` +
|
||||
`/v1/iam/add-project` (admin-gated in IAM authz — the create path surfaces
|
||||
a permission message for non-admins and stays skippable),
|
||||
`/v1/iam/get-account` + `/v1/iam/update-user?columns=web3onboard` (wallet
|
||||
link).
|
||||
|
||||
All hostnames talk to the same IAM backend — the org is carried in the
|
||||
request body (`organization: <orgId>`), and the IAM backend tenant-scopes
|
||||
on that.
|
||||
|
||||
@@ -1,146 +1,75 @@
|
||||
# Hanzo ID - Hosted Login Pages
|
||||
<p align="center"><img src=".github/hero.svg" alt="id" width="880"></p>
|
||||
|
||||
Configurable, white-label login pages for Hanzo IAM. Each organization can customize their login experience based on their domain (CNAME).
|
||||
# @hanzo/id
|
||||
|
||||
## Architecture
|
||||
White-label login + identity verification portal. One Vite SPA, four hosts
|
||||
(`hanzo.id`, `lux.id`, `zoo.id`, `pars.id`), per-tenant brand resolved from
|
||||
the request hostname at runtime.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ hanzo.id │ │ pars.id │ │ lux.id │
|
||||
│ (CNAME) │ │ (CNAME) │ │ (CNAME) │
|
||||
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
└───────────────────┴───────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Hanzo ID │ ← This repo (frontend)
|
||||
│ (Next.js) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Hanzo IAM │ ← Backend auth services
|
||||
│ (Go API) │
|
||||
└─────────────┘
|
||||
apps/
|
||||
web/ Vite + React 19 + @hanzo/gui — the actual SPA
|
||||
k8s/ Deployment + Service + Ingress (4 hosts, 4 TLS secrets)
|
||||
pkgs/
|
||||
shared/ @hanzo/id-shared — TenantConfig + brand resolver
|
||||
auth/ @hanzo/id-auth — composable login/signup/OTP flows
|
||||
on top of @hanzo/iam SDK
|
||||
idv/ @hanzo/id-idv — pluggable identity verification
|
||||
(Persona, Onfido, Veriff, stub)
|
||||
legacy-nextjs/ Frozen — predecessor Next.js implementation. Kept
|
||||
for reference until v0.1.0 ships to production.
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Domain-based branding**: Logo, colors, content based on CNAME
|
||||
- **Configurable auth methods**: Password, code, WebAuthn, Face ID
|
||||
- **Social providers**: Google, GitHub, and more
|
||||
- **Customizable content**: Quotes, testimonials, feature highlights
|
||||
- **Dark mode by default**: Clean, modern design
|
||||
- **Easy to fork**: Simple structure for white-labeling
|
||||
|
||||
## Configuration
|
||||
|
||||
Branding can be configured in two ways:
|
||||
|
||||
### 1. Static Configuration (for known domains)
|
||||
|
||||
Edit `lib/branding.ts` to add your domain:
|
||||
|
||||
```typescript
|
||||
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
|
||||
'your-domain.com': {
|
||||
orgId: 'your-org',
|
||||
orgName: 'Your Organization',
|
||||
logo: '/logos/your-logo.svg',
|
||||
colors: {
|
||||
primary: '#3b82f6',
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Your App',
|
||||
subtitle: 'Sign in to continue',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Dynamic Configuration (from IAM backend)
|
||||
|
||||
The login page fetches branding from IAM API:
|
||||
|
||||
```
|
||||
GET https://api.hanzo.id/api/branding?domain=your-domain.com
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"orgId": "your-org",
|
||||
"orgName": "Your Organization",
|
||||
"logo": "https://...",
|
||||
"colors": { ... },
|
||||
"content": { ... },
|
||||
"links": { ... },
|
||||
"auth": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Start production server
|
||||
npm start
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:5173 (defaults to hanzo brand)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
To preview a different brand locally, edit `/etc/hosts`:
|
||||
|
||||
```
|
||||
127.0.0.1 lux.id zoo.id pars.id
|
||||
```
|
||||
|
||||
then visit `http://lux.id:5173`.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# IAM backend URL
|
||||
HANZO_IAM_URL=https://api.hanzo.id
|
||||
|
||||
# Public IAM URL (for client-side redirects)
|
||||
NEXT_PUBLIC_IAM_URL=https://api.hanzo.id
|
||||
pnpm build # builds apps/web -> dist/
|
||||
docker build -t ghcr.io/hanzoai/id:0.1.0 .
|
||||
```
|
||||
|
||||
## Forking for White-Label
|
||||
## Adding a brand
|
||||
|
||||
1. Fork this repository
|
||||
2. Update `lib/branding.ts` with your default branding
|
||||
3. Add your logo to `public/logos/`
|
||||
4. Update `app/globals.css` for custom styling
|
||||
5. Deploy to your infrastructure
|
||||
1. Publish or workspace-link the new per-org brand pkg (must ship
|
||||
`brand.json` at the package root and match the `BrandContract` shape
|
||||
in `pkgs/shared/src/types.ts`).
|
||||
2. Add a `DEFAULT_TENANTS` entry in `pkgs/shared/src/tenant.ts` OR put
|
||||
the override in the runtime catalog (`IAM_TENANT_CONFIG_JSON` env)
|
||||
so no rebuild is needed.
|
||||
3. Add the hostname to `apps/web/vite.config.ts::BRAND_PACKAGES`
|
||||
(lets dev + build serve `/brand/<pkg>/brand.json`).
|
||||
4. Add the hostname + TLS secret to `apps/web/k8s/ingress.yaml`.
|
||||
5. DNS: CNAME or A record → cluster ingress IP.
|
||||
|
||||
## Directory Structure
|
||||
That's it — no per-brand Worker, no per-brand image, no per-brand
|
||||
deployment. One binary, four brands.
|
||||
|
||||
```
|
||||
hanzo-id/
|
||||
├── app/
|
||||
│ ├── layout.tsx # Root layout with metadata
|
||||
│ ├── page.tsx # Redirects to /login
|
||||
│ ├── login/
|
||||
│ │ └── page.tsx # Main login page
|
||||
│ ├── signup/ # Sign up page
|
||||
│ ├── forgot-password # Password reset
|
||||
│ └── callback/ # OAuth callback handler
|
||||
├── components/
|
||||
│ ├── LoginForm.tsx # Login form component
|
||||
│ └── MarketingPanel.tsx # Right side marketing content
|
||||
├── lib/
|
||||
│ └── branding.ts # Branding configuration
|
||||
├── public/
|
||||
│ └── logos/ # Organization logos
|
||||
└── config/ # Additional configuration
|
||||
## Plugging an IDV provider
|
||||
|
||||
```ts
|
||||
import { registerProvider, createPersonaProvider } from '@hanzo/id-idv'
|
||||
registerProvider(createPersonaProvider({
|
||||
templateId: import.meta.env.VITE_PERSONA_TEMPLATE_ID,
|
||||
apiKey: import.meta.env.VITE_PERSONA_API_KEY,
|
||||
environment: 'production',
|
||||
}))
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT - Fork and customize freely!
|
||||
The portal stays unchanged — switching providers is a single registration
|
||||
call at boot.
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchUserInfo } from '@/lib/oauth'
|
||||
import { getIamUrl, getOrg } from '@/lib/iam'
|
||||
import { staticBranding, defaultBranding, resolveBrandingDomain, type BrandingConfig } from '@/lib/branding'
|
||||
|
||||
interface User {
|
||||
sub: string
|
||||
name?: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
// Per-org app links
|
||||
const orgApps: Record<string, { name: string; href: string; description: string }[]> = {
|
||||
hanzo: [
|
||||
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
|
||||
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
|
||||
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
|
||||
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
|
||||
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
|
||||
],
|
||||
lux: [
|
||||
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
|
||||
{ name: 'Exchange', href: 'https://lux.exchange', description: 'DEX trading' },
|
||||
{ name: 'Cloud', href: 'https://cloud.lux.network', description: 'Lux Cloud' },
|
||||
{ name: 'Explorer', href: 'https://explore.lux.network', description: 'Block explorer' },
|
||||
],
|
||||
zoo: [
|
||||
{ name: 'Network', href: 'https://zoo.ngo', description: 'Zoo Labs Foundation' },
|
||||
{ name: 'ZIPs', href: 'https://zips.zoo.ngo', description: 'Improvement proposals' },
|
||||
],
|
||||
pars: [
|
||||
{ name: 'Network', href: 'https://pars.network', description: 'Pars Network' },
|
||||
{ name: 'Foundation', href: 'https://parsis.foundation', description: 'Parsis Foundation' },
|
||||
],
|
||||
}
|
||||
|
||||
// Per-org billing URL
|
||||
function getBillingUrl(org: string): string {
|
||||
switch (org) {
|
||||
case 'lux': return 'https://billing.lux.network'
|
||||
case 'zoo': return 'https://billing.zoo.network'
|
||||
case 'pars': return 'https://billing.pars.network'
|
||||
default: return 'https://billing.hanzo.ai'
|
||||
}
|
||||
}
|
||||
|
||||
export default function AccountPage() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
|
||||
const domain = resolveBrandingDomain(host)
|
||||
const staticConfig = staticBranding[domain]
|
||||
const branding: BrandingConfig = staticConfig
|
||||
? { ...defaultBranding, ...staticConfig, domain }
|
||||
: { ...defaultBranding, domain }
|
||||
|
||||
const org = getOrg(host)
|
||||
const apps = orgApps[org] || orgApps.hanzo
|
||||
const billingUrl = getBillingUrl(org)
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
async function loadUser() {
|
||||
const token = localStorage.getItem('hanzo_access_token')
|
||||
if (!token) {
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
|
||||
// Try cached user first
|
||||
try {
|
||||
const cached = localStorage.getItem('hanzo_user')
|
||||
if (cached) {
|
||||
setUser(JSON.parse(cached))
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fetch fresh user info — use same-origin proxy to avoid CORS
|
||||
try {
|
||||
const userinfoUrl = window.location.origin + '/oauth/userinfo'
|
||||
const info = await fetchUserInfo(userinfoUrl.replace('/oauth/userinfo', ''), token)
|
||||
const userData: User = {
|
||||
sub: info.sub,
|
||||
name: info.name,
|
||||
displayName: info.displayName,
|
||||
email: info.email,
|
||||
avatar: info.avatar || info.permanentAvatar,
|
||||
}
|
||||
// Also try decoding id_token for richer claims
|
||||
if ((!userData.email || !userData.displayName) && localStorage.getItem('hanzo_id_token')) {
|
||||
try {
|
||||
const idToken = localStorage.getItem('hanzo_id_token')!
|
||||
const p = JSON.parse(atob(idToken.split('.')[1]))
|
||||
userData.email = userData.email || p.email
|
||||
userData.displayName = userData.displayName || p.displayName || p.name || p.preferred_username
|
||||
userData.name = userData.name || p.name || p.preferred_username
|
||||
userData.avatar = userData.avatar || p.avatar || p.picture || p.permanentAvatar
|
||||
} catch {}
|
||||
}
|
||||
setUser(userData)
|
||||
localStorage.setItem('hanzo_user', JSON.stringify(userData))
|
||||
} catch {
|
||||
// Token expired or invalid
|
||||
localStorage.removeItem('hanzo_access_token')
|
||||
localStorage.removeItem('hanzo_user')
|
||||
window.location.href = '/login'
|
||||
return
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('hanzo_access_token')
|
||||
localStorage.removeItem('hanzo_refresh_token')
|
||||
localStorage.removeItem('hanzo_user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
const cssVars = {
|
||||
'--color-primary': branding.colors.primary,
|
||||
'--color-primary-text': branding.colors.primaryText,
|
||||
'--color-background': branding.colors.background,
|
||||
'--color-surface': branding.colors.surface,
|
||||
'--color-text': branding.colors.text,
|
||||
'--color-text-muted': branding.colors.textMuted,
|
||||
'--color-border': branding.colors.border,
|
||||
'--color-error': branding.colors.error,
|
||||
} as React.CSSProperties
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
<div
|
||||
className="animate-spin w-8 h-8 border-2 border-zinc-700 rounded-full"
|
||||
style={{ borderTopColor: branding.colors.primary }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black" style={cssVars}>
|
||||
{/* Nav */}
|
||||
<nav className="flex items-center justify-between px-6 md:px-12 py-4 border-b border-zinc-800/50">
|
||||
<a href="/" className="flex items-center gap-3">
|
||||
<img src={branding.logo} alt={branding.orgName} className="h-8" />
|
||||
</a>
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href={billingUrl}
|
||||
className="text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Billing
|
||||
</a>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 md:px-12 py-12">
|
||||
{/* Profile header */}
|
||||
<div className="flex items-center gap-6 mb-12">
|
||||
{user?.avatar ? (
|
||||
<img src={user.avatar} alt="" className="w-20 h-20 rounded-full" />
|
||||
) : (
|
||||
<div
|
||||
className="w-20 h-20 rounded-full flex items-center justify-center text-3xl font-bold"
|
||||
style={{ backgroundColor: branding.colors.primary + '20', color: branding.colors.primary }}
|
||||
>
|
||||
{(user?.displayName || user?.name || user?.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">
|
||||
{user?.displayName || user?.name || 'User'}
|
||||
</h1>
|
||||
{user?.email && (
|
||||
<p className="text-zinc-400 mt-1">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Account info */}
|
||||
<div className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Account</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">User ID</div>
|
||||
<div className="text-white font-mono text-sm">{user?.sub}</div>
|
||||
</div>
|
||||
{user?.name && (
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Username</div>
|
||||
<div className="text-white">{user.name}</div>
|
||||
</div>
|
||||
)}
|
||||
{user?.email && (
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Email</div>
|
||||
<div className="text-white">{user.email}</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Organization</div>
|
||||
<div className="text-white">{branding.orgName}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing */}
|
||||
<div className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Billing & Usage</h2>
|
||||
<p className="text-zinc-400 text-sm mb-6">
|
||||
Manage your subscription, payment methods, and usage.
|
||||
</p>
|
||||
<a
|
||||
href={billingUrl}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
Manage Billing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apps */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">{branding.orgName} Apps</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{apps.map((app) => (
|
||||
<a
|
||||
key={app.name}
|
||||
href={app.href}
|
||||
className="p-4 rounded-xl border border-zinc-800 bg-zinc-900/30 hover:bg-zinc-900/60 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-white">{app.name}</span>
|
||||
<svg className="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">{app.description}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-12 pt-8 border-t border-zinc-800 flex items-center justify-between">
|
||||
<a
|
||||
href="/"
|
||||
className="text-sm text-zinc-500 hover:text-white transition-colors"
|
||||
>
|
||||
← Back to {branding.orgName}
|
||||
</a>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 transition-colors text-sm"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* OAuth bridge callback for downstream apps (Platform, MPC, etc.)
|
||||
*
|
||||
* Handles the code exchange on behalf of apps that need IAM tokens.
|
||||
* Decodes the state param to determine the redirect target.
|
||||
*
|
||||
* Usage:
|
||||
* GET /api/auth/bridge?code=...&state=base64({redirect,clientId,app})
|
||||
*
|
||||
* The state is a base64-encoded JSON object:
|
||||
* { redirect: "https://platform.hanzo.ai/login", clientId: "...", app: "platform" }
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getIamUrl } from '@/lib/iam'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
// Allowed redirect origins for security
|
||||
const ALLOWED_ORIGINS = [
|
||||
'https://platform.hanzo.ai',
|
||||
'https://console.hanzo.ai',
|
||||
'https://cloud.hanzo.ai',
|
||||
'https://mpc.hanzo.ai',
|
||||
'https://mpc.lux.network',
|
||||
'https://mpc.zoo.network',
|
||||
'https://mpc.pars.network',
|
||||
'https://commerce.hanzo.ai',
|
||||
'https://billing.hanzo.ai',
|
||||
'https://analytics.hanzo.ai',
|
||||
'https://insights.hanzo.ai',
|
||||
'https://hanzo.ai',
|
||||
'https://lux.id',
|
||||
'https://zoo.id',
|
||||
'https://pars.id',
|
||||
'https://hanzo.id',
|
||||
...(process.env.NODE_ENV !== 'production' ? [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:4000',
|
||||
'http://localhost:5173',
|
||||
] : []),
|
||||
]
|
||||
|
||||
function validateRedirectOrigin(redirect: string, fallback: string): string {
|
||||
try {
|
||||
const url = new URL(redirect)
|
||||
if (ALLOWED_ORIGINS.some(o => url.origin === new URL(o).origin)) {
|
||||
return redirect
|
||||
}
|
||||
} catch {}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const directAccessToken = url.searchParams.get('access_token')
|
||||
const directRefreshToken = url.searchParams.get('refresh_token')
|
||||
const host = url.hostname
|
||||
|
||||
const iamOrigin = getIamUrl(host)
|
||||
const iamHost = new URL(iamOrigin).host
|
||||
|
||||
const defaultRedirect = `${url.origin}/login`
|
||||
|
||||
// Reject oversized state
|
||||
if (state && state.length > 4096) {
|
||||
return NextResponse.redirect(`${url.origin}/login?error=invalid_state`)
|
||||
}
|
||||
|
||||
// Decode state for redirect target and client info
|
||||
let redirect = defaultRedirect
|
||||
let clientId = process.env.NEXT_PUBLIC_CLIENT_ID || 'hanzo-id'
|
||||
let codeVerifier = ''
|
||||
try {
|
||||
const decoded = JSON.parse(atob(state || ''))
|
||||
if (decoded.redirect) {
|
||||
redirect = validateRedirectOrigin(decoded.redirect, defaultRedirect)
|
||||
}
|
||||
if (decoded.clientId) {
|
||||
clientId = decoded.clientId
|
||||
}
|
||||
if (decoded.code_verifier) {
|
||||
codeVerifier = decoded.code_verifier
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const redirectUrl = new URL(redirect)
|
||||
|
||||
// Direct token passthrough (from implicit flow / password login)
|
||||
if (directAccessToken) {
|
||||
redirectUrl.searchParams.set('access_token', directAccessToken)
|
||||
redirectUrl.searchParams.set('refresh_token', directRefreshToken || '')
|
||||
redirectUrl.searchParams.set('provider', 'hanzo')
|
||||
redirectUrl.searchParams.set('status', '200')
|
||||
return NextResponse.redirect(redirectUrl.toString())
|
||||
}
|
||||
|
||||
// Authorization code exchange
|
||||
if (!code) {
|
||||
redirectUrl.searchParams.set('error', 'no_code')
|
||||
return NextResponse.redirect(redirectUrl.toString())
|
||||
}
|
||||
|
||||
const callbackUri = `${url.origin}/api/auth/bridge`
|
||||
const tokenPayload: Record<string, string> = {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: callbackUri,
|
||||
}
|
||||
|
||||
// Forward PKCE code_verifier if provided (prevents authorization code interception)
|
||||
if (codeVerifier) {
|
||||
tokenPayload.code_verifier = codeVerifier
|
||||
}
|
||||
|
||||
const clientSecret = process.env.IAM_CLIENT_SECRET || process.env.HANZO_IAM_CLIENT_SECRET
|
||||
if (clientSecret) {
|
||||
tokenPayload.client_secret = clientSecret
|
||||
}
|
||||
|
||||
const tokenRes = await fetch(`${iamOrigin}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Host': iamHost,
|
||||
},
|
||||
body: JSON.stringify(tokenPayload),
|
||||
})
|
||||
|
||||
const tokens = await tokenRes.json().catch(() => ({} as Record<string, unknown>))
|
||||
|
||||
if (tokens.access_token) {
|
||||
redirectUrl.searchParams.set('access_token', tokens.access_token as string)
|
||||
redirectUrl.searchParams.set('refresh_token', (tokens.refresh_token as string) || '')
|
||||
redirectUrl.searchParams.set(
|
||||
'expires_at',
|
||||
tokens.expires_in
|
||||
? String(Math.floor(Date.now() / 1000) + Number(tokens.expires_in))
|
||||
: '0',
|
||||
)
|
||||
redirectUrl.searchParams.set('provider', 'hanzo')
|
||||
redirectUrl.searchParams.set('status', '200')
|
||||
} else {
|
||||
redirectUrl.searchParams.set('error', (tokens.error as string) || 'token_exchange_failed')
|
||||
redirectUrl.searchParams.set(
|
||||
'error_description',
|
||||
(tokens.error_description as string) || (tokens.message as string) || 'Failed to exchange code',
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.redirect(redirectUrl.toString())
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* Server-side social OAuth callback handler.
|
||||
*
|
||||
* When a user logs in via Google/GitHub/etc, the social provider redirects
|
||||
* back to /callback with ?code=&state=. The IAM SPA callback relies on
|
||||
* sessionStorage which breaks through our proxy layer, so we handle the
|
||||
* full exchange server-side.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Decode state (base64 query string or JSON) to extract app/org/provider
|
||||
* 2. Read _oauth_ctx cookie as fallback context
|
||||
* 3. POST to IAM /api/login with type:'token' to complete the login
|
||||
* 4. Redirect to the original redirect_uri with tokens
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { resolveClient } from '@/lib/clients'
|
||||
import { getIamUrl } from '@/lib/iam'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
// Allowed redirect origins — must match bridge handler allowlist
|
||||
const ALLOWED_ORIGINS = [
|
||||
'https://platform.hanzo.ai',
|
||||
'https://console.hanzo.ai',
|
||||
'https://cloud.hanzo.ai',
|
||||
'https://mpc.hanzo.ai',
|
||||
'https://mpc.lux.network',
|
||||
'https://mpc.zoo.network',
|
||||
'https://mpc.pars.network',
|
||||
'https://commerce.hanzo.ai',
|
||||
'https://billing.hanzo.ai',
|
||||
'https://analytics.hanzo.ai',
|
||||
'https://insights.hanzo.ai',
|
||||
'https://hanzo.ai',
|
||||
'https://lux.id',
|
||||
'https://zoo.id',
|
||||
'https://pars.id',
|
||||
'https://hanzo.id',
|
||||
...(process.env.NODE_ENV !== 'production' ? [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:4000',
|
||||
'http://localhost:5173',
|
||||
] : []),
|
||||
]
|
||||
|
||||
function validateRedirectOrigin(redirect: string, fallback: string): string {
|
||||
try {
|
||||
const url = new URL(redirect)
|
||||
if (ALLOWED_ORIGINS.some(o => url.origin === new URL(o).origin)) {
|
||||
return redirect
|
||||
}
|
||||
} catch {}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const host = url.hostname
|
||||
|
||||
const iamOrigin = getIamUrl(host)
|
||||
const iamHost = new URL(iamOrigin).host
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(new URL('/login?error=missing_code_or_state', url.origin))
|
||||
}
|
||||
|
||||
// Reject oversized state to prevent abuse
|
||||
if (state.length > 4096) {
|
||||
return NextResponse.redirect(new URL('/login?error=invalid_state', url.origin))
|
||||
}
|
||||
|
||||
// Decode state — IAM encodes as base64 query string or JSON
|
||||
let stateParams = new URLSearchParams()
|
||||
let stateObj: Record<string, string> = {}
|
||||
try {
|
||||
const decoded = atob(state)
|
||||
if (decoded.startsWith('?') || decoded.includes('=')) {
|
||||
stateParams = new URLSearchParams(decoded)
|
||||
} else {
|
||||
stateObj = JSON.parse(decoded)
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.redirect(new URL('/login?error=invalid_state', url.origin))
|
||||
}
|
||||
|
||||
// Read _oauth_ctx cookie as fallback
|
||||
const cookieHeader = request.headers.get('cookie') || ''
|
||||
let oauthCtx: Record<string, string> = {}
|
||||
const ctxMatch = cookieHeader.match(/_oauth_ctx=([^;]+)/)
|
||||
if (ctxMatch) {
|
||||
try {
|
||||
oauthCtx = JSON.parse(atob(decodeURIComponent(ctxMatch[1])))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Resolve context from state (primary), cookie (secondary), JSON (tertiary)
|
||||
const application = stateParams.get('application') || oauthCtx.application || stateObj.application || ''
|
||||
const provider = stateParams.get('provider') || oauthCtx.provider || stateObj.provider || ''
|
||||
const method = stateParams.get('method') || stateObj.method || 'link'
|
||||
const stateClientId = stateParams.get('client_id') || oauthCtx.clientId || ''
|
||||
const originalRedirectUri = stateParams.get('redirect_uri') || oauthCtx.redirectUri || stateObj.redirectUri || ''
|
||||
|
||||
// Resolve organization from client map — ALWAYS prefer client map over untrusted sources
|
||||
// to prevent cross-tenant org bypass attacks
|
||||
let organization = ''
|
||||
if (stateClientId) {
|
||||
const client = resolveClient(stateClientId)
|
||||
if (client) organization = client.organization
|
||||
}
|
||||
// Only fall back to cookie/state if no client map match, and validate it's a known org
|
||||
if (!organization) {
|
||||
const KNOWN_ORGS = ['hanzo', 'lux', 'zoo', 'pars', 'zen', 'adnexus']
|
||||
const candidateOrg = oauthCtx.organization || stateObj.organization || ''
|
||||
if (KNOWN_ORGS.includes(candidateOrg)) {
|
||||
organization = candidateOrg
|
||||
}
|
||||
}
|
||||
|
||||
// Call IAM to complete the social login
|
||||
// type:'token' because our IAM version has a bug where type:'code'
|
||||
// maps to an empty grant_type and fails
|
||||
const loginRes = await fetch(`${iamOrigin}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Cookie': cookieHeader,
|
||||
'Host': iamHost,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'token',
|
||||
code,
|
||||
state: 'hanzo',
|
||||
redirectUri: `${url.origin}/callback`,
|
||||
application,
|
||||
organization,
|
||||
provider,
|
||||
method,
|
||||
}),
|
||||
})
|
||||
|
||||
const loginData = await loginRes.json().catch(() => ({} as Record<string, unknown>))
|
||||
|
||||
// Clear the oauth context cookie
|
||||
const clearCookie = '_oauth_ctx=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'
|
||||
|
||||
if (loginData.status === 'ok' && loginData.data) {
|
||||
// Validate redirect URI against allowlist to prevent open redirect
|
||||
const candidateRedirect = originalRedirectUri
|
||||
? originalRedirectUri.replaceAll(iamHost, host)
|
||||
: `${url.origin}/login`
|
||||
const targetRedirectUri = validateRedirectOrigin(candidateRedirect, `${url.origin}/login`)
|
||||
const targetUrl = new URL(targetRedirectUri)
|
||||
targetUrl.searchParams.set('access_token', loginData.data as string)
|
||||
targetUrl.searchParams.set('refresh_token', (loginData.data2 as string) || '')
|
||||
targetUrl.searchParams.set('provider', 'hanzo')
|
||||
targetUrl.searchParams.set('status', '200')
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': targetUrl.toString(),
|
||||
'Set-Cookie': clearCookie,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Error path — never include sensitive debug info in redirect params
|
||||
if (originalRedirectUri) {
|
||||
const candidateError = originalRedirectUri.replaceAll(iamHost, host)
|
||||
const safeErrorRedirect = validateRedirectOrigin(candidateError, `${url.origin}/login`)
|
||||
const errorUrl = new URL(safeErrorRedirect)
|
||||
errorUrl.searchParams.set('error', (loginData.msg as string) || 'social_login_failed')
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': errorUrl.toString(),
|
||||
'Set-Cookie': clearCookie,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': `${url.origin}/login?error=${encodeURIComponent((loginData.msg as string) || 'social_login_failed')}`,
|
||||
'Set-Cookie': clearCookie,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Server-side logout handler.
|
||||
*
|
||||
* Calls IAM to invalidate the session, clears cookies,
|
||||
* and redirects to the login page.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getIamUrl } from '@/lib/iam'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = new URL(request.url)
|
||||
const host = url.hostname
|
||||
const iamOrigin = getIamUrl(host)
|
||||
const iamHost = new URL(iamOrigin).host
|
||||
|
||||
const idTokenHint = url.searchParams.get('id_token_hint') || ''
|
||||
const postLogoutRedirectUri = url.searchParams.get('post_logout_redirect_uri') || `${url.origin}/login?prompt=login`
|
||||
const state = url.searchParams.get('state') || ''
|
||||
|
||||
// Call IAM logout
|
||||
const logoutUrl = new URL('/api/logout', iamOrigin)
|
||||
logoutUrl.searchParams.set('id_token_hint', idTokenHint)
|
||||
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri)
|
||||
logoutUrl.searchParams.set('state', state)
|
||||
|
||||
try {
|
||||
await fetch(logoutUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Host': iamHost,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: '/login?prompt=login',
|
||||
'Set-Cookie': 'iam_session_id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return GET(request)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { exchangeCode } from '@/lib/oauth'
|
||||
import { getIamUrl, getDefaultClientId } from '@/lib/iam'
|
||||
|
||||
/**
|
||||
* Claim a referral code after successful login/signup.
|
||||
* Fire-and-forget: never blocks redirect on failure.
|
||||
*/
|
||||
function claimReferral(accessToken: string, userId: string, email: string) {
|
||||
const refCode = sessionStorage.getItem('hanzo_ref_code')
|
||||
if (!refCode) return
|
||||
|
||||
sessionStorage.removeItem('hanzo_ref_code')
|
||||
|
||||
fetch('https://commerce.hanzo.ai/api/v1/referral/claim', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ code: refCode, userId, email }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function CallbackHandler() {
|
||||
const searchParams = useSearchParams()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
handleCallback()
|
||||
}, [])
|
||||
|
||||
async function handleCallback() {
|
||||
const errorParam = searchParams.get('error')
|
||||
if (errorParam) {
|
||||
setError(searchParams.get('error_description') || errorParam)
|
||||
return
|
||||
}
|
||||
|
||||
// Token passthrough from social login / bridge callback
|
||||
const accessToken = searchParams.get('access_token')
|
||||
if (accessToken) {
|
||||
localStorage.setItem('hanzo_access_token', accessToken)
|
||||
const refreshToken = searchParams.get('refresh_token')
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('hanzo_refresh_token', refreshToken)
|
||||
}
|
||||
const idToken = searchParams.get('id_token')
|
||||
if (idToken) {
|
||||
localStorage.setItem('hanzo_id_token', idToken)
|
||||
}
|
||||
|
||||
// Extract user info: prefer id_token (has full claims), fall back to access_token
|
||||
try {
|
||||
const idPayload = idToken
|
||||
? JSON.parse(atob(idToken.split('.')[1]))
|
||||
: null
|
||||
const atPayload = JSON.parse(atob(accessToken.split('.')[1]))
|
||||
const p = idPayload || atPayload
|
||||
localStorage.setItem('hanzo_user', JSON.stringify({
|
||||
sub: p.sub || atPayload.sub || atPayload.name,
|
||||
name: p.name || p.preferred_username || atPayload.name,
|
||||
displayName: p.displayName || p.name || p.preferred_username,
|
||||
email: p.email || atPayload.email,
|
||||
avatar: p.avatar || p.picture || p.permanentAvatar,
|
||||
}))
|
||||
claimReferral(accessToken, p.sub || atPayload.sub || atPayload.name, p.email || atPayload.email)
|
||||
} catch {}
|
||||
|
||||
const postLoginRedirect = sessionStorage.getItem('hanzo_auth_post_login_redirect')
|
||||
if (postLoginRedirect) {
|
||||
sessionStorage.removeItem('hanzo_auth_post_login_redirect')
|
||||
window.location.href = postLoginRedirect
|
||||
} else {
|
||||
window.location.href = '/account'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// PKCE authorization code flow
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
|
||||
if (!code || !state) {
|
||||
setError('Missing authorization code or state')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const host = window.location.hostname
|
||||
const iamUrl = getIamUrl(host)
|
||||
const clientId = getDefaultClientId(host)
|
||||
const redirectUri = `${window.location.origin}/callback`
|
||||
|
||||
const tokens = await exchangeCode({
|
||||
iamUrl,
|
||||
code,
|
||||
state,
|
||||
clientId,
|
||||
redirectUri,
|
||||
})
|
||||
|
||||
localStorage.setItem('hanzo_access_token', tokens.access_token)
|
||||
if (tokens.refresh_token) {
|
||||
localStorage.setItem('hanzo_refresh_token', tokens.refresh_token)
|
||||
}
|
||||
if (tokens.id_token) {
|
||||
localStorage.setItem('hanzo_id_token', tokens.id_token)
|
||||
}
|
||||
|
||||
// Extract user info: prefer id_token (has full claims), fall back to access_token
|
||||
try {
|
||||
const idPayload = tokens.id_token
|
||||
? JSON.parse(atob(tokens.id_token.split('.')[1]))
|
||||
: null
|
||||
const atPayload = JSON.parse(atob(tokens.access_token.split('.')[1]))
|
||||
const p = idPayload || atPayload
|
||||
localStorage.setItem('hanzo_user', JSON.stringify({
|
||||
sub: p.sub || atPayload.sub || atPayload.name,
|
||||
name: p.name || p.preferred_username || atPayload.name,
|
||||
displayName: p.displayName || p.name || p.preferred_username,
|
||||
email: p.email || atPayload.email,
|
||||
avatar: p.avatar || p.picture || p.permanentAvatar,
|
||||
}))
|
||||
claimReferral(tokens.access_token, p.sub || atPayload.sub || atPayload.name, p.email || atPayload.email)
|
||||
} catch {}
|
||||
|
||||
const postLoginRedirect = sessionStorage.getItem('hanzo_auth_post_login_redirect')
|
||||
if (postLoginRedirect) {
|
||||
sessionStorage.removeItem('hanzo_auth_post_login_redirect')
|
||||
window.location.href = postLoginRedirect
|
||||
} else {
|
||||
window.location.href = '/account'
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="login-card max-w-md w-full p-8 text-center">
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
<a href="/login" className="link text-sm">Back to login</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-zinc-700 border-t-white rounded-full mx-auto mb-4" />
|
||||
<p className="text-zinc-400 text-sm">Completing sign in...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
<Suspense fallback={
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-zinc-700 border-t-white rounded-full mx-auto mb-4" />
|
||||
<p className="text-zinc-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
}>
|
||||
<CallbackHandler />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { getIamUrl, getOrg } from '@/lib/iam'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
|
||||
const iamUrl = getIamUrl(host)
|
||||
const org = getOrg(host)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
if (!email) throw new Error('Please enter your email address')
|
||||
|
||||
const res = await fetch(`${iamUrl}/api/send-verification-code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
dest: email,
|
||||
type: 'reset',
|
||||
organization: org,
|
||||
applicationId: `admin/${org}`,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.status === 'error') throw new Error(data.msg || 'Failed to send reset email')
|
||||
|
||||
setSent(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black p-8">
|
||||
<div className="login-card w-full max-w-md p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Reset password</h1>
|
||||
<p className="text-zinc-400 text-sm mb-6">
|
||||
{sent
|
||||
? 'Check your email for a password reset link.'
|
||||
: 'Enter your email and we\'ll send you a reset link.'}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sent ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20 text-green-400 text-sm">
|
||||
If an account exists for {email}, you will receive a password reset email shortly.
|
||||
</div>
|
||||
<a href="/login" className="block text-center link text-sm">
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input w-full pl-10 py-3 rounded-lg"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-zinc-500">
|
||||
Remember your password?{' '}
|
||||
<a href="/login" className="link">Sign in</a>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Default Hanzo colors - overridden by branding config */
|
||||
--color-primary: #e4e4e7;
|
||||
--color-primary-text: #09090b;
|
||||
--color-background: #000000;
|
||||
--color-surface: #0a0a0a;
|
||||
--color-text: #ffffff;
|
||||
--color-text-muted: #a1a1aa;
|
||||
--color-border: #27272a;
|
||||
--color-error: #dc2626;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Login card styling */
|
||||
.login-card {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Input styling */
|
||||
.input {
|
||||
background-color: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(228, 228, 231, 0.2);
|
||||
}
|
||||
|
||||
/* Link styling */
|
||||
.link {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Quote card styling */
|
||||
.quote-card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export const runtime = 'edge'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { headers } from 'next/headers'
|
||||
import { staticBranding, defaultBranding, resolveBrandingDomain } from '@/lib/branding'
|
||||
import './globals.css'
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const headersList = await headers()
|
||||
const host = headersList.get('host') || 'hanzo.id'
|
||||
const domain = resolveBrandingDomain(host)
|
||||
|
||||
const staticConfig = staticBranding[domain]
|
||||
const orgName = staticConfig?.orgName || defaultBranding.orgName
|
||||
|
||||
return {
|
||||
title: `${orgName} ID`,
|
||||
description: `Secure identity for ${orgName}. Sign in, manage your account, and access all ${orgName} services.`,
|
||||
}
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Suspense } from 'react'
|
||||
import { headers } from 'next/headers'
|
||||
import { getBranding, staticBranding, defaultBranding, resolveBrandingDomain, BrandingConfig } from '@/lib/branding'
|
||||
import LoginForm from '@/components/LoginForm'
|
||||
import MarketingPanel from '@/components/MarketingPanel'
|
||||
import LanguageDropdown from '@/components/LanguageDropdown'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
async function getBrandingForDomain(): Promise<BrandingConfig> {
|
||||
const headersList = await headers()
|
||||
const host = headersList.get('host') || 'hanzo.id'
|
||||
const domain = resolveBrandingDomain(host)
|
||||
|
||||
// First check static configs, then fetch from IAM
|
||||
const staticConfig = staticBranding[domain]
|
||||
if (staticConfig) {
|
||||
return { ...defaultBranding, ...staticConfig, domain }
|
||||
}
|
||||
|
||||
return getBranding(domain)
|
||||
}
|
||||
|
||||
export default async function LoginPage() {
|
||||
const branding = await getBrandingForDomain()
|
||||
|
||||
// Generate CSS variables from branding
|
||||
const cssVars = {
|
||||
'--color-primary': branding.colors.primary,
|
||||
'--color-primary-text': branding.colors.primaryText,
|
||||
'--color-background': branding.colors.background,
|
||||
'--color-surface': branding.colors.surface,
|
||||
'--color-text': branding.colors.text,
|
||||
'--color-text-muted': branding.colors.textMuted,
|
||||
'--color-border': branding.colors.border,
|
||||
'--color-error': branding.colors.error,
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex" style={cssVars}>
|
||||
{/* Left side - Login Form */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
|
||||
<div className="login-card w-full max-w-md p-8">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<img
|
||||
src={branding.logo}
|
||||
alt={branding.orgName}
|
||||
className="h-10"
|
||||
/>
|
||||
<Suspense><LanguageDropdown /></Suspense>
|
||||
</div>
|
||||
|
||||
<Suspense><LoginForm branding={branding} /></Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Marketing Panel */}
|
||||
<div className="hidden lg:flex w-1/2 items-center justify-center p-12 bg-gradient-to-br from-black via-zinc-900 to-black">
|
||||
<MarketingPanel branding={branding} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export const runtime = 'edge'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-white mb-4">404</h1>
|
||||
<p className="text-zinc-400 mb-8">Page not found</p>
|
||||
<a href="/login" className="text-sm text-zinc-500 hover:text-white transition-colors">
|
||||
Go to login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
import { headers } from 'next/headers'
|
||||
import Link from 'next/link'
|
||||
import { staticBranding, defaultBranding, resolveBrandingDomain, type BrandingConfig } from '@/lib/branding'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
// Per-org landing page content
|
||||
interface LandingContent {
|
||||
headline: string
|
||||
description: string
|
||||
features: { title: string; description: string; icon: string }[]
|
||||
standards: { label: string; value: string }[]
|
||||
cta: string
|
||||
secondaryCta?: { label: string; href: string }
|
||||
}
|
||||
|
||||
const landingContent: Record<string, LandingContent> = {
|
||||
lux: {
|
||||
headline: 'Your Identity on Lux',
|
||||
description: 'Decentralized identity anchored on high-performance blockchain infrastructure. Own your credentials, prove who you are without exposing what you are, and authenticate across the entire Lux ecosystem with one login.',
|
||||
features: [
|
||||
{
|
||||
title: 'Decentralized Identifiers (DIDs)',
|
||||
description: 'W3C-standard DIDs anchored on Lux Network. Your identity is portable, censorship-resistant, and fully under your control. No central authority can revoke or freeze your credentials.',
|
||||
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
|
||||
},
|
||||
{
|
||||
title: 'Verifiable Credentials',
|
||||
description: 'Issue and present tamper-proof credentials — KYC attestations, membership proofs, reputation scores — without revealing unnecessary personal data. Selective disclosure by default.',
|
||||
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
},
|
||||
{
|
||||
title: 'Cross-Chain Single Sign-On',
|
||||
description: 'One identity across Lux mainnet, subnets, the bridge, DEX, and every ecosystem dApp. OAuth2/OIDC compliant — works with traditional apps too.',
|
||||
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
|
||||
},
|
||||
{
|
||||
title: 'Post-Quantum Cryptography',
|
||||
description: 'Forward-looking key management with lattice-based and hash-based signatures. Your identity stays secure against quantum computing threats — today and tomorrow.',
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
},
|
||||
{
|
||||
title: 'Key Recovery & Social Recovery',
|
||||
description: 'Lost your keys? Recover your identity through trusted guardians, multi-sig recovery, or hardware backup — no single point of failure.',
|
||||
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
|
||||
},
|
||||
{
|
||||
title: 'Privacy-Preserving Auth',
|
||||
description: 'Zero-knowledge proofs let you prove eligibility, age, membership, or accreditation without revealing the underlying data. Your privacy is non-negotiable.',
|
||||
icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'W3C DID', value: 'Core v1.0' },
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
{ label: 'WebAuthn', value: 'L2' },
|
||||
{ label: 'FIDO2', value: 'Passkeys' },
|
||||
],
|
||||
cta: 'Create Your Lux ID',
|
||||
secondaryCta: { label: 'Explore Lux Network', href: 'https://lux.network' },
|
||||
},
|
||||
pars: {
|
||||
headline: 'Your Identity on Pars',
|
||||
description: 'Self-sovereign identity for the next generation of decentralized infrastructure. Own your data, control your credentials, and authenticate across the Pars ecosystem with confidence.',
|
||||
features: [
|
||||
{
|
||||
title: 'Self-Sovereign Identity',
|
||||
description: 'Your identity belongs to you — not a corporation, not a government. W3C DID-compliant identifiers give you full ownership and portability.',
|
||||
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
|
||||
},
|
||||
{
|
||||
title: 'Verifiable Credentials',
|
||||
description: 'Carry tamper-proof digital credentials — academic degrees, professional certifications, KYC attestations — verified on-chain, shared on your terms.',
|
||||
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
},
|
||||
{
|
||||
title: 'Ecosystem-Wide Access',
|
||||
description: 'One account for all Pars applications, governance, staking, and partner integrations. Standards-based SSO that just works.',
|
||||
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
},
|
||||
{
|
||||
title: 'Privacy by Design',
|
||||
description: 'Zero-knowledge proofs and selective disclosure — share only what you choose. Prove you\'re eligible without revealing why.',
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
},
|
||||
{
|
||||
title: 'Multi-Factor Security',
|
||||
description: 'Hardware keys, biometrics, TOTP, passkeys — layer security however you need. Enterprise-grade protection for every user.',
|
||||
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
|
||||
},
|
||||
{
|
||||
title: 'Open Standards',
|
||||
description: 'Built on OAuth 2.0, OpenID Connect, W3C DIDs, and Verifiable Credentials. No vendor lock-in, interoperable with any standards-compliant system.',
|
||||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'W3C DID', value: 'Core v1.0' },
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
{ label: 'WebAuthn', value: 'L2' },
|
||||
{ label: 'VC', value: 'Data Model' },
|
||||
],
|
||||
cta: 'Create Your Pars ID',
|
||||
secondaryCta: { label: 'Explore Pars Network', href: 'https://pars.network' },
|
||||
},
|
||||
zoo: {
|
||||
headline: 'Your Identity on Zoo',
|
||||
description: 'Verifiable research identity for the open AI research network. Collaborate on decentralized science, participate in governance, and build reputation across the Zoo ecosystem.',
|
||||
features: [
|
||||
{
|
||||
title: 'Research Identity',
|
||||
description: 'A verifiable, portable identity for researchers, contributors, and AI practitioners. Link your publications, models, and contributions to a cryptographic identity you own.',
|
||||
icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
|
||||
},
|
||||
{
|
||||
title: 'Governance & ZIPs',
|
||||
description: 'Participate in Zoo Improvement Proposals (ZIPs) with a verified identity. Vote on protocol upgrades, fund allocation, and research priorities.',
|
||||
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
|
||||
},
|
||||
{
|
||||
title: 'Cross-Network Reputation',
|
||||
description: 'Build reputation that travels with you. Contributions to Zoo, Hanzo, and partner networks all feed into a unified, verifiable reputation graph.',
|
||||
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
|
||||
},
|
||||
{
|
||||
title: 'Decentralized Science (DeSci)',
|
||||
description: 'Credential your research contributions on-chain. Peer review, data sharing, and reproducibility — all backed by verifiable credentials.',
|
||||
icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
|
||||
},
|
||||
{
|
||||
title: 'Privacy-First',
|
||||
description: 'Selective disclosure lets you prove qualifications without exposing personal data. Research anonymously when you need to.',
|
||||
icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
},
|
||||
{
|
||||
title: 'Open & Interoperable',
|
||||
description: 'W3C DID, OAuth 2.0, OIDC — standards-based identity that works with ORCID, institutional logins, and any research platform.',
|
||||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'W3C DID', value: 'Core v1.0' },
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
{ label: 'VC', value: 'Data Model' },
|
||||
{ label: 'DeSci', value: 'ZIPs' },
|
||||
],
|
||||
cta: 'Create Your Zoo ID',
|
||||
secondaryCta: { label: 'Explore Zoo Network', href: 'https://zoo.ngo' },
|
||||
},
|
||||
hanzo: {
|
||||
headline: 'Your AI Identity',
|
||||
description: 'One identity across the entire Hanzo AI ecosystem. Secure, standards-based authentication for developers building the future of AI.',
|
||||
features: [
|
||||
{
|
||||
title: 'Unified AI Access',
|
||||
description: 'Single sign-in to Console, Chat, Cloud, Gateway, and every Hanzo service. One identity, one API key namespace, one billing account.',
|
||||
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
|
||||
},
|
||||
{
|
||||
title: 'Developer-First Auth',
|
||||
description: 'OAuth 2.0, OpenID Connect, PKCE, API keys, service tokens — all RFC-standard. SDKs in Python, TypeScript, Go, and Rust. No vendor lock-in.',
|
||||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||||
},
|
||||
{
|
||||
title: 'Enterprise Security',
|
||||
description: 'SSO with SAML/OIDC, hardware-backed MFA, fine-grained RBAC, audit logs, and SOC 2 compliance. Built for teams that ship.',
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
},
|
||||
{
|
||||
title: 'Multi-Tenant Organizations',
|
||||
description: 'Create organizations, invite team members, assign roles, and scope API keys — all from a single identity. White-label ready for your own domains.',
|
||||
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
},
|
||||
{
|
||||
title: 'Passkeys & Biometrics',
|
||||
description: 'FIDO2 passkeys, Face ID, Touch ID, hardware security keys — passwordless authentication that\'s both more secure and more convenient.',
|
||||
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
|
||||
},
|
||||
{
|
||||
title: 'Web3 + Traditional',
|
||||
description: 'Connect with MetaMask, WalletConnect, or hardware wallets alongside traditional email/password and social login. Bridge Web2 and Web3 seamlessly.',
|
||||
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
{ label: 'WebAuthn', value: 'L2' },
|
||||
{ label: 'SAML', value: '2.0' },
|
||||
{ label: 'FIDO2', value: 'Passkeys' },
|
||||
],
|
||||
cta: 'Get Started',
|
||||
secondaryCta: { label: 'Read the Docs', href: 'https://docs.hanzo.ai' },
|
||||
},
|
||||
zen: {
|
||||
headline: 'Your Zen Identity',
|
||||
description: 'Access frontier AI models with a single identity. Zen LM powers the next generation of language models — your identity unlocks them all.',
|
||||
features: [
|
||||
{
|
||||
title: 'Model Access',
|
||||
description: 'Authenticate once to access all Zen LM models — from 600M to 480B parameters. Inference, fine-tuning, and evaluation with one API key.',
|
||||
icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
|
||||
},
|
||||
{
|
||||
title: 'Usage & Billing',
|
||||
description: 'Track model usage, manage API keys, set spending limits, and control team access — all from your Zen ID dashboard.',
|
||||
icon: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
|
||||
},
|
||||
{
|
||||
title: 'Open Standards',
|
||||
description: 'OAuth 2.0 / OIDC compliant — integrate with any platform, CI/CD pipeline, or workflow. SDKs for every major language.',
|
||||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||||
},
|
||||
{
|
||||
title: 'Developer Experience',
|
||||
description: 'CLI login, API key management, scoped tokens, and seamless integration with development tools. Built for AI engineers.',
|
||||
icon: 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||||
},
|
||||
{
|
||||
title: 'Team Management',
|
||||
description: 'Create organizations, invite collaborators, and share model access with fine-grained permissions.',
|
||||
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
},
|
||||
{
|
||||
title: 'Cross-Ecosystem',
|
||||
description: 'Your Zen ID works across Hanzo, Lux, Zoo, and partner platforms. One identity, every AI service.',
|
||||
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
{ label: 'WebAuthn', value: 'L2' },
|
||||
{ label: 'FIDO2', value: 'Passkeys' },
|
||||
{ label: 'JWT', value: 'RFC 7519' },
|
||||
],
|
||||
cta: 'Get Started with Zen',
|
||||
secondaryCta: { label: 'Explore Models', href: 'https://zenlm.org' },
|
||||
},
|
||||
adnexus: {
|
||||
headline: 'Your Ad Nexus Identity',
|
||||
description: 'Secure identity for the programmatic advertising platform. Manage campaigns, analytics, and integrations with enterprise-grade authentication.',
|
||||
features: [
|
||||
{
|
||||
title: 'Campaign Access',
|
||||
description: 'Single sign-on to all Ad Nexus tools — campaign manager, analytics dashboard, creative studio, and billing.',
|
||||
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
|
||||
},
|
||||
{
|
||||
title: 'Team Permissions',
|
||||
description: 'Role-based access control for agencies and brands. Scoped permissions for campaign managers, analysts, and billing admins.',
|
||||
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
},
|
||||
{
|
||||
title: 'Enterprise SSO',
|
||||
description: 'SAML, OIDC, and OAuth 2.0 federation. Connect your existing identity provider for seamless onboarding.',
|
||||
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
|
||||
},
|
||||
],
|
||||
standards: [
|
||||
{ label: 'OAuth 2.0', value: 'RFC 6749' },
|
||||
{ label: 'OIDC', value: 'Core 1.0' },
|
||||
{ label: 'SAML', value: '2.0' },
|
||||
{ label: 'PKCE', value: 'RFC 7636' },
|
||||
],
|
||||
cta: 'Get Started',
|
||||
secondaryCta: { label: 'Learn More', href: 'https://ad.nexus' },
|
||||
},
|
||||
}
|
||||
|
||||
const defaultLanding = landingContent.hanzo
|
||||
|
||||
async function getBrandingForDomain() {
|
||||
const headersList = await headers()
|
||||
const host = headersList.get('host') || 'hanzo.id'
|
||||
const domain = resolveBrandingDomain(host)
|
||||
const staticConfig = staticBranding[domain]
|
||||
const branding: BrandingConfig = staticConfig
|
||||
? { ...defaultBranding, ...staticConfig, domain }
|
||||
: { ...defaultBranding, domain }
|
||||
return branding
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const branding = await getBrandingForDomain()
|
||||
const content = landingContent[branding.orgId] || defaultLanding
|
||||
|
||||
const cssVars = {
|
||||
'--color-primary': branding.colors.primary,
|
||||
'--color-primary-text': branding.colors.primaryText,
|
||||
'--color-background': branding.colors.background,
|
||||
'--color-surface': branding.colors.surface,
|
||||
'--color-text': branding.colors.text,
|
||||
'--color-text-muted': branding.colors.textMuted,
|
||||
'--color-border': branding.colors.border,
|
||||
'--color-error': branding.colors.error,
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={cssVars}>
|
||||
{/* Nav */}
|
||||
<nav className="flex items-center justify-between px-6 md:px-12 py-4 border-b border-zinc-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={branding.logo} alt={branding.orgName} className="h-8" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/login" className="text-sm text-zinc-400 hover:text-white transition-colors">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/signup"
|
||||
className="text-sm px-4 py-2 rounded-lg font-medium transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="px-6 md:px-12 py-20 md:py-32">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border text-sm mb-8"
|
||||
style={{ borderColor: branding.colors.primary + '40', color: branding.colors.primary }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Decentralized Identity
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-white mb-6 leading-tight tracking-tight">
|
||||
{content.headline}
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-zinc-400 max-w-2xl mx-auto mb-12 leading-relaxed">
|
||||
{content.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="px-8 py-3.5 rounded-lg font-medium text-lg transition-opacity hover:opacity-90 w-full sm:w-auto"
|
||||
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
|
||||
>
|
||||
{content.cta}
|
||||
</Link>
|
||||
{content.secondaryCta ? (
|
||||
<a
|
||||
href={content.secondaryCta.href}
|
||||
className="px-8 py-3.5 rounded-lg font-medium text-lg border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors w-full sm:w-auto text-center"
|
||||
>
|
||||
{content.secondaryCta.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-3.5 rounded-lg font-medium text-lg border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors w-full sm:w-auto text-center"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Standards bar */}
|
||||
<section className="border-y border-zinc-800/50 px-6 md:px-12 py-6">
|
||||
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-6 md:gap-10">
|
||||
{content.standards.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-zinc-500">{s.label}</span>
|
||||
<span className="text-zinc-300 font-mono text-xs px-1.5 py-0.5 rounded bg-zinc-800">{s.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="px-6 md:px-12 py-20 md:py-28">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Built for the future of identity
|
||||
</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">
|
||||
Standards-compliant, privacy-preserving, and designed for decentralized ecosystems.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{content.features.map((feature, i) => (
|
||||
<div key={i} className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30 hover:bg-zinc-900/60 transition-colors">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
|
||||
style={{ backgroundColor: branding.colors.primary + '15' }}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
style={{ color: branding.colors.primary }}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={feature.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
|
||||
<p className="text-sm text-zinc-400 leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="px-6 md:px-12 py-20">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="p-12 rounded-2xl border border-zinc-800 bg-gradient-to-br from-zinc-900/80 to-zinc-900/30">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Ready to own your identity?
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-8 max-w-lg mx-auto">
|
||||
Create your {branding.orgName} ID in seconds. Free, open, and yours forever.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="px-8 py-3.5 rounded-lg font-medium text-lg transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
|
||||
>
|
||||
{content.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Already have an account? Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-zinc-800/50 px-6 md:px-12 py-8">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-zinc-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={branding.logo} alt={branding.orgName} className="h-5 opacity-50" />
|
||||
<span>© {new Date().getFullYear()} {branding.orgName}</span>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
{branding.links.terms && <a href={branding.links.terms} className="hover:text-zinc-300 transition-colors">Terms</a>}
|
||||
{branding.links.privacy && <a href={branding.links.privacy} className="hover:text-zinc-300 transition-colors">Privacy</a>}
|
||||
{branding.links.docs && <a href={branding.links.docs} className="hover:text-zinc-300 transition-colors">Documentation</a>}
|
||||
{branding.links.home && <a href={branding.links.home} className="hover:text-zinc-300 transition-colors">{branding.orgName}</a>}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Suspense } from 'react'
|
||||
import { headers } from 'next/headers'
|
||||
import { getBranding, staticBranding, defaultBranding, resolveBrandingDomain, BrandingConfig } from '@/lib/branding'
|
||||
import SignUpForm from '@/components/SignUpForm'
|
||||
import MarketingPanel from '@/components/MarketingPanel'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
async function getBrandingForDomain(): Promise<BrandingConfig> {
|
||||
const headersList = await headers()
|
||||
const host = headersList.get('host') || 'hanzo.id'
|
||||
const domain = resolveBrandingDomain(host)
|
||||
|
||||
const staticConfig = staticBranding[domain]
|
||||
if (staticConfig) {
|
||||
return { ...defaultBranding, ...staticConfig, domain }
|
||||
}
|
||||
|
||||
return getBranding(domain)
|
||||
}
|
||||
|
||||
export default async function SignUpPage() {
|
||||
const branding = await getBrandingForDomain()
|
||||
|
||||
const cssVars = {
|
||||
'--color-primary': branding.colors.primary,
|
||||
'--color-primary-text': branding.colors.primaryText,
|
||||
'--color-background': branding.colors.background,
|
||||
'--color-surface': branding.colors.surface,
|
||||
'--color-text': branding.colors.text,
|
||||
'--color-text-muted': branding.colors.textMuted,
|
||||
'--color-border': branding.colors.border,
|
||||
'--color-error': branding.colors.error,
|
||||
} as React.CSSProperties
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex" style={cssVars}>
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
|
||||
<div className="login-card w-full max-w-md p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<img
|
||||
src={branding.logo}
|
||||
alt={branding.logoAlt || branding.orgName}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<Suspense><SignUpForm branding={branding} /></Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex w-1/2 items-center justify-center p-12 bg-gradient-to-br from-black via-zinc-900 to-black">
|
||||
<MarketingPanel branding={branding} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<link id="favicon" rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Sign in</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"version": "0.1.26",
|
||||
"description": "Hanzo ID \u2014 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": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5174",
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crossmarkio/sdk": "^0.4.0",
|
||||
"@hanzo/brand": "^1.3.0",
|
||||
"@hanzo/gui": "^7.2.4",
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"@hanzo/id-auth": "workspace:*",
|
||||
"@hanzo/id-connect": "workspace:*",
|
||||
"@hanzo/id-idv": "workspace:*",
|
||||
"@hanzo/id-onboarding": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@luxfi/brand": "^1.0.0",
|
||||
"@parsdao/brand": "^1.0.0",
|
||||
"@tanstack/react-router": "^1.168.0",
|
||||
"@tonconnect/sdk": "^4.0.0",
|
||||
"@zooai/brand": "^1.3.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"sats-connect": "^4.2.1",
|
||||
"viem": "^2.53.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<g transform="translate(128, 128) scale(11.46)">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 541 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- Hanzo brand mark: black square + canonical block-H. Red is the brand accent (links/CTAs); the mark itself is always black + white. -->
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<g transform="translate(128, 128) scale(11.46)">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 683 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<text x="512" y="720" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-weight="700" font-size="800" fill="#FFFFFF" text-anchor="middle" letter-spacing="-40">L</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Lux brand mark: solid black square -->
|
||||
<rect width="1024" height="1024" fill="#000000"/>
|
||||
<text x="512" y="640" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-weight="700" font-size="640" fill="#FFFFFF" text-anchor="middle" letter-spacing="-32">L</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 407 B |
@@ -0,0 +1,69 @@
|
||||
<svg viewBox="-120 -120 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!--
|
||||
Pars Network Logo - Persian 8-Pointed Star (Khatam/Shamseh)
|
||||
The traditional Persian geometric motif - recursive fractal star
|
||||
Used in mosques, palaces, and tilework across Persia for millennia.
|
||||
-->
|
||||
<defs>
|
||||
<linearGradient id="pars-gold" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#f5d06f"/>
|
||||
<stop offset="50%" stop-color="#caa24a"/>
|
||||
<stop offset="100%" stop-color="#f3dc8f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pars-blue" x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#003355"/>
|
||||
<stop offset="50%" stop-color="#00abff"/>
|
||||
<stop offset="100%" stop-color="#66d0ff"/>
|
||||
</linearGradient>
|
||||
<filter id="pars-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur"/>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Outer 8-pointed star -->
|
||||
<g filter="url(#pars-glow)">
|
||||
<path
|
||||
d="M0,-100 L30,-60 L100,-40 L60,0 L100,40 L30,60 L0,100 L-30,60 L-100,40 L-60,0 L-100,-40 L-30,-60 Z"
|
||||
fill="none"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="4"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Inner star with blue fill -->
|
||||
<path
|
||||
d="M0,-70 L22,-42 L70,-28 L42,0 L70,28 L22,42 L0,70 L-22,42 L-70,28 L-42,0 L-70,-28 L-22,-42 Z"
|
||||
fill="url(#pars-blue)"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Recursive inner star -->
|
||||
<path
|
||||
d="M0,-45 L14,-27 L45,-18 L27,0 L45,18 L14,27 L0,45 L-14,27 L-45,18 L-27,0 L-45,-18 L-14,-27 Z"
|
||||
fill="none"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.8"
|
||||
/>
|
||||
|
||||
<!-- Interlaced circles (Persian geometric pattern) -->
|
||||
<g fill="none" stroke="#eaf7ff" stroke-width="1.5" opacity="0.6">
|
||||
<circle r="55"/>
|
||||
<circle r="35"/>
|
||||
</g>
|
||||
|
||||
<!-- Center rosette -->
|
||||
<circle r="8" fill="url(#pars-gold)"/>
|
||||
<path
|
||||
d="M0,-20 L6,-6 L20,0 L6,6 L0,20 L-6,6 L-20,0 L-6,-6 Z"
|
||||
fill="#002a47"
|
||||
stroke="url(#pars-gold)"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,38 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Zoo brand mark: three-circle CMYK Venn diagram, full color.
|
||||
Filled to the viewport so it scales cleanly into 32x32 nav slots
|
||||
and 256x256 OG cards without tiny dead margins. -->
|
||||
<defs>
|
||||
<clipPath id="logoClip">
|
||||
<circle cx="512" cy="511" r="500"/>
|
||||
</clipPath>
|
||||
<clipPath id="logoYellow">
|
||||
<circle cx="512" cy="250" r="430"/>
|
||||
</clipPath>
|
||||
<clipPath id="logoMagenta">
|
||||
<circle cx="240" cy="670" r="430"/>
|
||||
</clipPath>
|
||||
<clipPath id="logoCyan">
|
||||
<circle cx="784" cy="670" r="430"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#logoClip)">
|
||||
<circle cx="512" cy="250" r="430" fill="#FCF006"/>
|
||||
<circle cx="240" cy="670" r="430" fill="#EA018E"/>
|
||||
<circle cx="784" cy="670" r="430" fill="#01ACF1"/>
|
||||
<g clip-path="url(#logoYellow)">
|
||||
<circle cx="240" cy="670" r="430" fill="#ED1C24"/>
|
||||
</g>
|
||||
<g clip-path="url(#logoYellow)">
|
||||
<circle cx="784" cy="670" r="430" fill="#00A652"/>
|
||||
</g>
|
||||
<g clip-path="url(#logoMagenta)">
|
||||
<circle cx="784" cy="670" r="430" fill="#2E3192"/>
|
||||
</g>
|
||||
<g clip-path="url(#logoYellow)">
|
||||
<g clip-path="url(#logoMagenta)">
|
||||
<circle cx="784" cy="670" r="430" fill="#000000"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 67 67" role="img" aria-label="Hanzo">
|
||||
<style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style>
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 443 B |
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { loadBrand, parseCatalog, resolveTenant, type BrandContract, type TenantConfig } from '@hanzo/id-shared'
|
||||
import { createAuthClient } from '@hanzo/id-auth'
|
||||
import { Portal } from './pages/Portal'
|
||||
import { Login } from './pages/Login'
|
||||
import { Signup } from './pages/Signup'
|
||||
import { Forgot } from './pages/Forgot'
|
||||
import { Callback } from './pages/Callback'
|
||||
import { Onboarding } from './pages/Onboarding'
|
||||
import { DeviceApproval } from './pages/DeviceApproval'
|
||||
|
||||
/**
|
||||
* Top-level wiring. Resolves tenant + brand once on mount, then routes via
|
||||
* `window.location.pathname`. No router lib needed — this app is 5 pages,
|
||||
* `<a href>` is enough. Adding paths is a switch case.
|
||||
*/
|
||||
export function App() {
|
||||
const [tenant, setTenant] = useState<TenantConfig | null>(null)
|
||||
const [brand, setBrand] = useState<BrandContract | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function boot() {
|
||||
// The runtime serves the per-host tenant catalog at /config.json
|
||||
// (templated from SPA_IAM_TENANT_CONFIG_JSON by the static server). Read
|
||||
// it from there — NOT a `window.__ID_CATALOG__` global, which the runtime
|
||||
// never injects (relying on it silently dropped every catalog-only host,
|
||||
// e.g. osage.id, to the bundled Hanzo default). Fall back to the inlined
|
||||
// global, then empty, so a host always resolves to something.
|
||||
let catalogRaw: string | undefined
|
||||
try {
|
||||
const res = await fetch('/config.json', { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
const cfg = (await res.json()) as { iamTenantConfigJson?: string }
|
||||
catalogRaw = cfg.iamTenantConfigJson
|
||||
}
|
||||
} catch {
|
||||
// network/parse error → fall back below
|
||||
}
|
||||
if (!catalogRaw) {
|
||||
catalogRaw = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
|
||||
}
|
||||
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
|
||||
if (cancelled) return
|
||||
setTenant(t)
|
||||
try {
|
||||
const b = await loadBrand(t.brandPackage)
|
||||
if (cancelled) return
|
||||
setBrand(b)
|
||||
document.title = `Sign in — ${b.name}`
|
||||
const fav = document.getElementById('favicon') as HTMLLinkElement | null
|
||||
if (fav && b.faviconUrl) fav.href = b.faviconUrl
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e))
|
||||
}
|
||||
}
|
||||
void boot()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
|
||||
|
||||
if (error) return <div className="hanzo-id-error">{error}</div>
|
||||
if (!tenant || !brand || !client) return <div>Loading…</div>
|
||||
|
||||
const path = window.location.pathname
|
||||
// Device-authorization approval (RFC 8628). Must precede the `/login` catch
|
||||
// since it lives under `/login/oauth/device`.
|
||||
if (path === '/login/oauth/device' || path.startsWith('/login/oauth/device/'))
|
||||
return <DeviceApproval client={client} brand={brand} />
|
||||
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} />
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return <Signup client={client} brand={brand} />
|
||||
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} />
|
||||
if (path === '/callback' || path.startsWith('/callback/')) return <Callback tenant={tenant} brand={brand} />
|
||||
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding tenant={tenant} brand={brand} />
|
||||
return <Portal client={client} brand={brand} tenant={tenant} />
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
:root {
|
||||
--brand: #ffffff;
|
||||
--bg: #0a0a0a;
|
||||
--fg: #fafafa;
|
||||
--muted: #a3a3a3;
|
||||
--border: #262626;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hanzo-id-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.hanzo-id-brand-header {
|
||||
padding: 16px 0 32px;
|
||||
}
|
||||
|
||||
/* Text fallback when a brand ships no logo asset (see BrandHeader). */
|
||||
.hanzo-id-wordmark {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.hanzo-id-page main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hanzo-id-page h1 { margin: 0; font-size: 28px; }
|
||||
.hanzo-id-page .lede { color: var(--muted); margin: 0; }
|
||||
|
||||
form { display: flex; flex-direction: column; gap: 16px; }
|
||||
form label { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: var(--muted); }
|
||||
form input {
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
|
||||
.hanzo-id-btn, form button {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
/* 13px vertical padding -> 44px tall (>=44px touch target). */
|
||||
padding: 13px 16px;
|
||||
min-height: 44px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
}
|
||||
.hanzo-id-btn[aria-disabled='true'], form button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.hanzo-id-btn.primary { background: var(--brand); }
|
||||
|
||||
.hanzo-id-cta-row { display: flex; gap: 12px; }
|
||||
.hanzo-id-footer-links { color: var(--muted); font-size: 14px; }
|
||||
.hanzo-id-footer-links a { color: var(--fg); }
|
||||
|
||||
/* A2P SMS consent disclosure (shown on phone/SMS surfaces). */
|
||||
.hanzo-id-sms-consent { color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.hanzo-id-sms-consent p { margin: 0 0 6px; }
|
||||
.hanzo-id-sms-consent-links { margin: 0; }
|
||||
.hanzo-id-sms-consent a { color: var(--fg); }
|
||||
|
||||
.hanzo-id-error {
|
||||
background: #2d0a0a;
|
||||
color: #ff7878;
|
||||
border: 1px solid #5a1414;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.hanzo-id-info {
|
||||
background: #0a1f2d;
|
||||
color: #78b8ff;
|
||||
border: 1px solid #14385a;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ──Device-authorization approval ─────────────────────────────── */
|
||||
.hanzo-id-device main { gap: 18px; }
|
||||
.hanzo-id-device-prompt { color: var(--muted); font-size: 14px; line-height: 1.5; margin: 0; }
|
||||
.hanzo-id-device-prompt strong { color: var(--fg); }
|
||||
.hanzo-id-device-code-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.hanzo-id-device-code-field span { color: var(--muted); font-size: 13px; }
|
||||
.hanzo-id-device-code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hanzo-id-device-confirm {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hanzo-id-device-confirm input {
|
||||
width: auto;
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ──Forced TOTP enrollment ────────────────────────────────────── */
|
||||
.hanzo-id-mfa-enroll { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-mfa-enroll h2 { margin: 0; font-size: 22px; }
|
||||
.hanzo-id-mfa-qr {
|
||||
align-self: center;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.hanzo-id-mfa-qr svg { width: 100%; height: 100%; display: block; }
|
||||
.hanzo-id-mfa-manual { font-size: 14px; color: var(--muted); }
|
||||
.hanzo-id-mfa-manual summary { cursor: pointer; }
|
||||
.hanzo-id-mfa-secret,
|
||||
.hanzo-id-mfa-recovery code {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.hanzo-id-mfa-recovery { font-size: 13px; color: var(--muted); line-height: 1.6; }
|
||||
.hanzo-id-mfa-recovery code { letter-spacing: normal; }
|
||||
|
||||
/* ── Social / Web3 sign-in buttons ─────────────────────────────── */
|
||||
.hanzo-id-social { display: flex; flex-direction: column; gap: 10px; }
|
||||
.hanzo-id-social-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 11px 14px;
|
||||
/* Flex-centered above, so min-height keeps the label centered at
|
||||
the >=44px touch target. */
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hanzo-id-social-btn:hover { border-color: #3a3a3a; background: #161616; }
|
||||
.hanzo-id-social-btn svg { flex: none; }
|
||||
|
||||
/* Labeled divider between social row and email form. */
|
||||
.hanzo-id-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.hanzo-id-divider::before,
|
||||
.hanzo-id-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.hanzo-id-divider span { padding: 0 12px; }
|
||||
|
||||
/* ── Onboarding flow ───────────────────────────────────────────── */
|
||||
.hanzo-id-onboarding { display: flex; flex-direction: column; gap: 24px; }
|
||||
.hanzo-id-onboarding-head { display: flex; flex-direction: column; gap: 6px; }
|
||||
.hanzo-id-onboarding-head h1 { margin: 0; font-size: 26px; }
|
||||
.hanzo-id-onboarding-body { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-onboarding-done { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-onboarding-done h1 { margin: 0; font-size: 26px; }
|
||||
|
||||
.hanzo-id-stepdots { display: flex; gap: 8px; }
|
||||
.hanzo-id-stepdots span {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
.hanzo-id-stepdots span.on { background: var(--brand); }
|
||||
|
||||
.hanzo-id-org-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.hanzo-id-org-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.hanzo-id-org-row:hover { border-color: #3a3a3a; background: #161616; }
|
||||
.hanzo-id-org-slug { color: var(--muted); font-size: 13px; font-family: ui-monospace, monospace; }
|
||||
|
||||
.hanzo-id-linkbtn {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hanzo-id-slug-preview { color: var(--muted); font-size: 13px; margin: -8px 0 0; font-family: ui-monospace, monospace; }
|
||||
|
||||
.hanzo-id-onboarding-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.hanzo-id-onboarding-actions .hanzo-id-btn { flex: 1; min-width: 120px; }
|
||||
.hanzo-id-btn.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
|
||||
.hanzo-id-btn.ghost:hover { border-color: #3a3a3a; }
|
||||
|
||||
.hanzo-id-summary { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; }
|
||||
.hanzo-id-summary dt { color: var(--muted); font-size: 14px; }
|
||||
.hanzo-id-summary dd { margin: 0; font-size: 14px; font-family: ui-monospace, monospace; }
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
|
||||
/**
|
||||
* Brand lockup for the auth pages. Renders the brand logo when it loads, and
|
||||
* falls back to the brand NAME as a text wordmark when the logo is absent or
|
||||
* fails to load — so a 404 logo (e.g. a brand package that doesn't ship its
|
||||
* assets) never shows a broken-image icon. The name always exists on the
|
||||
* brand contract, so the header is always presentable.
|
||||
*/
|
||||
export function BrandHeader({ brand }: { brand: BrandContract }) {
|
||||
const [imgOk, setImgOk] = useState(true)
|
||||
const showImg = Boolean(brand.logoUrl) && imgOk
|
||||
return (
|
||||
<header className="hanzo-id-brand-header">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
{showImg ? (
|
||||
<img src={brand.logoUrl} alt={brand.name} height={32} onError={() => setImgOk(false)} />
|
||||
) : (
|
||||
<span className="hanzo-id-wordmark">{brand.name}</span>
|
||||
)}
|
||||
</a>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import { registerProvider } from '@hanzo/id-idv'
|
||||
import { createStubProvider } from '@hanzo/id-idv/providers/stub'
|
||||
import './app.css'
|
||||
|
||||
// Default IDV provider — replace at boot via env-driven config.
|
||||
registerProvider(createStubProvider())
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) throw new Error('#root missing')
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Per-org marketing content + app launcher links.
|
||||
*
|
||||
* The brand-neutral `BrandContract` (from `loadBrand`) carries only the
|
||||
* visual essentials (name, logo, accent). The split-view login's marketing
|
||||
* panel and the post-login apps launcher need richer, org-specific copy —
|
||||
* ported verbatim from the frozen `legacy-nextjs` design (`staticBranding`
|
||||
* content + `orgApps`). Keyed by `tenant.orgId` so it stays decoupled from
|
||||
* hostname switches; unknown orgs fall back to `hanzo`.
|
||||
*/
|
||||
|
||||
export interface Quote {
|
||||
readonly text: string
|
||||
readonly author: string
|
||||
readonly role?: string
|
||||
}
|
||||
|
||||
export interface Marketing {
|
||||
/** Pill above the hero ("✦ <tagline>"). */
|
||||
readonly tagline?: string
|
||||
/** Hero heading. */
|
||||
readonly title: string
|
||||
/** Hero subheading. */
|
||||
readonly subtitle: string
|
||||
/** Rotating testimonials. */
|
||||
readonly quotes: readonly Quote[]
|
||||
}
|
||||
|
||||
export interface AppLink {
|
||||
readonly name: string
|
||||
readonly href: string
|
||||
readonly description: string
|
||||
}
|
||||
|
||||
const MARKETING: Record<string, Marketing> = {
|
||||
hanzo: {
|
||||
tagline: 'AI-powered development',
|
||||
title: 'Start building in seconds',
|
||||
subtitle: 'Describe your idea and watch AI bring it to life instantly.',
|
||||
quotes: [
|
||||
{ text: 'Hanzo is amazing. It is revolutionizing how we build and deploy applications.', author: 'Developer', role: 'Software Engineer' },
|
||||
],
|
||||
},
|
||||
lux: {
|
||||
tagline: 'Lux-powered infrastructure',
|
||||
title: 'Start deploying in seconds',
|
||||
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem.',
|
||||
quotes: [
|
||||
{ text: 'Lux is fast. We deploy chains in minutes, not weeks.', author: 'Validator', role: 'Node Operator' },
|
||||
],
|
||||
},
|
||||
zoo: {
|
||||
tagline: 'Open AI research network',
|
||||
title: 'Build the future of DeAI',
|
||||
subtitle: 'Open AI research and decentralized science for everyone.',
|
||||
quotes: [
|
||||
{ text: 'Zoo is where bleeding-edge DeAI experiments actually ship.', author: 'Researcher', role: 'ML Engineer' },
|
||||
],
|
||||
},
|
||||
pars: {
|
||||
tagline: 'Sovereign digital identity',
|
||||
title: 'Welcome to Pars',
|
||||
subtitle: 'The decentralized network for the next generation.',
|
||||
quotes: [
|
||||
{ text: 'Pars gives our community a sovereign, verifiable identity layer.', author: 'Member', role: 'Community Lead' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const APPS: Record<string, readonly AppLink[]> = {
|
||||
hanzo: [
|
||||
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
|
||||
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
|
||||
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
|
||||
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
|
||||
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
|
||||
],
|
||||
lux: [
|
||||
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
|
||||
{ name: 'Exchange', href: 'https://lux.exchange', description: 'DEX trading' },
|
||||
{ name: 'Cloud', href: 'https://lux.cloud', description: 'Lux Cloud' },
|
||||
{ name: 'Explorer', href: 'https://explore.lux.network', description: 'Block explorer' },
|
||||
],
|
||||
zoo: [
|
||||
{ name: 'Network', href: 'https://zoo.ngo', description: 'Zoo Labs Foundation' },
|
||||
{ name: 'ZIPs', href: 'https://zips.zoo.ngo', description: 'Improvement proposals' },
|
||||
{ name: 'Chat', href: 'https://chat.zoo.ngo', description: 'DeAI chat interface' },
|
||||
],
|
||||
pars: [
|
||||
{ name: 'Network', href: 'https://pars.network', description: 'Pars Network' },
|
||||
{ name: 'Vote', href: 'https://pars.vote', description: 'Governance & proposals' },
|
||||
],
|
||||
}
|
||||
|
||||
const BILLING: Record<string, string> = {
|
||||
hanzo: 'https://billing.hanzo.ai',
|
||||
lux: 'https://billing.lux.network',
|
||||
zoo: 'https://billing.zoo.network',
|
||||
pars: 'https://billing.pars.network',
|
||||
}
|
||||
|
||||
export function marketingFor(orgId: string): Marketing {
|
||||
return MARKETING[orgId] ?? MARKETING.hanzo
|
||||
}
|
||||
|
||||
export function appsFor(orgId: string): readonly AppLink[] {
|
||||
return APPS[orgId] ?? APPS.hanzo
|
||||
}
|
||||
|
||||
export function billingFor(orgId: string): string {
|
||||
return BILLING[orgId] ?? BILLING.hanzo
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { createIam, createAuthClient, decodeState } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* OAuth/OIDC callback.
|
||||
*
|
||||
* Two kinds of return land here:
|
||||
* 1. The portal's own OIDC PKCE return (password / SDK `signinRedirect`) —
|
||||
* completed by the `@hanzo/iam` SDK's `handleCallback`, which reads back
|
||||
* the exact PKCE verifier/state it stored.
|
||||
* 2. A SOCIAL provider return (GitHub/Google), where `social.ts` sent the
|
||||
* user out with a base64 `state` that encodes the original authorize
|
||||
* request. We detect that, exchange the provider `code` at the IAM backend
|
||||
* (`client.providerLogin`), and follow the continue-URL it returns — which
|
||||
* re-enters this callback as case (1). (Pending live verification; only
|
||||
* reachable once real provider creds are seeded.)
|
||||
*
|
||||
* Routing after the OIDC exchange:
|
||||
* - A downstream app left its target in `post_login_redirect` → forward tokens.
|
||||
* - A bare portal sign-in → `/onboarding`.
|
||||
*/
|
||||
|
||||
/** Decode a social-provider `state` (URL-safe base64 of the authorize query). */
|
||||
function decodeProviderState(state: string | null): URLSearchParams | null {
|
||||
if (!state) return null
|
||||
try {
|
||||
const decoded = decodeState(state)
|
||||
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
|
||||
// A provider-login state always carries application + provider markers.
|
||||
if (params.get('provider') && params.get('application')) return params
|
||||
} catch {
|
||||
// not base64 → an SDK/OIDC state, not a provider return
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const providerState = decodeProviderState(search.get('state'))
|
||||
|
||||
// Case (2): social provider return → exchange the provider code, then follow
|
||||
// the continue-URL back into case (1).
|
||||
if (providerState && search.get('code')) {
|
||||
const client = createAuthClient({ tenant })
|
||||
const oidcQuery = decodeState(search.get('state')!)
|
||||
client
|
||||
.providerLogin({
|
||||
application: providerState.get('application') ?? '',
|
||||
provider: providerState.get('provider') ?? '',
|
||||
code: search.get('code') ?? '',
|
||||
oidcQuery,
|
||||
method: providerState.get('method') ?? 'signin',
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.redirectUrl) window.location.replace(r.redirectUrl)
|
||||
else setError(r.error ?? 'Sign-in failed')
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
return
|
||||
}
|
||||
|
||||
// Case (1): the portal's own OIDC PKCE return.
|
||||
const iam = createIam(tenant)
|
||||
iam
|
||||
.handleCallback(window.location.href)
|
||||
.then((tok) => {
|
||||
const target = sessionStorage.getItem('post_login_redirect')
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
if (target) {
|
||||
// Forward tokens to whichever app initiated this flow.
|
||||
const url = new URL(target, window.location.origin)
|
||||
url.searchParams.set('access_token', tok.accessToken)
|
||||
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
|
||||
if (tok.idToken) url.searchParams.set('id_token', tok.idToken)
|
||||
window.location.replace(url.toString())
|
||||
return
|
||||
}
|
||||
// Bare portal sign-in → onboarding.
|
||||
window.location.replace('/onboarding')
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
}, [tenant])
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-callback">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : <p>Completing sign-in…</p>}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { LoginForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* RFC 8628 device-authorization approval (`/login/oauth/device`).
|
||||
*
|
||||
* The terminal leg of `dev login --device-auth`: the CLI shows a short
|
||||
* `user_code` and sends the human here (the IAM `verification_uri`;
|
||||
* `verification_uri_complete` adds `?user_code=<code>`). The human signs in to
|
||||
* the SAME issuer, confirms the code matches what their device shows, and
|
||||
* approves — which flips the device code's `UserSignIn=true` so the CLI's token
|
||||
* poll completes.
|
||||
*
|
||||
* Auth is reused, never reimplemented: not-signed-in renders the normal
|
||||
* `<LoginForm>` + `<SocialButtons>`; once the issuer session cookie is set the
|
||||
* page reads it back from `/v1/iam/get-account` and shows the confirm step.
|
||||
* Approval rides that session cookie (`client.approveDevice`), so no token ever
|
||||
* touches the URL or logs.
|
||||
*/
|
||||
|
||||
type Phase =
|
||||
| { s: 'checking' }
|
||||
| { s: 'signin' }
|
||||
| { s: 'confirm'; email?: string }
|
||||
| { s: 'consent'; email?: string }
|
||||
| { s: 'approving' }
|
||||
| { s: 'approved' }
|
||||
|
||||
/** Read the user_code from `?user_code=` first, then a trailing path segment
|
||||
* (`/login/oauth/device/<code>`) so both the complete and bare verification
|
||||
* URIs work; absent → the user types it. */
|
||||
function readUserCode(): string {
|
||||
const fromQuery = new URLSearchParams(window.location.search).get('user_code')
|
||||
if (fromQuery) return fromQuery
|
||||
const m = window.location.pathname.match(/\/login\/oauth\/device\/([^/?#]+)/)
|
||||
return m ? decodeURIComponent(m[1]!) : ''
|
||||
}
|
||||
|
||||
/** A device-flow return must never leave tokens/codes sitting in the address
|
||||
* bar (history, referrer, shoulder-surf). Strip everything but `user_code`. */
|
||||
function scrubUrl() {
|
||||
const url = new URL(window.location.href)
|
||||
let changed = false
|
||||
for (const k of ['access_token', 'refresh_token', 'id_token', 'code', 'state']) {
|
||||
if (url.searchParams.has(k)) {
|
||||
url.searchParams.delete(k)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
export function DeviceApproval({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
const [phase, setPhase] = useState<Phase>({ s: 'checking' })
|
||||
const [userCode, setUserCode] = useState(() => readUserCode())
|
||||
// Anti-phishing gate: the `?user_code=` prefill is DISPLAY-ONLY. A signed-in
|
||||
// victim who lands here from a crafted `verification_uri_complete` link must
|
||||
// NOT be able to approve an attacker's device with one click — they have to
|
||||
// explicitly affirm the code matches the one their OWN device shows. The
|
||||
// prefill cannot tick this box, so it can never auto-approve.
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const appLabel = client.tenant.appName
|
||||
|
||||
// Resolve the issuer session: signed in → confirm, else → sign-in form. Reads
|
||||
// same-origin from `/v1/iam/get-account` (cookie session; the brand `*.id`
|
||||
// host IS `iamUrl`, so the cookie rides along) — identical to the Portal.
|
||||
useEffect(() => {
|
||||
scrubUrl()
|
||||
let alive = true
|
||||
fetch(new URL('/v1/iam/get-account', client.tenant.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((b: Record<string, unknown>) => {
|
||||
if (!alive) return
|
||||
const d = b.data as Record<string, unknown> | undefined
|
||||
if (b.status === 'ok' && d && typeof d === 'object') {
|
||||
setPhase({ s: 'confirm', email: str(d.email) ?? str(d.name) })
|
||||
} else {
|
||||
setPhase({ s: 'signin' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setPhase({ s: 'signin' })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [client.tenant.iamUrl])
|
||||
|
||||
async function approve() {
|
||||
setError(null)
|
||||
setPhase({ s: 'approving' })
|
||||
const res = await client.approveDevice(userCode)
|
||||
if (res.ok) {
|
||||
setPhase({ s: 'approved' })
|
||||
} else if (res.required) {
|
||||
setPhase({ s: 'consent' })
|
||||
} else {
|
||||
setError(res.error ?? 'Approval failed. Restart sign-in on your device.')
|
||||
setPhase({ s: 'confirm' })
|
||||
}
|
||||
}
|
||||
|
||||
if (phase.s === 'checking') {
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase.s === 'signin') {
|
||||
// Sign in first (reuse the normal flow). The password leg stays on-page via
|
||||
// `onAuthenticated` and re-checks the session; the social leg round-trips and
|
||||
// returns to THIS page (postLoginRedirect), where the session check resumes.
|
||||
const returnTo = userCode
|
||||
? `${window.location.pathname}?user_code=${encodeURIComponent(userCode)}`
|
||||
: window.location.pathname
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>Sign in to approve your device</h1>
|
||||
<SocialButtons client={client} intent="signin" postLoginRedirect={returnTo} />
|
||||
<LoginForm client={client} onAuthenticated={() => setPhase({ s: 'confirm' })} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/forget">Forgot password?</a>
|
||||
</p>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase.s === 'approved') {
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>You're signed in on your device</h1>
|
||||
<p className="lede">Approval complete — you can close this window and return to your device.</p>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
const busy = phase.s === 'approving'
|
||||
const consent = phase.s === 'consent'
|
||||
const email = phase.s === 'confirm' || phase.s === 'consent' ? phase.email : undefined
|
||||
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>Approve this device</h1>
|
||||
{email ? <p className="lede">Signed in as {email}</p> : null}
|
||||
|
||||
<p className="hanzo-id-device-prompt">
|
||||
You are about to authorize <strong>{appLabel}</strong> to sign in on a device. Approve
|
||||
ONLY if this code matches the one shown on that device.
|
||||
</p>
|
||||
|
||||
<label className="hanzo-id-device-code-field">
|
||||
<span>Device code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoCapitalize="characters"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoComplete="one-time-code"
|
||||
aria-label="Device code"
|
||||
className="hanzo-id-device-code"
|
||||
value={userCode}
|
||||
onChange={(e) => setUserCode(e.target.value)}
|
||||
placeholder="e.g. K7M4P2QH"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{consent ? (
|
||||
<p className="hanzo-id-info">
|
||||
{brand.name} needs your consent to continue. By approving you grant {appLabel} access to
|
||||
your profile.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<label className="hanzo-id-device-confirm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span>
|
||||
I started this sign-in on my own device, and this code matches the one it shows.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
|
||||
<div className="hanzo-id-cta-row">
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-btn primary"
|
||||
disabled={busy || userCode.trim().length === 0 || !confirmed}
|
||||
onClick={approve}
|
||||
>
|
||||
{busy ? 'Approving…' : consent ? 'Approve & grant access' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
function Shell({ brand, children }: { brand: BrandContract; children: ReactNode }) {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-device">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { ForgotForm, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
export function Forgot({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-forgot">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Reset your {brand.name} password</h1>
|
||||
<ForgotForm client={client} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/login">Back to sign in</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import {
|
||||
LoginForm,
|
||||
MfaEnrollForm,
|
||||
OTPForm,
|
||||
SocialButtons,
|
||||
mfaChannelOf,
|
||||
type AuthClient,
|
||||
type LoginResponse,
|
||||
} from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const redirectUri = sp.get('redirect_uri') ?? undefined
|
||||
const state = sp.get('state') ?? undefined
|
||||
const clientIdOverride = sp.get('client_id') ?? undefined
|
||||
const codeChallenge = sp.get('code_challenge') ?? undefined
|
||||
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
|
||||
const nonce = sp.get('nonce') ?? undefined
|
||||
// A provider the user already chose upstream (the console sends
|
||||
// `?provider_hint=provider-github` when they click "Continue with GitHub"
|
||||
// over there). With no live session we launch that provider straight away
|
||||
// instead of showing this form — so the click lands directly in the social
|
||||
// flow, never bouncing the user to a second login page. We honor ONLY
|
||||
// `provider_hint`, never a bare `provider=` (the SSO SDK uses that for its
|
||||
// `<org>-iam` IDP hint — a different meaning).
|
||||
const providerHint = sp.get('provider_hint') ?? undefined
|
||||
|
||||
// TRUE single sign-on. When an app sent the user here for an authorization
|
||||
// code (client_id + redirect_uri present) AND the browser already holds an
|
||||
// issuer session from an earlier sign-in (the `iam_session_id` cookie), mint
|
||||
// the code from that session and redirect straight back — no form, no
|
||||
// credential re-entry. With no live session we fall back to auto-launching the
|
||||
// hinted provider if one was named, else the interactive form. A bare portal
|
||||
// visit (no client_id/redirect_uri) has nowhere to redirect, so it shows the
|
||||
// form immediately as before.
|
||||
const canSilent = !!clientIdOverride && !!redirectUri
|
||||
const fallback = providerHint ? 'federate' : 'form'
|
||||
const [phase, setPhase] = useState<'silent' | 'federate' | 'form'>(canSilent ? 'silent' : fallback)
|
||||
|
||||
// null = show the credential form; otherwise IAM returned an MFA signal and
|
||||
// we render the matching step instead of navigating on.
|
||||
const [mfa, setMfa] = useState<LoginResponse | null>(null)
|
||||
const [challengeError, setChallengeError] = useState<string | null>(null)
|
||||
|
||||
const clientId = clientIdOverride ?? client.tenant.clientId
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSilent) return
|
||||
let cancelled = false
|
||||
client
|
||||
.silentLogin({
|
||||
clientId: clientIdOverride!,
|
||||
application: clientIdOverride!,
|
||||
redirectUri: redirectUri!,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
nonce,
|
||||
})
|
||||
.then((r) => {
|
||||
if (cancelled) return
|
||||
if (r.redirectUrl) {
|
||||
window.location.assign(r.redirectUrl)
|
||||
} else {
|
||||
setPhase(fallback)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPhase(fallback)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Run once on mount; the OAuth params are fixed for the life of the page.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// The credential check succeeded (or MFA was satisfied). For a downstream
|
||||
// OIDC request, re-enter authorize with the now-established IAM session so it
|
||||
// mints the code; for a bare portal sign-in, land on onboarding.
|
||||
function completeAfterAuth() {
|
||||
if (redirectUri) {
|
||||
window.location.href = client.authorize({
|
||||
clientId,
|
||||
redirectUri,
|
||||
state: state ?? '',
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
} else {
|
||||
window.location.href = '/onboarding'
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === 'silent') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main aria-busy="true">
|
||||
<p>Signing you in…</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-launch the hinted provider. `SocialButtons` is headless here — it
|
||||
// resolves the app config and runs the hop; we show a busy state meanwhile,
|
||||
// and drop to the form only if the hint matched no configured provider.
|
||||
if (phase === 'federate') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main aria-busy="true">
|
||||
<p>Signing you in…</p>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signin"
|
||||
postLoginRedirect={redirectUri}
|
||||
autoStart={providerHint}
|
||||
onAutoStartResolved={(started) => {
|
||||
if (!started) setPhase('form')
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mfa?.mfaStage === 'enroll') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<MfaEnrollForm client={client} onComplete={completeAfterAuth} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mfa?.mfaStage === 'challenge') {
|
||||
const iamType = mfa.mfaTypes?.[0] ?? 'app'
|
||||
async function onChallenge(code: string) {
|
||||
setChallengeError(null)
|
||||
const res = await client.mfaChallenge({
|
||||
mfaType: iamType,
|
||||
passcode: code,
|
||||
clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
redirectUri,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
if (res.error) {
|
||||
setChallengeError(res.error)
|
||||
} else if (res.redirectUrl) {
|
||||
window.location.href = res.redirectUrl
|
||||
} else {
|
||||
completeAfterAuth()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Two-factor authentication</h1>
|
||||
<p className="lede">Enter the code from your authenticator app to finish signing in.</p>
|
||||
{challengeError ? <p role="alert" className="hanzo-id-error">{challengeError}</p> : null}
|
||||
<OTPForm channel={mfaChannelOf(iamType)} onSubmit={onChallenge} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Sign in to {brand.name}</h1>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signin"
|
||||
postLoginRedirect={redirectUri}
|
||||
/>
|
||||
<LoginForm
|
||||
client={client}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
clientIdOverride={clientIdOverride ?? undefined}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
nonce={nonce}
|
||||
onMfaRequired={setMfa}
|
||||
/>
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/forget">Forgot password?</a> · <a href="/signup">Create account</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { createIam } from '@hanzo/id-auth'
|
||||
import { OnboardingFlow, createOnboardingService, type OnboardingState } from '@hanzo/id-onboarding'
|
||||
import { getConnector } from '@hanzo/id-connect/connectors'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* Post-login onboarding page.
|
||||
*
|
||||
* Reached after a bare portal sign-in (no downstream `redirect_uri`). Mounts
|
||||
* the `@hanzo/id-onboarding` flow (org → project → wallet) wired to:
|
||||
*
|
||||
* - the IAM session token: read from the same `@hanzo/iam` PKCE client the
|
||||
* Callback stored it on, so the onboarding writes ride the logged-in
|
||||
* user's bearer token. One client, one way.
|
||||
* - a `window.ethereum` wallet connector: the host owns the wallet lib so
|
||||
* the onboarding pkg stays wallet-agnostic. Absent injected provider →
|
||||
* the wallet step is skip-only.
|
||||
*
|
||||
* On completion it lands on the portal home (`/`); a downstream app that
|
||||
* wanted a token would have carried `redirect_uri` and never reached here.
|
||||
*/
|
||||
export function Onboarding({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
|
||||
const iam = useMemo(() => createIam(tenant), [tenant])
|
||||
|
||||
const service = useMemo(
|
||||
() =>
|
||||
createOnboardingService({
|
||||
iamUrl: tenant.iamUrl,
|
||||
orgId: tenant.orgId,
|
||||
getAccessToken: () => iam.getValidAccessToken(),
|
||||
}),
|
||||
[tenant, iam],
|
||||
)
|
||||
|
||||
function onComplete(_state: OnboardingState) {
|
||||
// Land on the authenticated portal (apps launcher), NOT the bare hero.
|
||||
// The marker makes the portal treat the just-established session as authed
|
||||
// even before the cross-request get-account read settles.
|
||||
window.location.replace('/?signed_in=1')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-onboarding-page">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<OnboardingFlow
|
||||
service={service}
|
||||
brandName={brand.name}
|
||||
connectWallet={connectInjectedWallet}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* EVM wallet connector backed by @hanzo/id-connect (EIP-6963 multi-injection,
|
||||
* viem under the hood). Returns the checksummed 0x address, or null when the
|
||||
* user cancels or no injected EVM wallet is present. The onboarding wallet step
|
||||
* only needs the address (it stores it via update-user?columns=web3onboard), so
|
||||
* we connect and return account.address — no signature round-trip here.
|
||||
*/
|
||||
async function connectInjectedWallet(): Promise<string | null> {
|
||||
try {
|
||||
const account = await getConnector('evm').connect()
|
||||
return account.address ?? null
|
||||
} catch {
|
||||
return null // user rejected, or no injected EVM wallet available
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import type { AuthClient } from '@hanzo/id-auth'
|
||||
import { Login } from './Login'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
import { appsFor, billingFor } from '../marketing'
|
||||
|
||||
type Auth =
|
||||
| { s: 'loading' }
|
||||
| { s: 'anon' }
|
||||
| { s: 'authed'; name?: string; email?: string }
|
||||
|
||||
/**
|
||||
* Root portal (`/`). The portal IS the login surface, not a marketing hero:
|
||||
*
|
||||
* - signed out → the actual `<Login>` form (GitHub/Google/email+password),
|
||||
* identical to `/login`. A bare sign-in here lands on
|
||||
* onboarding, then back on `/` authenticated.
|
||||
* - signed in → the apps launcher (the org's apps) + billing / sign-out.
|
||||
*
|
||||
* Auth is read same-origin from `/v1/iam/get-account` (cookie session;
|
||||
* `tenant.iamUrl` is the brand's own `*.id` host, so this is first-party and
|
||||
* the session cookie rides along). The `?signed_in=1` marker set by the
|
||||
* bare-login / onboarding-complete redirect is the authoritative "just
|
||||
* authenticated" signal when the cookie read hasn't propagated yet.
|
||||
*/
|
||||
export function Portal({
|
||||
client,
|
||||
brand,
|
||||
tenant,
|
||||
}: {
|
||||
client: AuthClient
|
||||
brand: BrandContract
|
||||
tenant: TenantConfig
|
||||
}) {
|
||||
const [auth, setAuth] = useState<Auth>({ s: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
|
||||
fetch(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((b: Record<string, unknown>) => {
|
||||
if (!alive) return
|
||||
const d = b.data as Record<string, unknown> | undefined
|
||||
if (b.status === 'ok' && d && typeof d === 'object') {
|
||||
setAuth({ s: 'authed', name: str(d.displayName) ?? str(d.name), email: str(d.email) })
|
||||
} else {
|
||||
setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [tenant.iamUrl])
|
||||
|
||||
if (auth.s === 'loading') {
|
||||
return (
|
||||
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Signed out: the root IS the login form (no marketing hero).
|
||||
if (auth.s === 'anon') return <Login client={client} brand={brand} />
|
||||
|
||||
// Signed in: the apps launcher.
|
||||
const apps = appsFor(tenant.orgId)
|
||||
const billingUrl = billingFor(tenant.orgId)
|
||||
const logoutUrl = client.logout(undefined, `${tenant.publicOrigin}/login`)
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-portal">
|
||||
<BrandHeader brand={brand} />
|
||||
<main style={{ width: '100%', maxWidth: 760 }}>
|
||||
<h1>Your {brand.name} apps</h1>
|
||||
{auth.email ? <p className="lede">{auth.email}</p> : null}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
|
||||
gap: 12,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
{apps.map((a) => (
|
||||
<a
|
||||
key={a.name}
|
||||
href={a.href}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '16px 18px',
|
||||
border: '1px solid rgba(255,255,255,0.14)',
|
||||
borderRadius: 12,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{a.name}</span>
|
||||
<span aria-hidden style={{ opacity: 0.5 }}>↗</span>
|
||||
</div>
|
||||
<div style={{ opacity: 0.6, fontSize: 13, marginTop: 4 }}>{a.description}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 28, display: 'flex', gap: 18 }}>
|
||||
<a className="hanzo-id-linkbtn" href={billingUrl}>Billing</a>
|
||||
<a className="hanzo-id-linkbtn" href={logoutUrl}>Sign out</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { SignupForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
export function Signup({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const inviteCode = sp.get('invite') ?? undefined
|
||||
const clientIdOverride = sp.get('client_id') ?? undefined
|
||||
const redirectUri = sp.get('redirect_uri') ?? undefined
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-signup">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Create your {brand.name} account</h1>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signup"
|
||||
postLoginRedirect={redirectUri}
|
||||
/>
|
||||
<SignupForm client={client} inviteCode={inviteCode} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
Already have an account? <a href="/login">Sign in</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'path'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
// ESM Vite config has no global `require`; build one bound to this file so
|
||||
// `require.resolve('@scope/brand/brand.json')` works at config-eval time.
|
||||
const req = createRequire(import.meta.url)
|
||||
|
||||
/**
|
||||
* Per-brand brand.json copy plugin.
|
||||
*
|
||||
* Each per-org brand package (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
|
||||
* `@parsdao/brand`) ships a `brand.json` at the package root. We serve each at
|
||||
* a FLAT, encoding-safe path `/brand/<scope>.json` (scope = the npm scope:
|
||||
* `@hanzo/brand` -> `hanzo`). A nested `/brand/@hanzo/brand/brand.json` URL
|
||||
* carries a literal `@` and an encoded `%2F` that the production static server
|
||||
* (hanzoai/static) cannot map to the on-disk file — it falls through to the
|
||||
* SPA catch-all and returns index.html, so the runtime brand fetch would parse
|
||||
* HTML as JSON. The flat slug avoids that entirely. `loadBrand` fetches the
|
||||
* same `/brand/<scope>.json`.
|
||||
*
|
||||
* Assets (logos, favicons) are imported by URL inside the per-brand `brand.json`
|
||||
* (CDN URLs in production), so no further asset copying is needed.
|
||||
*/
|
||||
const BRAND_PACKAGES = ['@hanzo/brand', '@luxfi/brand', '@zooai/brand', '@parsdao/brand']
|
||||
|
||||
/** npm scope -> flat brand slug: `@hanzo/brand` -> `hanzo`. */
|
||||
const brandSlug = (pkg: string): string => pkg.replace(/^@/, '').split('/')[0]!
|
||||
|
||||
function brandJsonPlugin() {
|
||||
return {
|
||||
name: 'hanzo-id-brand-json',
|
||||
configureServer(server: any) {
|
||||
server.middlewares.use((req2: any, res: any, next: any) => {
|
||||
const m = /^\/brand\/([^/]+)\.json$/.exec(req2.url ?? '')
|
||||
if (!m) return next()
|
||||
const slug = m[1]!
|
||||
const pkg = BRAND_PACKAGES.find((p) => brandSlug(p) === slug)
|
||||
if (!pkg) {
|
||||
res.statusCode = 404
|
||||
return res.end()
|
||||
}
|
||||
try {
|
||||
const path = req.resolve(`${pkg}/brand.json`)
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
return res.end(readFileSync(path, 'utf8'))
|
||||
} catch {
|
||||
res.statusCode = 404
|
||||
return res.end()
|
||||
}
|
||||
})
|
||||
},
|
||||
generateBundle(this: any) {
|
||||
for (const pkg of BRAND_PACKAGES) {
|
||||
try {
|
||||
const path = req.resolve(`${pkg}/brand.json`)
|
||||
if (!existsSync(path)) continue
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: `brand/${brandSlug(pkg)}.json`,
|
||||
source: readFileSync(path, 'utf8'),
|
||||
})
|
||||
} catch {
|
||||
// pkg not installed — skip silently; only the brands listed in
|
||||
// package.json deps actually need their JSON shipped.
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), brandJsonPlugin()],
|
||||
resolve: {
|
||||
alias: { '@': resolve(__dirname, 'src') },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
preview: {
|
||||
port: 5174,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
@@ -1,148 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'zh-TW', label: '繁體中文' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'ko', label: '한국어' },
|
||||
{ code: 'es', label: 'Español' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'de', label: 'Deutsch' },
|
||||
{ code: 'pt', label: 'Português' },
|
||||
{ code: 'pt-BR', label: 'Português (BR)' },
|
||||
{ code: 'it', label: 'Italiano' },
|
||||
{ code: 'nl', label: 'Nederlands' },
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'cs', label: 'Čeština' },
|
||||
{ code: 'sk', label: 'Slovenčina' },
|
||||
{ code: 'hu', label: 'Magyar' },
|
||||
{ code: 'ro', label: 'Română' },
|
||||
{ code: 'bg', label: 'Български' },
|
||||
{ code: 'hr', label: 'Hrvatski' },
|
||||
{ code: 'sr', label: 'Српски' },
|
||||
{ code: 'sl', label: 'Slovenščina' },
|
||||
{ code: 'uk', label: 'Українська' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
{ code: 'el', label: 'Ελληνικά' },
|
||||
{ code: 'tr', label: 'Türkçe' },
|
||||
{ code: 'ar', label: 'العربية' },
|
||||
{ code: 'fa', label: 'فارسی' },
|
||||
{ code: 'he', label: 'עברית' },
|
||||
{ code: 'hi', label: 'हिन्दी' },
|
||||
{ code: 'bn', label: 'বাংলা' },
|
||||
{ code: 'ta', label: 'தமிழ்' },
|
||||
{ code: 'te', label: 'తెలుగు' },
|
||||
{ code: 'mr', label: 'मराठी' },
|
||||
{ code: 'gu', label: 'ગુજરાતી' },
|
||||
{ code: 'kn', label: 'ಕನ್ನಡ' },
|
||||
{ code: 'ml', label: 'മലയാളം' },
|
||||
{ code: 'pa', label: 'ਪੰਜਾਬੀ' },
|
||||
{ code: 'ur', label: 'اردو' },
|
||||
{ code: 'th', label: 'ไทย' },
|
||||
{ code: 'vi', label: 'Tiếng Việt' },
|
||||
{ code: 'id', label: 'Bahasa Indonesia' },
|
||||
{ code: 'ms', label: 'Bahasa Melayu' },
|
||||
{ code: 'tl', label: 'Filipino' },
|
||||
{ code: 'sw', label: 'Kiswahili' },
|
||||
{ code: 'am', label: 'አማርኛ' },
|
||||
{ code: 'ha', label: 'Hausa' },
|
||||
{ code: 'yo', label: 'Yorùbá' },
|
||||
{ code: 'ig', label: 'Igbo' },
|
||||
{ code: 'zu', label: 'isiZulu' },
|
||||
{ code: 'af', label: 'Afrikaans' },
|
||||
{ code: 'sv', label: 'Svenska' },
|
||||
{ code: 'da', label: 'Dansk' },
|
||||
{ code: 'no', label: 'Norsk' },
|
||||
{ code: 'fi', label: 'Suomi' },
|
||||
{ code: 'et', label: 'Eesti' },
|
||||
{ code: 'lv', label: 'Latviešu' },
|
||||
{ code: 'lt', label: 'Lietuvių' },
|
||||
{ code: 'ca', label: 'Català' },
|
||||
{ code: 'eu', label: 'Euskara' },
|
||||
{ code: 'gl', label: 'Galego' },
|
||||
{ code: 'ka', label: 'ქართული' },
|
||||
{ code: 'hy', label: 'Հայերեն' },
|
||||
{ code: 'az', label: 'Azərbaycan' },
|
||||
{ code: 'uz', label: 'Oʻzbek' },
|
||||
{ code: 'kk', label: 'Қазақ' },
|
||||
{ code: 'mn', label: 'Монгол' },
|
||||
{ code: 'my', label: 'မြန်မာ' },
|
||||
{ code: 'km', label: 'ភាសាខ្មែរ' },
|
||||
{ code: 'lo', label: 'ລາວ' },
|
||||
{ code: 'ne', label: 'नेपाली' },
|
||||
{ code: 'si', label: 'සිංහල' },
|
||||
]
|
||||
|
||||
export default function LanguageDropdown() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [lang, setLang] = useState('en')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Read from localStorage or browser language
|
||||
const saved = localStorage.getItem('hanzo_lang')
|
||||
if (saved) {
|
||||
setLang(saved)
|
||||
} else {
|
||||
const browserLang = navigator.language.split('-')[0]
|
||||
const match = LANGUAGES.find(l => l.code === browserLang)
|
||||
if (match) setLang(match.code)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSelect = (code: string) => {
|
||||
setLang(code)
|
||||
localStorage.setItem('hanzo_lang', code)
|
||||
setOpen(false)
|
||||
// IAM uses ?lang= param for locale
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('lang', code)
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
|
||||
const current = LANGUAGES.find(l => l.code === lang) || LANGUAGES[0]
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors"
|
||||
aria-label="Language"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<span className="text-xs">{current.label}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-40 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl z-50 py-1 max-h-64 overflow-y-auto">
|
||||
{LANGUAGES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => handleSelect(l.code)}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-zinc-800 transition-colors ${
|
||||
l.code === lang ? 'text-white' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import type { BrandingConfig } from '@/lib/branding'
|
||||
import { passwordLogin, startAuthorize } from '@/lib/oauth'
|
||||
import { getIamUrl, getOrg, getDefaultClientId } from '@/lib/iam'
|
||||
import { CLIENT_APP_MAP } from '@/lib/clients'
|
||||
|
||||
interface LoginFormProps {
|
||||
branding: BrandingConfig
|
||||
}
|
||||
|
||||
type AuthMethod = 'password' | 'code' | 'webauthn' | 'faceid'
|
||||
|
||||
export default function LoginForm({ branding }: LoginFormProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const [authMethod, setAuthMethod] = useState<AuthMethod>('password')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [autoSignIn, setAutoSignIn] = useState(true)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
|
||||
const iamUrl = getIamUrl(host)
|
||||
const org = getOrg(host)
|
||||
const defaultClientId = getDefaultClientId(host)
|
||||
|
||||
// OAuth params from query string (when redirected from a client app)
|
||||
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId') ?? defaultClientId
|
||||
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri')
|
||||
const responseType = searchParams.get('response_type') ?? searchParams.get('responseType')
|
||||
const scope = searchParams.get('scope')
|
||||
const state = searchParams.get('state')
|
||||
const codeChallenge = searchParams.get('code_challenge')
|
||||
const codeChallengeMethod = searchParams.get('code_challenge_method')
|
||||
|
||||
const isOAuthFlow = !!(redirectUri && responseType)
|
||||
|
||||
// Resolve the IAM application name from clientId.
|
||||
// IAM's /api/login expects the application NAME (e.g. "app-hanzobot"),
|
||||
// not the OAuth client_id (e.g. "hanzobot-client-id").
|
||||
const [appName, setAppName] = useState<string | null>(null)
|
||||
|
||||
// Capture referral code from URL and persist through redirects
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const ref = searchParams.get('ref')
|
||||
if (ref) {
|
||||
sessionStorage.setItem('hanzo_ref_code', ref)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId) return
|
||||
|
||||
const params = new URLSearchParams({
|
||||
clientId,
|
||||
type: 'code',
|
||||
responseType: responseType || 'code',
|
||||
redirectUri: redirectUri || `${window.location.origin}/callback`,
|
||||
scope: scope || 'openid profile email',
|
||||
state: state || '',
|
||||
})
|
||||
|
||||
fetch(`/api/get-app-login?${params}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// IAM returns the app data even when status is "error"
|
||||
// (e.g. redirect URI validation fails but app info is still present)
|
||||
if (data?.data?.name) {
|
||||
setAppName(data.data.name)
|
||||
} else {
|
||||
// Fallback to static client map
|
||||
const client = CLIENT_APP_MAP[clientId]
|
||||
if (client) setAppName(client.application)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// API unreachable — use static client map
|
||||
const client = CLIENT_APP_MAP[clientId]
|
||||
if (client) setAppName(client.application)
|
||||
})
|
||||
}, [clientId, responseType, redirectUri, scope, state])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
if (!email || !password) {
|
||||
throw new Error('Please enter your email and password')
|
||||
}
|
||||
|
||||
// Resolve application name: API result > static map > raw clientId
|
||||
const resolvedApp = appName || CLIENT_APP_MAP[clientId]?.application || clientId
|
||||
|
||||
if (isOAuthFlow) {
|
||||
// OAuth flow: direct code grant via /api/login with PKCE
|
||||
// Pass OAuth params (including code_challenge) as query params so IAM
|
||||
// binds the authorization code to the PKCE challenge.
|
||||
const loginParams = new URLSearchParams({
|
||||
clientId,
|
||||
responseType: responseType!,
|
||||
redirectUri: redirectUri!,
|
||||
...(scope ? { scope } : {}),
|
||||
...(state ? { state } : {}),
|
||||
...(codeChallenge ? { code_challenge: codeChallenge } : {}),
|
||||
...(codeChallengeMethod ? { code_challenge_method: codeChallengeMethod } : {}),
|
||||
})
|
||||
|
||||
const res = await fetch(`/api/login?${loginParams}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: responseType === 'token' ? 'token' : 'code',
|
||||
organization: org,
|
||||
username: email,
|
||||
password,
|
||||
application: resolvedApp,
|
||||
clientId,
|
||||
redirectUri: redirectUri!,
|
||||
state: state || '',
|
||||
// PKCE: pass in body too (Casdoor may read from body, not just query params)
|
||||
...(codeChallenge ? { codeChallenge } : {}),
|
||||
...(codeChallengeMethod ? { codeChallengeMethod } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.status !== 'ok') throw new Error(data.msg || 'Login failed')
|
||||
|
||||
// Redirect to the client's callback with the authorization code
|
||||
const redirect = new URL(redirectUri!)
|
||||
redirect.searchParams.set('code', data.data)
|
||||
if (state) redirect.searchParams.set('state', state)
|
||||
window.location.href = redirect.toString()
|
||||
} else {
|
||||
// Direct login: get token, store, redirect to account
|
||||
// Use origin (same-domain) so the request goes through middleware proxy
|
||||
const result = await passwordLogin({
|
||||
iamUrl: window.location.origin,
|
||||
org,
|
||||
username: email,
|
||||
password,
|
||||
application: resolvedApp,
|
||||
})
|
||||
|
||||
// Store token
|
||||
localStorage.setItem('hanzo_access_token', result.token)
|
||||
|
||||
// Fetch full user profile from userinfo endpoint (access token JWT has limited claims)
|
||||
try {
|
||||
const res = await fetch('/oauth/userinfo', {
|
||||
headers: { Authorization: `Bearer ${result.token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const info = await res.json()
|
||||
localStorage.setItem('hanzo_user', JSON.stringify({
|
||||
sub: info.sub,
|
||||
name: info.name || info.preferred_username,
|
||||
displayName: info.displayName || info.name || info.preferred_username,
|
||||
email: info.email,
|
||||
avatar: info.avatar || info.picture || info.permanentAvatar,
|
||||
}))
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback: decode JWT for basic info
|
||||
if (!localStorage.getItem('hanzo_user')) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(result.token.split('.')[1]))
|
||||
localStorage.setItem('hanzo_user', JSON.stringify({
|
||||
sub: payload.sub || payload.name,
|
||||
name: payload.name,
|
||||
displayName: payload.displayName || payload.name,
|
||||
email: payload.email || email,
|
||||
avatar: payload.avatar,
|
||||
}))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Redirect to account or home
|
||||
window.location.href = '/account'
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSSOLogin = async () => {
|
||||
const callbackUri = `${window.location.origin}/callback`
|
||||
await startAuthorize({
|
||||
iamUrl,
|
||||
clientId,
|
||||
redirectUri: callbackUri,
|
||||
scope: scope ?? 'openid profile email',
|
||||
})
|
||||
}
|
||||
|
||||
const authTabs = [
|
||||
{ key: 'password' as const, label: 'Password', enabled: branding.auth.passwordEnabled },
|
||||
{ key: 'code' as const, label: 'Code', enabled: branding.auth.codeEnabled },
|
||||
{ key: 'webauthn' as const, label: 'WebAuthn', enabled: branding.auth.webauthnEnabled },
|
||||
{ key: 'faceid' as const, label: 'Face ID', enabled: branding.auth.faceIdEnabled },
|
||||
].filter(t => t.enabled)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Auth method tabs */}
|
||||
{authTabs.length > 1 && (
|
||||
<div className="flex gap-4 mb-6 border-b border-zinc-800">
|
||||
{authTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => { setAuthMethod(tab.key); setError(null) }}
|
||||
className={`pb-3 text-sm font-medium transition-colors ${
|
||||
authMethod === tab.key
|
||||
? 'text-white border-b-2'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: authMethod === tab.key ? branding.colors.primary : 'transparent'
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Email/Username */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Email or username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input w-full pl-10 py-3 rounded-lg"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
{authMethod === 'password' && (
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input w-full pl-10 pr-12 py-3 rounded-lg"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{showPassword ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
) : (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto sign in & Forgot password */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSignIn}
|
||||
onChange={(e) => setAutoSignIn(e.target.checked)}
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ accentColor: branding.colors.primary }}
|
||||
/>
|
||||
<span className="text-zinc-400">Remember me</span>
|
||||
</label>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="link text-sm"
|
||||
style={{ color: branding.colors.primary }}
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Sign in button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50 transition-opacity"
|
||||
style={{ backgroundColor: branding.colors.primary }}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
|
||||
{/* Sign up link */}
|
||||
<p className="text-center text-sm text-zinc-500">
|
||||
No account?{' '}
|
||||
<a
|
||||
href={`/signup${typeof window !== 'undefined' ? window.location.search : ''}`}
|
||||
className="link"
|
||||
style={{ color: branding.colors.primary }}
|
||||
>
|
||||
Sign up now
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{/* Social providers / SSO */}
|
||||
{branding.auth.socialProviders.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-zinc-800" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-zinc-900 text-zinc-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Connect Wallet — always first */}
|
||||
{branding.auth.socialProviders.includes('metamask') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = `/oauth/authorize?provider=metamask&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
|
||||
className="flex items-center justify-center gap-2 w-full py-3 px-4 border rounded-lg font-medium transition-colors"
|
||||
style={{ borderColor: branding.colors.primary, color: branding.colors.primary }}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="6" width="20" height="14" rx="2" />
|
||||
<path d="M16 14h.01" />
|
||||
<path d="M2 10h20" />
|
||||
</svg>
|
||||
Connect Wallet
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{branding.auth.socialProviders.includes('google') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = `/oauth/authorize?provider=google&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
)}
|
||||
{branding.auth.socialProviders.includes('github') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = `/oauth/authorize?provider=github&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</button>
|
||||
)}
|
||||
{branding.auth.socialProviders.includes('apple') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = `/oauth/authorize?provider=apple&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17.543 12.72c-.026-2.757 2.248-4.08 2.352-4.143-1.282-1.876-3.278-2.134-3.988-2.165-1.698-.173-3.315 1.001-4.179 1.001-.864 0-2.195-.976-3.608-.95-1.858.028-3.57 1.078-4.528 2.74-1.931 3.347-.493 8.293 1.388 11.008.918 1.328 2.012 2.821 3.445 2.77 1.38-.057 1.902-.894 3.57-.894 1.668 0 2.137.894 3.604.867 1.489-.027 2.434-1.355 3.343-2.686 1.054-1.547 1.489-3.044 1.515-3.122-.034-.015-2.908-1.117-2.914-4.426zM14.829 4.793c.76-.92 1.272-2.199 1.131-3.469-1.093.045-2.415.728-3.2 1.648-.706.818-1.323 2.119-1.157 3.362 1.214.093 2.454-.623 3.226-1.541z"/>
|
||||
</svg>
|
||||
Apple
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Footer links */}
|
||||
<div className="mt-8 pt-6 border-t border-zinc-800 flex justify-center gap-4 text-xs text-zinc-500">
|
||||
{branding.links.terms && (
|
||||
<a href={branding.links.terms} className="hover:text-zinc-300">Terms</a>
|
||||
)}
|
||||
{branding.links.privacy && (
|
||||
<a href={branding.links.privacy} className="hover:text-zinc-300">Privacy</a>
|
||||
)}
|
||||
{branding.links.support && (
|
||||
<a href={branding.links.support} className="hover:text-zinc-300">Support</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { BrandingConfig } from '@/lib/branding'
|
||||
|
||||
interface MarketingPanelProps {
|
||||
branding: BrandingConfig
|
||||
}
|
||||
|
||||
export default function MarketingPanel({ branding }: MarketingPanelProps) {
|
||||
const [currentQuote, setCurrentQuote] = useState(0)
|
||||
const quotes = branding.content.quotes || []
|
||||
|
||||
// Auto-rotate quotes
|
||||
useEffect(() => {
|
||||
if (quotes.length <= 1) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentQuote((prev) => (prev + 1) % quotes.length)
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [quotes.length])
|
||||
|
||||
return (
|
||||
<div className="max-w-md space-y-8">
|
||||
{/* Badge */}
|
||||
{branding.content.tagline && (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-zinc-700 text-sm">
|
||||
<span className="text-yellow-400">✦</span>
|
||||
<span className="text-zinc-300">{branding.content.tagline}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{branding.content.title && (
|
||||
<h2 className="text-4xl font-bold text-white">
|
||||
{branding.content.title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Subtitle */}
|
||||
{branding.content.subtitle && (
|
||||
<p className="text-zinc-400 text-lg">
|
||||
{branding.content.subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Interactive prompt (optional feature showcase) */}
|
||||
{branding.content.features && branding.content.features.length > 0 && (
|
||||
<div className="bg-zinc-900/50 rounded-xl p-4 border border-zinc-800">
|
||||
<div className="text-xs text-zinc-500 mb-2 flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: branding.colors.primary }} />
|
||||
TRY SOMETHING LIKE
|
||||
</div>
|
||||
<p className="text-white">
|
||||
{branding.content.features[0].description}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium"
|
||||
style={{
|
||||
backgroundColor: branding.colors.primary,
|
||||
color: branding.colors.primaryText
|
||||
}}
|
||||
>
|
||||
<span>✦</span>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Testimonials/Quotes */}
|
||||
{quotes.length > 0 && (
|
||||
<div className="quote-card">
|
||||
<blockquote className="text-white mb-4">
|
||||
<span className="text-2xl text-zinc-600">"</span>
|
||||
{quotes[currentQuote].text}
|
||||
<span className="text-2xl text-zinc-600">"</span>
|
||||
</blockquote>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{quotes[currentQuote].avatar ? (
|
||||
<img
|
||||
src={quotes[currentQuote].avatar}
|
||||
alt={quotes[currentQuote].author}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-medium"
|
||||
style={{ backgroundColor: branding.colors.primary }}
|
||||
>
|
||||
{quotes[currentQuote].author.split(' ').map(n => n[0]).join('').slice(0, 2)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-white">
|
||||
{quotes[currentQuote].author}
|
||||
</div>
|
||||
{(quotes[currentQuote].role || quotes[currentQuote].company) && (
|
||||
<div className="text-sm text-zinc-500">
|
||||
{[quotes[currentQuote].role, quotes[currentQuote].company].filter(Boolean).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quote indicators */}
|
||||
{quotes.length > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
{quotes.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentQuote(i)}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
i === currentQuote ? 'bg-white' : 'bg-zinc-600'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: i === currentQuote ? branding.colors.primary : undefined
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import type { BrandingConfig } from '@/lib/branding'
|
||||
import { getIamUrl, getOrg, getDefaultClientId } from '@/lib/iam'
|
||||
|
||||
interface SignUpFormProps {
|
||||
branding: BrandingConfig
|
||||
}
|
||||
|
||||
export default function SignUpForm({ branding }: SignUpFormProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
|
||||
const iamUrl = getIamUrl(host)
|
||||
const org = getOrg(host)
|
||||
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId') ?? getDefaultClientId(host)
|
||||
|
||||
// Resolve the IAM application name from clientId.
|
||||
// IAM's /api/signup expects the application NAME (e.g. "app-hanzobot"),
|
||||
// not the OAuth client_id (e.g. "hanzobot-client-id").
|
||||
const [appName, setAppName] = useState<string | null>(null)
|
||||
|
||||
// Capture referral code from URL and persist through redirects
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const ref = searchParams.get('ref')
|
||||
if (ref) {
|
||||
sessionStorage.setItem('hanzo_ref_code', ref)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!clientId) return
|
||||
|
||||
const params = new URLSearchParams({
|
||||
clientId,
|
||||
type: 'code',
|
||||
responseType: 'code',
|
||||
redirectUri: `${window.location.origin}/callback`,
|
||||
scope: 'openid profile email',
|
||||
state: '',
|
||||
})
|
||||
|
||||
fetch(`/api/get-app-login?${params}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// IAM returns the app data even when status is "error"
|
||||
// (e.g. redirect URI validation fails but app info is still present)
|
||||
if (data?.data?.name) {
|
||||
setAppName(data.data.name)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [clientId])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
if (!email || !password) {
|
||||
throw new Error('Please fill in all required fields')
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters')
|
||||
}
|
||||
|
||||
const username = email.split('@')[0]
|
||||
const res = await fetch(`${iamUrl}/api/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
organization: org,
|
||||
application: appName || clientId,
|
||||
username,
|
||||
name: username,
|
||||
displayName: name || username,
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.status !== 'ok') {
|
||||
throw new Error(data.msg || 'Sign up failed')
|
||||
}
|
||||
|
||||
// Redirect to login with success message
|
||||
const loginUrl = new URL('/login', window.location.origin)
|
||||
// Preserve OAuth params and referral code
|
||||
const params = ['client_id', 'clientId', 'redirect_uri', 'redirectUri', 'response_type', 'responseType', 'scope', 'state', 'ref']
|
||||
for (const p of params) {
|
||||
const v = searchParams.get(p)
|
||||
if (v) loginUrl.searchParams.set(p, v)
|
||||
}
|
||||
loginUrl.searchParams.set('registered', '1')
|
||||
window.location.href = loginUrl.toString()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sign up failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Create account</h2>
|
||||
<p className="text-zinc-400 text-sm mb-6">
|
||||
Sign up for {branding.orgName}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Full name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input w-full pl-10 py-3 rounded-lg"
|
||||
autoComplete="name"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="input w-full pl-10 py-3 rounded-lg"
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Password (min 8 characters)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input w-full pl-10 pr-12 py-3 rounded-lg"
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{showPassword ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
) : (
|
||||
<>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50 transition-opacity"
|
||||
style={{ backgroundColor: branding.colors.primary }}
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-zinc-500">
|
||||
Already have an account?{' '}
|
||||
<a
|
||||
href={`/login${typeof window !== 'undefined' ? window.location.search : ''}`}
|
||||
className="link"
|
||||
style={{ color: branding.colors.primary }}
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{/* Terms */}
|
||||
{(branding.links.terms || branding.links.privacy) && (
|
||||
<p className="text-center text-xs text-zinc-500 mt-4">
|
||||
By creating an account, you agree to our{' '}
|
||||
{branding.links.terms && (
|
||||
<a href={branding.links.terms} className="hover:text-zinc-300">Terms</a>
|
||||
)}
|
||||
{branding.links.terms && branding.links.privacy && ' and '}
|
||||
{branding.links.privacy && (
|
||||
<a href={branding.links.privacy} className="hover:text-zinc-300">Privacy Policy</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,736 +0,0 @@
|
||||
/**
|
||||
* Branding configuration fetched from IAM backend based on domain
|
||||
*
|
||||
* Each organization can customize:
|
||||
* - Logo (URL or base64)
|
||||
* - Colors (primary, secondary, background, text)
|
||||
* - Login page content (quotes, testimonials)
|
||||
* - Links (terms, privacy, support)
|
||||
* - Features (which auth methods to show)
|
||||
*/
|
||||
|
||||
export interface BrandingConfig {
|
||||
// Organization info
|
||||
orgId: string
|
||||
orgName: string
|
||||
domain: string
|
||||
|
||||
// Visual branding
|
||||
logo: string
|
||||
logoAlt?: string
|
||||
favicon?: string
|
||||
|
||||
// Color scheme
|
||||
colors: {
|
||||
primary: string // Button color, accents
|
||||
primaryText: string // Text on primary color
|
||||
background: string // Page background
|
||||
surface: string // Card/form background
|
||||
text: string // Primary text
|
||||
textMuted: string // Secondary text
|
||||
border: string // Borders
|
||||
error: string // Error states
|
||||
}
|
||||
|
||||
// Login page content
|
||||
content: {
|
||||
title?: string // Main heading
|
||||
subtitle?: string // Subheading
|
||||
tagline?: string // Marketing tagline
|
||||
quotes?: Quote[] // Testimonials/quotes
|
||||
features?: Feature[] // Feature highlights
|
||||
}
|
||||
|
||||
// Links
|
||||
links: {
|
||||
terms?: string
|
||||
privacy?: string
|
||||
support?: string
|
||||
docs?: string
|
||||
home?: string
|
||||
}
|
||||
|
||||
// Auth features
|
||||
auth: {
|
||||
passwordEnabled: boolean
|
||||
codeEnabled: boolean // Email/SMS code
|
||||
webauthnEnabled: boolean // Passkeys
|
||||
faceIdEnabled: boolean
|
||||
socialProviders: string[] // google, github, etc
|
||||
}
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
text: string
|
||||
author: string
|
||||
role?: string
|
||||
company?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
title: string
|
||||
description: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
// Default Hanzo branding (fallback)
|
||||
export const defaultBranding: BrandingConfig = {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo',
|
||||
domain: 'hanzo.id',
|
||||
|
||||
logo: '/logos/hanzo.svg',
|
||||
|
||||
colors: {
|
||||
primary: '#e4e4e7', // Zinc-200 (monochrome white)
|
||||
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
|
||||
background: '#000000', // Pure black
|
||||
surface: '#0a0a0a', // Near black
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
|
||||
content: {
|
||||
title: 'Start building in seconds',
|
||||
subtitle: 'Describe your idea and watch AI bring it to life instantly',
|
||||
tagline: 'AI-powered development',
|
||||
quotes: [
|
||||
{
|
||||
text: 'Hanzo is amazing! It\'s revolutionizing how we build and deploy applications.',
|
||||
author: 'Developer',
|
||||
role: 'Software Engineer',
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
links: {
|
||||
terms: 'https://hanzo.ai/terms',
|
||||
privacy: 'https://hanzo.ai/privacy',
|
||||
support: 'https://hanzo.ai/support',
|
||||
docs: 'https://docs.hanzo.ai',
|
||||
home: 'https://hanzo.ai',
|
||||
},
|
||||
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
}
|
||||
|
||||
// Fetch branding from IAM backend based on domain
|
||||
export async function getBranding(domain: string): Promise<BrandingConfig> {
|
||||
const iamUrl = process.env.HANZO_IAM_URL || 'https://api.hanzo.id'
|
||||
|
||||
try {
|
||||
const res = await fetch(`${iamUrl}/api/branding?domain=${encodeURIComponent(domain)}`, {
|
||||
next: { revalidate: 300 }, // Cache for 5 minutes
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`Failed to fetch branding for ${domain}, using defaults`)
|
||||
return defaultBranding
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return { ...defaultBranding, ...data }
|
||||
} catch (error) {
|
||||
console.error(`Error fetching branding for ${domain}:`, error)
|
||||
return defaultBranding
|
||||
}
|
||||
}
|
||||
|
||||
// Static branding configs for known domains (can be overridden by IAM)
|
||||
// Supports both {org}.id format (e.g. lux.id) and id.{domain} format (e.g. id.ad.nexus)
|
||||
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
|
||||
'hanzo.id': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7', // Zinc-200 (monochrome white)
|
||||
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
},
|
||||
'id.hanzo.ai': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7', // Zinc-200 (monochrome white)
|
||||
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
},
|
||||
'pars.id': {
|
||||
orgId: 'pars',
|
||||
orgName: 'Pars Network',
|
||||
logo: '/logos/pars.svg',
|
||||
colors: {
|
||||
primary: '#3b82f6', // Blue
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Pars',
|
||||
subtitle: 'The decentralized network for the next generation',
|
||||
},
|
||||
links: {
|
||||
terms: 'https://pars.network/terms',
|
||||
privacy: 'https://pars.network/privacy',
|
||||
support: 'https://pars.network/support',
|
||||
docs: 'https://pars.network/docs',
|
||||
home: 'https://pars.network',
|
||||
},
|
||||
},
|
||||
'id.pars.network': {
|
||||
orgId: 'pars',
|
||||
orgName: 'Pars Network',
|
||||
logo: '/logos/pars.svg',
|
||||
colors: {
|
||||
primary: '#3b82f6', // Blue
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Pars',
|
||||
subtitle: 'The decentralized network for the next generation',
|
||||
},
|
||||
links: {
|
||||
terms: 'https://pars.network/terms',
|
||||
privacy: 'https://pars.network/privacy',
|
||||
support: 'https://pars.network/support',
|
||||
docs: 'https://pars.network/docs',
|
||||
home: 'https://pars.network',
|
||||
},
|
||||
},
|
||||
'lux.id': {
|
||||
orgId: 'lux',
|
||||
orgName: 'Lux Network',
|
||||
logo: '/logos/lux.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7', // Zinc-200 (clean white)
|
||||
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Start deploying in seconds',
|
||||
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem',
|
||||
tagline: 'Lux-powered infrastructure',
|
||||
quotes: [
|
||||
{
|
||||
text: "Lux is fast. We deploy chains in minutes, not weeks.",
|
||||
author: 'Validator',
|
||||
role: 'Node Operator',
|
||||
}
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
terms: 'https://lux.network/terms',
|
||||
privacy: 'https://lux.network/privacy',
|
||||
support: 'https://lux.network/support',
|
||||
docs: 'https://docs.lux.network',
|
||||
home: 'https://lux.network',
|
||||
},
|
||||
},
|
||||
'id.lux.network': {
|
||||
orgId: 'lux',
|
||||
orgName: 'Lux Network',
|
||||
logo: '/logos/lux.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7', // Zinc-200 (clean white)
|
||||
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Start deploying in seconds',
|
||||
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem',
|
||||
tagline: 'Lux-powered infrastructure',
|
||||
quotes: [
|
||||
{
|
||||
text: "Lux is fast. We deploy chains in minutes, not weeks.",
|
||||
author: 'Validator',
|
||||
role: 'Node Operator',
|
||||
}
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
terms: 'https://lux.network/terms',
|
||||
privacy: 'https://lux.network/privacy',
|
||||
support: 'https://lux.network/support',
|
||||
docs: 'https://docs.lux.network',
|
||||
home: 'https://lux.network',
|
||||
},
|
||||
},
|
||||
'id.lux-dev.network': {
|
||||
orgId: 'lux',
|
||||
orgName: 'Lux Network (Devnet)',
|
||||
logo: '/logos/lux.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Lux Devnet',
|
||||
subtitle: 'Development network — unstable, reset frequently',
|
||||
tagline: 'Lux devnet infrastructure',
|
||||
quotes: [
|
||||
{
|
||||
text: "Break things fast. Devnet resets nightly.",
|
||||
author: 'Engineer',
|
||||
role: 'Infra',
|
||||
}
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
terms: 'https://lux.network/terms',
|
||||
privacy: 'https://lux.network/privacy',
|
||||
support: 'https://lux.network/support',
|
||||
docs: 'https://docs.lux.network',
|
||||
home: 'https://lux-dev.network',
|
||||
},
|
||||
},
|
||||
'id.lux-test.network': {
|
||||
orgId: 'lux',
|
||||
orgName: 'Lux Network (Testnet)',
|
||||
logo: '/logos/lux.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Lux Testnet',
|
||||
subtitle: 'Test the full stack before mainnet deploy',
|
||||
tagline: 'Lux testnet infrastructure',
|
||||
quotes: [
|
||||
{
|
||||
text: "Validator stability tested here for 48h before mainnet promotion.",
|
||||
author: 'Validator',
|
||||
role: 'Operator',
|
||||
}
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
terms: 'https://lux.network/terms',
|
||||
privacy: 'https://lux.network/privacy',
|
||||
support: 'https://lux.network/support',
|
||||
docs: 'https://docs.lux.network',
|
||||
home: 'https://lux-test.network',
|
||||
},
|
||||
},
|
||||
'id.dev.hanzo.ai': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Dev)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Hanzo Dev',
|
||||
subtitle: 'Development environment',
|
||||
tagline: 'AI-powered development',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
},
|
||||
'id.test.hanzo.ai': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Test)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Hanzo Test',
|
||||
subtitle: 'Test environment',
|
||||
tagline: 'AI-powered development',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
}, 'id.zoo.network': {
|
||||
orgId: 'zoo',
|
||||
orgName: 'Zoo Labs',
|
||||
logo: '/logos/zoo.svg',
|
||||
colors: {
|
||||
primary: '#22c55e', // Green
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Build the future of DeAI',
|
||||
subtitle: 'Open AI research + decentralized science for everyone',
|
||||
tagline: 'Open AI research network',
|
||||
quotes: [
|
||||
{
|
||||
text: "Zoo is where bleeding-edge DeAI experiments actually ship.",
|
||||
author: 'Researcher',
|
||||
role: 'ML Engineer',
|
||||
}
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
terms: 'https://zoo.ngo/terms',
|
||||
privacy: 'https://zoo.ngo/privacy',
|
||||
support: 'https://zoo.ngo/support',
|
||||
docs: 'https://zoo.ngo/docs',
|
||||
home: 'https://zoo.ngo',
|
||||
},
|
||||
},
|
||||
'id.hanzo.network': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Network)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Hanzo Network',
|
||||
subtitle: 'Hanzo network identity',
|
||||
tagline: 'AI-powered infrastructure',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
home: 'https://hanzo.network',
|
||||
},
|
||||
},
|
||||
'id.hanzo-dev.network': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Devnet)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Hanzo Devnet',
|
||||
subtitle: 'Hanzo development network — resets nightly',
|
||||
tagline: 'Development environment',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
home: 'https://hanzo-dev.network',
|
||||
},
|
||||
},
|
||||
'id.hanzo-test.network': {
|
||||
orgId: 'hanzo',
|
||||
orgName: 'Hanzo (Testnet)',
|
||||
logo: '/logos/hanzo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Hanzo Testnet',
|
||||
subtitle: 'Hanzo test network — staging before mainnet',
|
||||
tagline: 'Test environment',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
home: 'https://hanzo-test.network',
|
||||
},
|
||||
},
|
||||
'id.zoo-dev.network': {
|
||||
orgId: 'zoo',
|
||||
orgName: 'Zoo Labs (Devnet)',
|
||||
logo: '/logos/zoo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Zoo Devnet',
|
||||
subtitle: 'Zoo development network',
|
||||
tagline: 'Zoo devnet',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
home: 'https://zoo-dev.network',
|
||||
},
|
||||
},
|
||||
'id.zoo-test.network': {
|
||||
orgId: 'zoo',
|
||||
orgName: 'Zoo Labs (Testnet)',
|
||||
logo: '/logos/zoo.svg',
|
||||
colors: {
|
||||
primary: '#e4e4e7',
|
||||
primaryText: '#09090b',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Zoo Testnet',
|
||||
subtitle: 'Zoo test network',
|
||||
tagline: 'Zoo testnet',
|
||||
},
|
||||
auth: {
|
||||
passwordEnabled: true,
|
||||
codeEnabled: true,
|
||||
webauthnEnabled: true,
|
||||
faceIdEnabled: true,
|
||||
socialProviders: ['metamask', 'google', 'github'],
|
||||
},
|
||||
links: {
|
||||
home: 'https://zoo-test.network',
|
||||
},
|
||||
},
|
||||
'zen.id': {
|
||||
orgId: 'zen',
|
||||
orgName: 'Zen LM',
|
||||
logo: '/logos/zen.svg',
|
||||
colors: {
|
||||
primary: '#a855f7', // Purple (Zen violet)
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Zen',
|
||||
subtitle: 'Frontier AI models for everyone',
|
||||
},
|
||||
links: {
|
||||
terms: 'https://zenlm.org/terms',
|
||||
privacy: 'https://zenlm.org/privacy',
|
||||
support: 'https://zenlm.org/support',
|
||||
docs: 'https://zenlm.org/docs',
|
||||
home: 'https://zenlm.org',
|
||||
},
|
||||
},
|
||||
'id.ad.nexus': {
|
||||
orgId: 'adnexus',
|
||||
orgName: 'Ad Nexus',
|
||||
logo: '/logos/adnexus.svg',
|
||||
logoAlt: 'Ad Nexus',
|
||||
colors: {
|
||||
primary: '#8b5cf6', // Purple
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Ad Nexus',
|
||||
subtitle: 'Programmatic advertising platform',
|
||||
},
|
||||
links: {
|
||||
terms: 'https://ad.nexus/terms',
|
||||
privacy: 'https://ad.nexus/privacy',
|
||||
support: 'https://ad.nexus/support',
|
||||
home: 'https://ad.nexus',
|
||||
},
|
||||
},
|
||||
'ad.nexus': {
|
||||
orgId: 'adnexus',
|
||||
orgName: 'Ad Nexus',
|
||||
logo: '/logos/adnexus.svg',
|
||||
logoAlt: 'Ad Nexus',
|
||||
colors: {
|
||||
primary: '#8b5cf6', // Purple
|
||||
primaryText: '#ffffff',
|
||||
background: '#000000',
|
||||
surface: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textMuted: '#a1a1aa',
|
||||
border: '#27272a',
|
||||
error: '#dc2626',
|
||||
},
|
||||
content: {
|
||||
title: 'Welcome to Ad Nexus',
|
||||
subtitle: 'Programmatic advertising platform',
|
||||
},
|
||||
links: {
|
||||
terms: 'https://ad.nexus/terms',
|
||||
privacy: 'https://ad.nexus/privacy',
|
||||
support: 'https://ad.nexus/support',
|
||||
home: 'https://ad.nexus',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Resolve domain to branding key
|
||||
// Handles: exact match, id.{domain} → {domain}, {sub}.{domain} patterns
|
||||
export function resolveBrandingDomain(host: string): string {
|
||||
const domain = host.split(':')[0]
|
||||
|
||||
// Exact match first
|
||||
if (staticBranding[domain]) return domain
|
||||
|
||||
// Try stripping 'id.' prefix: id.ad.nexus → ad.nexus
|
||||
if (domain.startsWith('id.')) {
|
||||
const stripped = domain.slice(3)
|
||||
if (staticBranding[stripped]) return stripped
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Client ID → application/organization map.
|
||||
*
|
||||
* Used for resolving the correct IAM application from a client_id,
|
||||
* e.g. during social login callbacks where we need to know which
|
||||
* app and org the login belongs to.
|
||||
*/
|
||||
|
||||
export interface ClientInfo {
|
||||
application: string
|
||||
organization: string
|
||||
}
|
||||
|
||||
export const CLIENT_APP_MAP: Record<string, ClientInfo> = {
|
||||
// Hanzo org
|
||||
'hanzo-platform-client-id': { application: 'app-platform', organization: 'hanzo' },
|
||||
'hanzo-app-client-id': { application: 'hanzo-id', organization: 'hanzo' },
|
||||
'hanzo-id': { application: 'hanzo-id', organization: 'hanzo' },
|
||||
'hanzo-console-client-id': { application: 'app-console', organization: 'hanzo' },
|
||||
'hanzo-cloud-client-id': { application: 'app-cloud', organization: 'hanzo' },
|
||||
'kms-client': { application: 'app-kms', organization: 'hanzo' },
|
||||
'hanzo-kms-client-id': { application: 'app-kms', organization: 'hanzo' },
|
||||
'hanzo-commerce-client-id': { application: 'app-commerce', organization: 'hanzo' },
|
||||
'hanzo-team-client-id': { application: 'app-team', organization: 'hanzo' },
|
||||
'hanzobot-client-id': { application: 'app-hanzobot', organization: 'hanzo' },
|
||||
'chat-app': { application: 'app-chat', organization: 'hanzo' },
|
||||
'hanzo-chat-client-id': { application: 'app-hanzo-chat', organization: 'hanzo' },
|
||||
'hanzo-web3': { application: 'app-hanzo-web3', organization: 'hanzo' },
|
||||
'app-analytics': { application: 'app-analytics', organization: 'hanzo' },
|
||||
'app-insights': { application: 'app-insights', organization: 'hanzo' },
|
||||
'bootnode-web': { application: 'app-bootnode', organization: 'hanzo' },
|
||||
'zt-console': { application: 'app-zt-console', organization: 'hanzo' },
|
||||
'hanzo-storage-client-id': { application: 'app-storage', organization: 'hanzo' },
|
||||
'hanzo-auto-client-id': { application: 'app-auto', organization: 'hanzo' },
|
||||
'hanzo-flow-client-id': { application: 'app-flow', organization: 'hanzo' },
|
||||
// Adnexus org
|
||||
'adnexus-app-client-id': { application: 'app-adnexus', organization: 'adnexus' },
|
||||
// Lux org
|
||||
'lux-app-client-id': { application: 'app-lux', organization: 'lux' },
|
||||
'lux-chat-client-id': { application: 'app-lux-chat', organization: 'lux' },
|
||||
'lux-kms-client': { application: 'app-lux-kms', organization: 'lux' },
|
||||
'lux-web3': { application: 'app-lux-web3', organization: 'lux' },
|
||||
'lux-mpc': { application: 'app-lux-mpc', organization: 'lux' },
|
||||
// Zoo org
|
||||
'zoo-app-client-id': { application: 'app-zoo', organization: 'zoo' },
|
||||
'zoo-web3': { application: 'app-zoo-web3', organization: 'zoo' },
|
||||
'zoo-mpc': { application: 'app-zoo-mpc', organization: 'zoo' },
|
||||
// Pars org
|
||||
'pars-app-client-id': { application: 'app-pars', organization: 'pars' },
|
||||
'pars-mpc': { application: 'app-pars-mpc', organization: 'pars' },
|
||||
// Zen org
|
||||
'zen-app-client-id': { application: 'app-zen', organization: 'zen' },
|
||||
}
|
||||
|
||||
export function resolveClient(clientId: string): ClientInfo | undefined {
|
||||
return CLIENT_APP_MAP[clientId]
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* IAM backend URL resolution.
|
||||
*
|
||||
* Maps the login portal domain to the correct IAM backend.
|
||||
* Configurable via env vars for self-hosted deployments.
|
||||
*/
|
||||
|
||||
// Default domain → IAM URL mapping
|
||||
const IAM_URLS: Record<string, string> = {
|
||||
'hanzo.id': 'https://iam.hanzo.ai',
|
||||
'id.hanzo.ai': 'https://iam.hanzo.ai',
|
||||
'lux.id': 'https://iam.lux.network',
|
||||
'id.lux.network': 'https://iam.lux.network',
|
||||
'zoo.id': 'https://iam.zoo.network',
|
||||
'id.zoo.network': 'https://iam.zoo.network',
|
||||
'pars.id': 'https://iam.pars.network',
|
||||
'id.pars.network': 'https://iam.pars.network',
|
||||
'zen.id': 'https://iam.hanzo.ai',
|
||||
'id.ad.nexus': 'https://iam.hanzo.ai',
|
||||
}
|
||||
|
||||
// Default domain → org mapping
|
||||
const ORG_MAP: Record<string, string> = {
|
||||
'hanzo.id': 'hanzo',
|
||||
'id.hanzo.ai': 'hanzo',
|
||||
'lux.id': 'lux',
|
||||
'id.lux.network': 'lux',
|
||||
'zoo.id': 'zoo',
|
||||
'id.zoo.network': 'zoo',
|
||||
'pars.id': 'pars',
|
||||
'id.pars.network': 'pars',
|
||||
'zen.id': 'zen',
|
||||
'id.ad.nexus': 'adnexus',
|
||||
}
|
||||
|
||||
// Default domain → default app clientId
|
||||
const APP_MAP: Record<string, string> = {
|
||||
'hanzo.id': 'hanzo-id',
|
||||
'id.hanzo.ai': 'hanzo-id',
|
||||
'lux.id': 'app-lux',
|
||||
'id.lux.network': 'app-lux',
|
||||
'zoo.id': 'app-zoo',
|
||||
'id.zoo.network': 'app-zoo',
|
||||
'pars.id': 'app-pars',
|
||||
'id.pars.network': 'app-pars',
|
||||
'zen.id': 'app-zen',
|
||||
'id.ad.nexus': 'app-adnexus',
|
||||
}
|
||||
|
||||
export function getIamUrl(host: string): string {
|
||||
const domain = host.split(':')[0]
|
||||
|
||||
// 1. Check env override (for self-hosted / K8s)
|
||||
if (typeof process !== 'undefined') {
|
||||
const envUrl = process.env.NEXT_PUBLIC_IAM_URL || process.env.HANZO_IAM_URL
|
||||
if (envUrl) return envUrl
|
||||
}
|
||||
|
||||
// 2. Static map
|
||||
return IAM_URLS[domain] ?? 'https://iam.hanzo.ai'
|
||||
}
|
||||
|
||||
export function getOrg(host: string): string {
|
||||
const domain = host.split(':')[0]
|
||||
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_ORG) {
|
||||
return process.env.NEXT_PUBLIC_ORG
|
||||
}
|
||||
return ORG_MAP[domain] ?? 'hanzo'
|
||||
}
|
||||
|
||||
export function getDefaultClientId(host: string): string {
|
||||
const domain = host.split(':')[0]
|
||||
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_CLIENT_ID) {
|
||||
return process.env.NEXT_PUBLIC_CLIENT_ID
|
||||
}
|
||||
return APP_MAP[domain] ?? 'hanzo-id'
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* 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('/api/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()
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
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.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',
|
||||
},
|
||||
'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 ---
|
||||
|
||||
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': '/api/login/oauth/access_token',
|
||||
// RFC 7662 — Token Introspection
|
||||
'/oauth/introspect': '/api/login/oauth/introspect',
|
||||
// RFC 7009 — Token Revocation
|
||||
'/oauth/revoke': '/api/login/oauth/revoke',
|
||||
// OIDC Core — UserInfo
|
||||
'/oauth/userinfo': '/api/userinfo',
|
||||
// OIDC — Logout
|
||||
'/oauth/logout': '/login/oauth/logout',
|
||||
// RFC 8628 — Device Authorization
|
||||
'/oauth/device': '/api/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)
|
||||
const IAM_PATH_PREFIXES = [
|
||||
'/api/',
|
||||
'/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}/api/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
|
||||
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 legacy IAM backend paths to RFC standard paths
|
||||
body = body.replaceAll('/login/oauth/authorize', '/oauth/authorize')
|
||||
body = body.replaceAll('/api/login/oauth/access_token', '/oauth/token')
|
||||
body = body.replaceAll('/api/login/oauth/refresh_token', '/oauth/token')
|
||||
body = body.replaceAll('/api/login/oauth/introspect', '/oauth/introspect')
|
||||
body = body.replaceAll('/api/login/oauth/revoke', '/oauth/revoke')
|
||||
body = body.replaceAll('/login/oauth/logout', '/oauth/logout')
|
||||
body = body.replaceAll('/api/login/oauth/device', '/oauth/device')
|
||||
body = body.replaceAll('/api/userinfo', '/oauth/userinfo')
|
||||
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: [
|
||||
'/api/:path*',
|
||||
'/oauth/:path*',
|
||||
'/login/oauth/:path*',
|
||||
'/.well-known/:path*',
|
||||
'/callback',
|
||||
'/cas/:path*',
|
||||
'/scim/:path*',
|
||||
],
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -1,30 +1,15 @@
|
||||
{
|
||||
"name": "@hanzo/id",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC",
|
||||
"version": "0.2.7",
|
||||
"description": "Hanzo ID \u2014 white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"pages:build": "npx @cloudflare/next-on-pages 2>&1 || true && node scripts/patch-not-found.mjs && npx @cloudflare/next-on-pages --skip-build",
|
||||
"deploy": "pnpm pages:build && wrangler pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true",
|
||||
"deploy:docker": "docker build -t hanzo-id . && docker push ghcr.io/hanzoai/id:latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0"
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm --filter @hanzo/id-web dev",
|
||||
"tc": "pnpm -r tc",
|
||||
"clean": "bash scripts/clean.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/next-on-pages": "^1.13.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"wrangler": "^3.0.0"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@hanzo/id-auth",
|
||||
"version": "0.1.3",
|
||||
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./forms": "./src/ui/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-connect": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"@paulmillr/qr": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
"react-dom": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* MFA wiring tests — pure, no network (fetch is mocked via `fetchImpl`).
|
||||
* Run with: pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Locks the wire contract verified live against iam.hanzo.ai:
|
||||
* - login answers a forced-MFA org with `data:"RequiredMfa"` (enroll) or
|
||||
* `data:"NextMfa"` + `data2` (challenge) — STRINGS, never a boolean.
|
||||
* - the `/v1/iam/mfa/setup/*` calls carry EVERY param on the query string with
|
||||
* an EMPTY body (the one shape IAM's authz self-match + controller accept).
|
||||
* - the challenge re-POSTs `/v1/iam/login` with `{mfaType,passcode}` and NO
|
||||
* username, riding the MFA session cookie.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import { createAuthClient, mfaChannelOf, MFA_TOTP } from './client.ts'
|
||||
|
||||
const TENANT: TenantConfig = {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-id',
|
||||
appName: 'hanzo-id',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
}
|
||||
|
||||
type Call = { url: string; init: RequestInit }
|
||||
|
||||
function mockFetch(body: unknown, calls: Call[]): typeof fetch {
|
||||
return (async (input: string | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(input), init: init ?? {} })
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
}) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
test('login → RequiredMfa maps to an enroll signal (not a redirect)', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'RequiredMfa' }, calls) })
|
||||
const res = await client.login({
|
||||
identifier: 'davelorenzini@gmail.com',
|
||||
password: 'x',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(res.mfaRequired, true)
|
||||
assert.equal(res.mfaStage, 'enroll')
|
||||
assert.equal(res.redirectUrl, undefined, 'must NOT short-circuit to /onboarding')
|
||||
})
|
||||
|
||||
test('login → NextMfa maps to a challenge signal and carries the allowed types', async () => {
|
||||
const calls: Call[] = []
|
||||
const body = {
|
||||
status: 'ok',
|
||||
data: 'NextMfa',
|
||||
data2: [{ mfaType: 'app', enabled: true }, { mfaType: 'sms', enabled: true }],
|
||||
}
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch(body, calls) })
|
||||
const res = await client.login({
|
||||
identifier: 'davelorenzini@gmail.com',
|
||||
password: 'x',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(res.mfaStage, 'challenge')
|
||||
assert.deepEqual(res.mfaTypes, ['app', 'sms'])
|
||||
})
|
||||
|
||||
test('mfaInitiate puts owner/name/mfaType on the query string with an empty body', async () => {
|
||||
const calls: Call[] = []
|
||||
const data = { secret: 'BOUYRUSHJCEDDB33', url: 'otpauth://totp/Hanzo:x?secret=BOUYRUSHJCEDDB33', recoveryCodes: ['rc-1'] }
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data }, calls) })
|
||||
const setup = await client.mfaInitiate({ owner: 'hanzo', name: 'davelorenzini@gmail.com' })
|
||||
|
||||
assert.equal(setup.secret, 'BOUYRUSHJCEDDB33')
|
||||
assert.equal(setup.mfaType, MFA_TOTP)
|
||||
assert.deepEqual(setup.recoveryCodes, ['rc-1'])
|
||||
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/initiate')
|
||||
assert.equal(u.searchParams.get('owner'), 'hanzo')
|
||||
assert.equal(u.searchParams.get('name'), 'davelorenzini@gmail.com')
|
||||
assert.equal(u.searchParams.get('mfaType'), 'app')
|
||||
assert.equal(calls[0].init.method, 'POST')
|
||||
assert.equal(calls[0].init.body, undefined, 'body must be empty for authz self-match')
|
||||
assert.equal(calls[0].init.credentials, 'include')
|
||||
})
|
||||
|
||||
test('mfaVerify carries owner/name (for authz) + secret + passcode on the query', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
|
||||
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '123456' })
|
||||
assert.equal(r.ok, true)
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/verify')
|
||||
assert.equal(u.searchParams.get('owner'), 'hanzo')
|
||||
assert.equal(u.searchParams.get('secret'), 'SEC')
|
||||
assert.equal(u.searchParams.get('passcode'), '123456')
|
||||
assert.equal(u.searchParams.get('mfaType'), 'app')
|
||||
})
|
||||
|
||||
test('mfaVerify surfaces an IAM error instead of throwing', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'error', msg: 'wrong passcode' }, calls) })
|
||||
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '000000' })
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.error, 'wrong passcode')
|
||||
})
|
||||
|
||||
test('mfaEnable echoes the recovery code back on the query', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
|
||||
const r = await client.mfaEnable({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', recoveryCode: 'rc-1' })
|
||||
assert.equal(r.ok, true)
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/enable')
|
||||
assert.equal(u.searchParams.get('recoveryCodes'), 'rc-1')
|
||||
assert.equal(u.searchParams.get('secret'), 'SEC')
|
||||
})
|
||||
|
||||
test('mfaChallenge re-POSTs /v1/iam/login with mfaType/passcode and NO username', async () => {
|
||||
const calls: Call[] = []
|
||||
// code flow: data is the freshly minted auth code
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'AUTHCODE' }, calls) })
|
||||
const res = await client.mfaChallenge({
|
||||
mfaType: 'app',
|
||||
passcode: '654321',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
redirectUri: 'https://app.example/cb',
|
||||
state: 'st',
|
||||
})
|
||||
const sent = JSON.parse(String(calls[0].init.body)) as Record<string, unknown>
|
||||
assert.equal(new URL(calls[0].url).pathname, '/v1/iam/login')
|
||||
assert.equal(sent.mfaType, 'app')
|
||||
assert.equal(sent.passcode, '654321')
|
||||
assert.equal(sent.username, undefined, 'challenge must not send a username')
|
||||
assert.equal(calls[0].init.credentials, 'include')
|
||||
assert.equal(res.redirectUrl, 'https://app.example/cb?code=AUTHCODE&state=st')
|
||||
})
|
||||
|
||||
test('mfaChannelOf maps IAM types to UI channels', () => {
|
||||
assert.equal(mfaChannelOf('app'), 'totp')
|
||||
assert.equal(mfaChannelOf('sms'), 'sms')
|
||||
assert.equal(mfaChannelOf('email'), 'email')
|
||||
assert.equal(mfaChannelOf('anything-else'), 'totp')
|
||||
})
|
||||
@@ -0,0 +1,426 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
|
||||
// A capturing fetch double: records the URL + parsed JSON body of the last call
|
||||
// and returns a canned IAM "ok" response. No network.
|
||||
function capturingFetch() {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, body })
|
||||
return new Response(JSON.stringify({ status: 'ok', data: 'AUTHCODE' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
// A routing fetch double for the silent-SSO org gate: silentLogin resolves the
|
||||
// app's org (`/v1/iam/get-app-login`) and the ambient session's owner
|
||||
// (`/v1/iam/get-account`) BEFORE minting a code (`/v1/iam/login`). This lets a
|
||||
// test set the app org + session owner independently and assert whether the mint
|
||||
// leg ran. `sessionOwner: null` models "no live session" (get-account errors).
|
||||
function routingFetch(opts: { appOrg: string; sessionOwner: string | null; code?: string }) {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = []
|
||||
const json = (payload: unknown) =>
|
||||
new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, body })
|
||||
if (url.includes('/get-app-login')) {
|
||||
return json({ status: 'ok', data: { name: 'app', organization: opts.appOrg, providers: [] } })
|
||||
}
|
||||
if (url.includes('/get-account')) {
|
||||
return opts.sessionOwner
|
||||
? json({ status: 'ok', data: { owner: opts.sessionOwner, name: 'z' } })
|
||||
: json({ status: 'error', msg: 'please sign in first' })
|
||||
}
|
||||
return json({ status: 'ok', data: opts.code ?? 'AUTHCODE' })
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
|
||||
return {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-console',
|
||||
appName: 'hanzo-console',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
oauthCallbackOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// THE FIX: with loginOrg unset, the portal must NOT pin the brand org — it omits
|
||||
// `organization` so IAM resolves the user cross-org (a global admin → the admin
|
||||
// org / full session; a brand user → their own org). Pinning `hanzo` here is the
|
||||
// live bug that truncates a global admin to one org.
|
||||
test('login OMITS organization when loginOrg is unset (org-agnostic resolution)', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
// organization intentionally not provided (LoginForm passes tenant.loginOrg)
|
||||
})
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(
|
||||
'organization' in calls[0]!.body,
|
||||
false,
|
||||
'organization must be absent from the body so IAM runs cross-org resolution',
|
||||
)
|
||||
// The identity + app still ride the request.
|
||||
assert.equal(calls[0]!.body.username, 'z@hanzo.ai')
|
||||
assert.equal(calls[0]!.body.application, 'hanzo-console')
|
||||
})
|
||||
|
||||
// An empty-string org is treated the same as unset (defensive: a catalog might
|
||||
// emit "").
|
||||
test('login OMITS organization when it is an empty string', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: '',
|
||||
})
|
||||
assert.equal('organization' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// A brand that DELIBERATELY scopes its portal to one org can still force it.
|
||||
test('login INCLUDES organization when one is explicitly provided', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'someone',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
})
|
||||
|
||||
// Per-app SSO: the downstream app's client_id + redirect_uri still flow through;
|
||||
// `type` flips to `code` and the org is STILL omitted (resolution stays correct
|
||||
// for the SSO path too).
|
||||
test('app SSO (redirectUri present) uses type=code and still omits organization', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
state: 'xyz',
|
||||
})
|
||||
assert.equal(calls[0]!.body.type, 'code')
|
||||
assert.match(calls[0]!.url, /type=code/)
|
||||
assert.equal('organization' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// Signup MUST still carry a concrete org — you cannot create a user in "no org".
|
||||
test('signup STILL sends organization (unchanged — create needs a concrete org)', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
})
|
||||
|
||||
// REGRESSION (the `hanzo-iam does not exist` social-login bug): an IAM
|
||||
// app-provider LINK can carry an outer `name` that is NOT the provider record's
|
||||
// name (some seeds label it `<org>-iam`). The provider's real identity is the
|
||||
// nested `provider.name` the backend resolves on the social hop. getAppLogin
|
||||
// MUST surface the inner record name (`provider-github`), never the outer label,
|
||||
// or SocialButtons posts `provider=<org>-iam` and the backend 400s.
|
||||
function appLoginFetch(payload: unknown): typeof fetch {
|
||||
return async () =>
|
||||
new Response(JSON.stringify({ status: 'ok', data: payload }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
test('getAppLogin uses the nested provider record name, not the outer link label', async () => {
|
||||
const fetchImpl = appLoginFetch({
|
||||
name: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
providers: [
|
||||
{
|
||||
// Outer link label — a real-world seed set this to the per-app default.
|
||||
name: 'hanzo-iam',
|
||||
canSignIn: true,
|
||||
canSignUp: true,
|
||||
// Nested provider RECORD — the true identity + creds.
|
||||
provider: { name: 'provider-github', type: 'GitHub', clientId: 'Iv23li_real', scopes: '' },
|
||||
},
|
||||
],
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const app = await client.getAppLogin('hanzo-console')
|
||||
assert.ok(app, 'app login resolved')
|
||||
assert.equal(app!.providers.length, 1)
|
||||
const gh = app!.providers[0]!
|
||||
assert.equal(gh.name, 'provider-github', 'provider name comes from the nested record')
|
||||
assert.equal(gh.key, 'github', 'key strips the provider- prefix')
|
||||
assert.equal(gh.type, 'GitHub')
|
||||
assert.equal(gh.configured, true, 'a real (non-placeholder) clientId is configured')
|
||||
})
|
||||
|
||||
// The social code exchange must reuse the provider's REGISTERED callback host
|
||||
// (oauthCallbackOrigin), not the brand host (publicOrigin). IAM forwards this
|
||||
// redirect_uri verbatim to the provider's token endpoint, which requires it to
|
||||
// match the authorize hop or the exchange fails `invalid_grant`. When a brand
|
||||
// portal (hanzo.id) shares the iam.hanzo.ai OAuth client these two differ.
|
||||
test('providerLogin posts redirectUri from oauthCallbackOrigin (matches the hop), with the single provider + code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({
|
||||
tenant: tenant({ publicOrigin: 'https://hanzo.id', oauthCallbackOrigin: 'https://iam.hanzo.ai' }),
|
||||
fetchImpl,
|
||||
})
|
||||
const r = await client.providerLogin({
|
||||
application: 'hanzo-console',
|
||||
provider: 'provider-google',
|
||||
code: 'goog_code_xyz',
|
||||
oidcQuery:
|
||||
'?client_id=hanzo-console&redirect_uri=https%3A%2F%2Fconsole.hanzo.ai%2Fauth%2Fiam%2Fcallback&response_type=code&scope=openid&state=rp1',
|
||||
method: 'signin',
|
||||
})
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(
|
||||
calls[0]!.body.redirectUri,
|
||||
'https://iam.hanzo.ai/callback',
|
||||
'redirect_uri derives from oauthCallbackOrigin (the hop), never publicOrigin',
|
||||
)
|
||||
assert.equal(calls[0]!.body.provider, 'provider-google')
|
||||
assert.equal(calls[0]!.body.code, 'goog_code_xyz')
|
||||
// The upstream OIDC params ride the query so IAM continues the original authorize.
|
||||
assert.match(calls[0]!.url, /client_id=hanzo-console/)
|
||||
assert.match(calls[0]!.url, /state=rp1/)
|
||||
// parseLoginResponse builds the app redirect from the minted code + the
|
||||
// original OIDC redirect_uri/state (the same shape as login/silentLogin).
|
||||
assert.equal(r.redirectUrl, 'https://console.hanzo.ai/auth/iam/callback?code=AUTHCODE&state=rp1')
|
||||
})
|
||||
|
||||
// When there is NO nested record (degenerate seed), fall back to the outer label
|
||||
// so the provider is still surfaced rather than dropped.
|
||||
test('getAppLogin falls back to the outer name when no nested provider record', async () => {
|
||||
const fetchImpl = appLoginFetch({
|
||||
name: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
providers: [{ name: 'provider-google', canSignIn: true, canSignUp: true, provider: null }],
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const app = await client.getAppLogin('hanzo-console')
|
||||
assert.ok(app)
|
||||
assert.equal(app!.providers[0]!.name, 'provider-google')
|
||||
})
|
||||
|
||||
// TRUE SSO — the silent leg. silentLogin carries NO credentials: IAM mints the
|
||||
// code from the existing issuer session (cookie sent via credentials:include).
|
||||
// It builds the redirect back to the app from the minted code + state. The mint
|
||||
// runs ONLY when the ambient session's org matches the app's org (same-org SSO,
|
||||
// the common case: a hanzo session signing into a hanzo app).
|
||||
test('silentLogin (same-org session) mints the code and redirects, carrying NO credentials', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: 'hanzo' })
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
state: 'st1',
|
||||
codeChallenge: 'chal',
|
||||
})
|
||||
|
||||
// The mint leg (POST /v1/iam/login, type=code) ran after the org gate passed.
|
||||
const mint = calls.find((c) => c.body.type === 'code')
|
||||
assert.ok(mint, 'mint leg ran for a same-org session')
|
||||
// No credentials of any kind — this is session-only.
|
||||
assert.equal('username' in mint!.body, false, 'no username in silent login')
|
||||
assert.equal('password' in mint!.body, false, 'no password in silent login')
|
||||
assert.equal('provider' in mint!.body, false, 'no provider hop in silent login')
|
||||
assert.equal(mint!.body.application, 'hanzo-console')
|
||||
// OAuth params ride the query so IAM mints a code for the right client + PKCE.
|
||||
assert.match(mint!.url, /clientId=hanzo-console/)
|
||||
assert.match(mint!.url, /code_challenge=chal/)
|
||||
// The mint returns data:'AUTHCODE' -> a fully-formed app redirect.
|
||||
assert.equal(
|
||||
r.redirectUrl,
|
||||
'https://console.hanzo.ai/auth/iam/callback?code=AUTHCODE&state=st1',
|
||||
)
|
||||
})
|
||||
|
||||
// THE ADMIN-GUARD FIX: silent SSO must NOT reuse a session that belongs to a
|
||||
// DIFFERENT org than the app being signed into. An operator with an ambient
|
||||
// hanzo/* session hitting the admin-guard (org=admin) must fall through to the
|
||||
// interactive form (which authenticates in the admin org and resolves the
|
||||
// admin/* identity) — NOT silently mint a code from the hanzo session (which
|
||||
// would confer owner=hanzo and shadow the fix). No mint leg runs; no redirect.
|
||||
test('silentLogin (cross-org session) does NOT mint — falls back to the form', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'hanzo' })
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-admin-guard',
|
||||
application: 'hanzo-admin-guard',
|
||||
redirectUri: 'https://admin.hanzo.ai/__guard/callback',
|
||||
state: 'st1',
|
||||
codeChallenge: 'chal',
|
||||
})
|
||||
|
||||
assert.equal(r.redirectUrl, undefined, 'no silent redirect for a cross-org session')
|
||||
assert.equal(calls.some((c) => c.body.type === 'code'), false, 'the mint leg must NOT run')
|
||||
})
|
||||
|
||||
// Same-org SSO still holds when BOTH are the admin org: an operator already
|
||||
// signed in as admin/* silently re-enters the admin console.
|
||||
test('silentLogin (same admin-org session) mints for the admin-guard', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'admin' })
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-admin-guard',
|
||||
application: 'hanzo-admin-guard',
|
||||
redirectUri: 'https://admin.hanzo.ai/__guard/callback',
|
||||
state: 'st1',
|
||||
})
|
||||
assert.ok(calls.find((c) => c.body.type === 'code'), 'mint leg ran for a same-org admin session')
|
||||
assert.equal(r.redirectUrl, 'https://admin.hanzo.ai/__guard/callback?code=AUTHCODE&state=st1')
|
||||
})
|
||||
|
||||
// No live session: silentLogin returns an empty response (no mint) so Login.tsx
|
||||
// falls back to the interactive form (never a dead end).
|
||||
test('silentLogin returns empty (no mint) when there is no session (form fallback)', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: null })
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
})
|
||||
assert.equal(r.redirectUrl, undefined)
|
||||
assert.equal(calls.some((c) => c.body.type === 'code'), false, 'no mint without a session')
|
||||
})
|
||||
|
||||
// ── Device-authorization approval (RFC 8628) ─────────────────────────────────
|
||||
// approveDevice rides the issuer SESSION (like silentLogin): NO credentials in
|
||||
// the body, `type:device` + the userCode IAM keys its DeviceAuthMap on, plus the
|
||||
// tenant application/organization for the app lookup. On {status:ok} the device
|
||||
// code is approved (UserSignIn=true) and the CLI's token poll succeeds.
|
||||
test('approveDevice posts type=device + normalized userCode + tenant app/org, NO credentials', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0]!.body.type, 'device')
|
||||
assert.equal(calls[0]!.body.userCode, 'K7M4P2QH')
|
||||
assert.equal(calls[0]!.body.application, 'hanzo-console')
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
// Session-only: never any credentials in a device approval.
|
||||
assert.equal('username' in calls[0]!.body, false)
|
||||
assert.equal('password' in calls[0]!.body, false)
|
||||
assert.equal('provider' in calls[0]!.body, false)
|
||||
assert.match(calls[0]!.url, /type=device/)
|
||||
assert.equal(r.ok, true)
|
||||
})
|
||||
|
||||
// IAM mints codes from an UPPERCASE unambiguous alphabet ([A-HJ-NP-Z2-9]); a
|
||||
// human may transcribe them lower-cased or with stray spaces/dashes. Normalize
|
||||
// TO uppercase so the lookup matches — case-insensitive entry, exact-match send.
|
||||
test('approveDevice uppercases and strips spaces/dashes before sending', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.approveDevice(' k7m4-p2qh ')
|
||||
assert.equal(calls[0]!.body.userCode, 'K7M4P2QH')
|
||||
})
|
||||
|
||||
// An empty/blank code never hits the network — fail fast with a clear message.
|
||||
test('approveDevice rejects an empty code without calling fetch', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const r = await client.approveDevice(' ')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(r.ok, false)
|
||||
assert.ok(r.error)
|
||||
})
|
||||
|
||||
// The IAM error message (e.g. "UserCode Expired") is surfaced verbatim.
|
||||
test('approveDevice surfaces the IAM error message', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response(JSON.stringify({ status: 'error', msg: 'UserCode Expired' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.error, 'UserCode Expired')
|
||||
})
|
||||
|
||||
// Consent branch: {status:ok, data:{required:true}} → {ok:false, required:true}
|
||||
// so the page can render consent instead of treating it as success or a dead end.
|
||||
test('approveDevice maps the consent-required branch to { required: true }', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response(JSON.stringify({ status: 'ok', data: { required: true } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.required, true)
|
||||
assert.equal(r.error, undefined)
|
||||
})
|
||||
|
||||
// getAppLogin's redirectUri is validated by IAM against the app's REGISTERED
|
||||
// list. A cross-app SSO read (the console's `hanzo-cloud` viewed from hanzo.id)
|
||||
// MUST send the downstream app's OWN redirect_uri — the portal's `/callback` is
|
||||
// not in that app's list, so hardcoding it makes IAM drop the response and no
|
||||
// social buttons resolve. Absent, it defaults to the portal's own callback.
|
||||
test('getAppLogin sends the passed redirect_uri, and defaults to the portal callback when omitted', async () => {
|
||||
const urls: string[] = []
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
urls.push(typeof input === 'string' ? input : input.toString())
|
||||
return new Response(
|
||||
JSON.stringify({ status: 'ok', data: { name: 'hanzo-cloud', organization: 'hanzo', providers: [] } }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
// Cross-app read: the console's registered redirect_uri rides through verbatim.
|
||||
await client.getAppLogin('hanzo-cloud', 'https://console.hanzo.ai/auth/callback')
|
||||
const u1 = new URL(urls[0]!)
|
||||
assert.equal(u1.searchParams.get('clientId'), 'hanzo-cloud')
|
||||
assert.equal(u1.searchParams.get('redirectUri'), 'https://console.hanzo.ai/auth/callback')
|
||||
|
||||
// Bare/own read: no redirect_uri → default to the portal's own /callback.
|
||||
await client.getAppLogin('hanzo-id')
|
||||
const u2 = new URL(urls[1]!)
|
||||
assert.equal(u2.searchParams.get('redirectUri'), 'https://hanzo.id/callback')
|
||||
})
|
||||
@@ -0,0 +1,769 @@
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type {
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
ForgotRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChallengeRequest,
|
||||
MfaChannel,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
OAuthAuthorizeRequest,
|
||||
SignupRequest,
|
||||
SilentLoginRequest,
|
||||
TokenResponse,
|
||||
} from './types'
|
||||
|
||||
/** IAM's TOTP MFA type constant (`object.TotpType`). */
|
||||
export const MFA_TOTP = 'app'
|
||||
|
||||
/** Map an IAM MFA type to the {@link MfaChannel} the OTP UI renders a label for. */
|
||||
export function mfaChannelOf(iamType: string): MfaChannel {
|
||||
return iamType === 'sms' ? 'sms' : iamType === 'email' ? 'email' : 'totp'
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable IAM client.
|
||||
*
|
||||
* Stateless wrapper around the canonical IAM REST surface (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
|
||||
* back to the requesting app via `redirectUri`. The browser never holds them
|
||||
* past the redirect.
|
||||
*/
|
||||
export interface AuthClient {
|
||||
readonly tenant: TenantConfig
|
||||
login(req: LoginRequest): Promise<LoginResponse>
|
||||
/**
|
||||
* Silent single-sign-on: mint an authorization code from the EXISTING issuer
|
||||
* session (the `iam_session_id` cookie set when the user signed in once for
|
||||
* another app) — no credentials, no provider hop. Returns `{ redirectUrl }`
|
||||
* (the app's `redirect_uri` + `?code=&state=`) when a live session exists, or
|
||||
* `{ error }` when it does not so the caller renders the interactive form.
|
||||
* This is the seamless 2nd/3rd-app login leg.
|
||||
*/
|
||||
silentLogin(req: SilentLoginRequest): Promise<LoginResponse>
|
||||
/**
|
||||
* Approve an RFC 8628 device-authorization request from the device-approval
|
||||
* page (`/login/oauth/device`). The user MUST already be signed in to the
|
||||
* issuer — this rides the SAME `iam_session_id` cookie as silent SSO
|
||||
* (`credentials:'include'`, no credentials in the body). It POSTs
|
||||
* `/v1/iam/login` with `type:'device'` + the `userCode` the device shows,
|
||||
* plus the tenant's `application`/`organization`; IAM resolves the user from
|
||||
* the session, flips the device code's `UserSignIn=true`, and the CLI's token
|
||||
* poll then succeeds. Returns `{required:true}` when the app needs consent
|
||||
* first (rare for first-party apps), or `{error}` with the IAM message.
|
||||
*/
|
||||
approveDevice(userCode: string): Promise<DeviceApprovalResult>
|
||||
signup(req: SignupRequest): Promise<LoginResponse>
|
||||
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
|
||||
authorize(req: OAuthAuthorizeRequest): string
|
||||
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
|
||||
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
|
||||
/**
|
||||
* Read the live enabled-auth-methods view for an application from
|
||||
* `/v1/iam/get-app-login` — the canonical source of truth for which
|
||||
* sign-in buttons (password / GitHub / Google / Web3) to render.
|
||||
* Resolves to null when the endpoint is unreachable so callers can fall
|
||||
* back to the tenant's declared default method set.
|
||||
*
|
||||
* `redirectUri` is validated by IAM against the app's registered list. For a
|
||||
* cross-app SSO read (e.g. console → hanzo.id, `clientId=hanzo-cloud`) pass the
|
||||
* DOWNSTREAM app's own OIDC `redirect_uri` — the portal's `/callback` is NOT in
|
||||
* that app's list, so hardcoding it makes IAM answer `status:error`
|
||||
* ("Redirect URI … doesn't exist in the allowed list") and drops the whole
|
||||
* response. Omit it for a bare/own-app read (defaults to the portal callback).
|
||||
*/
|
||||
getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null>
|
||||
/**
|
||||
* Complete a social provider login when the provider redirects back to
|
||||
* `/callback` with a `code` + base64 `state` (see `social.ts`). Exchanges the
|
||||
* provider code at the IAM backend (the IAM `AuthBackend.login` contract)
|
||||
* and resolves the URL to redirect to — the original OIDC `redirect_uri` with
|
||||
* an authorization code, which the portal's normal PKCE callback then
|
||||
* completes. NOTE: pending live verification — runs only once real OAuth
|
||||
* provider creds are seeded (the buttons are hidden until then).
|
||||
*/
|
||||
providerLogin(req: ProviderExchangeRequest): Promise<{ redirectUrl?: string; error?: string }>
|
||||
/**
|
||||
* Resolve the signed-in user's `{owner, name}` from the IAM session
|
||||
* (`/v1/iam/get-account`). After a `RequiredMfa` login the IAM session cookie
|
||||
* already authenticates the user (IAM calls `SetSessionUsername` before
|
||||
* answering `RequiredMfa`), so this is how the portal learns the identity to
|
||||
* key the forced-enrollment calls on. Resolves null when unauthenticated.
|
||||
*/
|
||||
getAccount(): Promise<MfaIdentity | null>
|
||||
/**
|
||||
* Begin TOTP enrollment: `POST /v1/iam/mfa/setup/initiate`. Returns the secret
|
||||
* + `otpauth://` URI + recovery codes. Does NOT persist anything — only
|
||||
* {@link mfaEnable} does.
|
||||
*/
|
||||
mfaInitiate(id: MfaIdentity): Promise<MfaSetup>
|
||||
/** Verify a TOTP code against a pending secret: `POST /v1/iam/mfa/setup/verify`. */
|
||||
mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }>
|
||||
/** Persist a verified TOTP enrollment: `POST /v1/iam/mfa/setup/enable`. */
|
||||
mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }>
|
||||
/**
|
||||
* Answer a `NextMfa` challenge: `POST /v1/iam/login` with `{mfaType, passcode}`
|
||||
* and NO username, riding the MFA session cookie IAM set with `NextMfa`.
|
||||
* Returns the same shape as {@link login} (a redirect with an auth code for the
|
||||
* code flow, or a bare-session signal for portal sign-in).
|
||||
*/
|
||||
mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse>
|
||||
}
|
||||
|
||||
/** Inputs to {@link AuthClient.providerLogin}, recovered from the /callback return. */
|
||||
export interface ProviderExchangeRequest {
|
||||
/** IAM application name (from the decoded state). */
|
||||
readonly application: string
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly provider: string
|
||||
/** The provider's authorization code (the `?code=` on the /callback return). */
|
||||
readonly code: string
|
||||
/** The ORIGINAL OIDC authorize query string (decoded from the base64 state). */
|
||||
readonly oidcQuery: string
|
||||
/** "signin" | "signup". */
|
||||
readonly method: string
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
readonly tenant: TenantConfig
|
||||
/** Override fetch impl (testing). Defaults to global fetch. */
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
const tenant = opts.tenant
|
||||
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('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)
|
||||
// Echo the downstream OIDC nonce so the minted code -> id_token carries it.
|
||||
// Strict openid-client consumers (LibreChat OPENID_REUSE_TOKENS) reject an
|
||||
// id_token whose nonce != the one they sent ("unexpected JWT claim value").
|
||||
if (req.nonce) url.searchParams.set('nonce', req.nonce)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', type)
|
||||
// `organization` is an OPTIONAL lookup hint (see LoginRequest). Omit it when
|
||||
// empty so IAM runs its cross-org resolution: a global-admin identity then
|
||||
// resolves to the `admin` org (full multi-org session) instead of being
|
||||
// pinned to — and truncated by — a colliding brand-org row. The session's
|
||||
// org is always the resolved user's real owner, never this hint.
|
||||
const body: Record<string, unknown> = {
|
||||
type,
|
||||
username: req.identifier,
|
||||
password: req.password,
|
||||
application: req.application,
|
||||
signinMethod: 'Password',
|
||||
autoSignin: true,
|
||||
}
|
||||
if (req.organization) body.organization = req.organization
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
// Resolve the org of the user in the ambient IAM session (the `iam_session_id`
|
||||
// cookie), or null when there is no live session. Reads `/v1/iam/get-account`;
|
||||
// the org is the `owner` field (Casdoor returns the User at the top level or
|
||||
// under `data`). Used to keep silent SSO from reusing a session that belongs
|
||||
// to a DIFFERENT org than the app being signed into.
|
||||
async function sessionOwner(): Promise<string | null> {
|
||||
try {
|
||||
const res = await f(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
if (body.status === 'error') return null
|
||||
const nested = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
const owner = typeof body.owner === 'string' ? body.owner : nested.owner
|
||||
return typeof owner === 'string' && owner ? owner : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function silentLogin(req: SilentLoginRequest): Promise<LoginResponse> {
|
||||
// Silent SSO may reuse the ambient IAM session ONLY when that session's user
|
||||
// belongs to the SAME org as the app being signed into. A cross-org app —
|
||||
// e.g. the admin-guard (client_id=hanzo-admin-guard, org=admin) reached from
|
||||
// a browser that already holds a hanzo/* session — must NOT mint a code from
|
||||
// the wrong-org session: that confers owner=hanzo and silently shadows the
|
||||
// org-scoped credential form (which resolves the admin/* identity). Resolve
|
||||
// the app's org and the session owner; on no session or an org mismatch,
|
||||
// return an empty response so Login.tsx falls back to the interactive form,
|
||||
// which authenticates in the app's own org. Same-org SSO (the common case)
|
||||
// still mints silently, so seamless sign-in is preserved.
|
||||
const [app, owner] = await Promise.all([getAppLogin(req.clientId), sessionOwner()])
|
||||
if (!owner) return {}
|
||||
const appOrg = app?.organization
|
||||
if (appOrg && owner !== appOrg) return {}
|
||||
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', req.scope ?? 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
if (req.nonce) url.searchParams.set('nonce', req.nonce)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', 'code')
|
||||
// NO username/password and NO provider: IAM's Login handler falls through to
|
||||
// its "already signed in to IAM" branch (`GetSessionUsername() != ""`) and
|
||||
// mints an authorization code for `application` from the existing
|
||||
// `iam_session_id` cookie. `credentials: 'include'` sends that cookie. When
|
||||
// there is no live session IAM responds `status:error` -> parseLoginResponse
|
||||
// returns `{ error }`, and Login.tsx renders the interactive form instead.
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ type: 'code', application: req.application, autoSignin: true }),
|
||||
})
|
||||
return parseLoginResponse(res, { redirectUri: req.redirectUri, state: req.state })
|
||||
}
|
||||
|
||||
async function approveDevice(userCode: string): Promise<DeviceApprovalResult> {
|
||||
const code = normalizeUserCode(userCode)
|
||||
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
// IAM's device branch keys the cache off the `userCode` in the BODY; the
|
||||
// `type` echo on the query mirrors the other login legs. NO credentials —
|
||||
// the user is already signed in, so this rides the session cookie
|
||||
// (`credentials:'include'`) and IAM resolves the user from the session.
|
||||
url.searchParams.set('type', 'device')
|
||||
const body: Record<string, unknown> = {
|
||||
type: 'device',
|
||||
userCode: code,
|
||||
application: tenant.appName,
|
||||
}
|
||||
// `organization` scopes the application lookup (FindApplicationByName); it
|
||||
// does NOT resolve the user (that comes from the session), so pinning the
|
||||
// tenant org here is safe — unlike password login, which omits it.
|
||||
if (tenant.orgId) body.organization = tenant.orgId
|
||||
let res: Response
|
||||
try {
|
||||
res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
parsed = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { ok: false, error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || parsed.status === 'error') {
|
||||
return { ok: false, error: typeof parsed.msg === 'string' && parsed.msg ? parsed.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
// Consent branch: {status:ok, data:{required:true}}. First-party apps skip
|
||||
// this; surface it so the caller can render consent rather than dead-ending.
|
||||
const data = parsed.data
|
||||
if (data !== null && typeof data === 'object' && (data as Record<string, unknown>).required === true) {
|
||||
return { ok: false, required: true }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function signup(req: SignupRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/signup', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
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,
|
||||
confirm: req.password,
|
||||
autoSignin: true,
|
||||
...(req.inviteCode ? { invitationCode: req.inviteCode } : {}),
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res)
|
||||
}
|
||||
|
||||
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
|
||||
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('organization', req.organization)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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('/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')
|
||||
url.searchParams.set('scope', req.scope ?? 'openid profile email')
|
||||
url.searchParams.set('state', req.state)
|
||||
if (req.nonce) url.searchParams.set('nonce', req.nonce)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
|
||||
const url = new URL('/v1/iam/oauth/token', tenant.iamUrl)
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: tenant.clientId,
|
||||
redirect_uri: `${tenant.publicOrigin}/callback`,
|
||||
})
|
||||
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`)
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
return {
|
||||
accessToken: String(data.access_token ?? ''),
|
||||
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
|
||||
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
|
||||
tokenType: String(data.token_type ?? 'Bearer'),
|
||||
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined,
|
||||
scope: typeof data.scope === 'string' ? data.scope : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function logout(idTokenHint?: string, postLogoutRedirectUri?: string): string {
|
||||
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',
|
||||
postLogoutRedirectUri ?? `${tenant.publicOrigin}/login`,
|
||||
)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null> {
|
||||
const id = clientId ?? tenant.clientId
|
||||
const url = new URL('/v1/iam/get-app-login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', id)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
// Validate against the downstream app's OWN redirect_uri when the caller has
|
||||
// one (the SSO authorize flow carries it); the portal's own /callback is not
|
||||
// registered for another app, so IAM would reject the read and we'd surface
|
||||
// no social buttons. Fall back to the portal callback for a bare/own read.
|
||||
url.searchParams.set('redirectUri', redirectUri || `${tenant.publicOrigin}/callback`)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
url.searchParams.set('state', 'app-login')
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: { Accept: 'application/json' } })
|
||||
if (!res.ok) return null
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) return null
|
||||
return parseAppLogin(body.data as Record<string, unknown>, tenant.appName, tenant.orgId)
|
||||
}
|
||||
|
||||
async function providerLogin(
|
||||
req: ProviderExchangeRequest,
|
||||
): Promise<{ redirectUrl?: string; error?: string }> {
|
||||
// POST the provider code to the IAM backend together with the app's ORIGINAL
|
||||
// OIDC authorize params (recovered from the round-tripped `state`). IAM
|
||||
// exchanges the provider code, signs the user in, and mints an authorization
|
||||
// code BOUND TO THE APP'S request — the app's client_id and, crucially, its
|
||||
// PKCE `code_challenge` (C1) — which we then hand back to the originating app
|
||||
// so its OWN callback exchanges the code with ITS verifier (V1). The minted
|
||||
// code is bound to C1, so that exchange matches; this is the keystone of the
|
||||
// social-login PKCE round-trip.
|
||||
const oidc = new URLSearchParams(req.oidcQuery.replace(/^\?/, ''))
|
||||
const appRedirectUri = oidc.get('redirect_uri') ?? ''
|
||||
const appState = oidc.get('state') ?? ''
|
||||
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
// OIDC params ride the QUERY — IAM's HandleLoggedIn reads them there first
|
||||
// when minting the code. Forward exactly the app's request so the code
|
||||
// carries its client_id, scope, nonce and — load-bearing — its
|
||||
// `code_challenge`. (`redirect_uri` snake-case is NOT forwarded: IAM reads
|
||||
// the code's redirect binding from camelCase `redirectUri`, set below.)
|
||||
for (const [k, v] of oidc) {
|
||||
if (['client_id', 'response_type', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method'].includes(k)) {
|
||||
url.searchParams.set(k, v)
|
||||
}
|
||||
}
|
||||
// Bind the minted code to the APP's redirect_uri (camelCase `redirectUri` —
|
||||
// the param HandleLoggedIn reads), so the code targets the app, NOT the
|
||||
// provider-callback host carried in the body below.
|
||||
if (appRedirectUri) url.searchParams.set('redirectUri', appRedirectUri)
|
||||
|
||||
// The redirect_uri IAM forwards to the PROVIDER's token endpoint MUST be
|
||||
// byte-identical to the hop's redirect_uri — the provider's REGISTERED
|
||||
// callback host (`oauthCallbackOrigin`, e.g. `iam.hanzo.ai`, shared across
|
||||
// brand portals) — or the provider rejects the exchange `invalid_grant`.
|
||||
// This is a DIFFERENT redirect_uri from the app's above: one drives the
|
||||
// provider exchange (body), one binds the minted app code (query).
|
||||
const callbackOrigin = tenant.oauthCallbackOrigin ?? tenant.publicOrigin
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type: 'code',
|
||||
application: req.application,
|
||||
provider: req.provider,
|
||||
code: req.code,
|
||||
// IAM's social state guard accepts state == application name.
|
||||
state: req.application,
|
||||
redirectUri: `${callbackOrigin}/callback`,
|
||||
// "signup" = find-or-create-LOGIN (see social.ts); never the link branch.
|
||||
method: req.method,
|
||||
}),
|
||||
})
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
// IAM returns the freshly-minted authorization CODE in `data` (codeToResponse
|
||||
// → the bare code string, NOT a URL). Build the redirect back to the app's
|
||||
// own redirect_uri + original state so the app runs the standard OIDC code
|
||||
// exchange. (Treating the bare code AS the redirect URL — the prior bug —
|
||||
// dead-ended the flow on the issuer host and never returned to the app.)
|
||||
const code = typeof body.data === 'string' ? body.data : ''
|
||||
if (!code) return { error: 'provider login returned no authorization code' }
|
||||
if (!appRedirectUri) return { error: 'provider login: missing redirect_uri in social state' }
|
||||
const sep = appRedirectUri.includes('?') ? '&' : '?'
|
||||
return {
|
||||
redirectUrl: `${appRedirectUri}${sep}code=${encodeURIComponent(code)}&state=${encodeURIComponent(appState)}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function getAccount(): Promise<MfaIdentity | null> {
|
||||
const url = new URL('/v1/iam/get-account', tenant.iamUrl)
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: { Accept: 'application/json' }, credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
if (typeof d.owner !== 'string' || typeof d.name !== 'string' || !d.owner || !d.name) return null
|
||||
return { owner: d.owner, name: d.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `/v1/iam/mfa/setup/*` POST URL with EVERY param on the query string
|
||||
* and send an EMPTY body. This is the one wire shape IAM's authz filter and
|
||||
* the MFA controller both accept: the controller reads `owner`/`name`/… from
|
||||
* the merged form (query + body), while the authz filter only extracts the
|
||||
* `{owner,name}` object from the query when the body is empty (a non-empty
|
||||
* body is JSON-unmarshalled, and a urlencoded body fails that parse → empty
|
||||
* object → the self-access match `sub==obj` fails → "Unauthorized operation").
|
||||
* `owner`/`name` ride the query on EVERY call — including `verify`, which
|
||||
* otherwise carries no identity — purely so that self-access check passes.
|
||||
*/
|
||||
async function mfaSetupPost(path: string, params: Record<string, string>): Promise<Record<string, unknown>> {
|
||||
const url = new URL(`/v1/iam/mfa/setup/${path}`, tenant.iamUrl)
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
||||
const res = await f(url.toString(), { method: 'POST', credentials: 'include' })
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (typeof body.status === 'string' && body.status === 'error') {
|
||||
throw new Error(typeof body.msg === 'string' && body.msg ? body.msg : `HTTP ${res.status}`)
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return body
|
||||
}
|
||||
|
||||
async function mfaInitiate(id: MfaIdentity): Promise<MfaSetup> {
|
||||
const body = await mfaSetupPost('initiate', { owner: id.owner, name: id.name, mfaType: MFA_TOTP })
|
||||
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
const secret = typeof d.secret === 'string' ? d.secret : ''
|
||||
const url = typeof d.url === 'string' ? d.url : ''
|
||||
if (!secret || !url) throw new Error('IAM returned no TOTP secret')
|
||||
return {
|
||||
mfaType: MFA_TOTP,
|
||||
secret,
|
||||
url,
|
||||
recoveryCodes: Array.isArray(d.recoveryCodes) ? d.recoveryCodes.filter((c): c is string => typeof c === 'string') : [],
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await mfaSetupPost('verify', { owner: req.owner, name: req.name, mfaType: MFA_TOTP, secret: req.secret, passcode: req.passcode })
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await mfaSetupPost('enable', {
|
||||
owner: req.owner,
|
||||
name: req.name,
|
||||
mfaType: MFA_TOTP,
|
||||
secret: req.secret,
|
||||
recoveryCodes: req.recoveryCode,
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaChallenge(req: MfaChallengeRequest): 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('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)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', type)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
// No username: IAM resolves the user from the MFA session cookie it set
|
||||
// when it answered NextMfa.
|
||||
mfaType: req.mfaType,
|
||||
passcode: req.passcode,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
enableMfaRemember: req.rememberDevice ?? false,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
return {
|
||||
tenant,
|
||||
login,
|
||||
silentLogin,
|
||||
approveDevice,
|
||||
signup,
|
||||
forgot,
|
||||
authorize,
|
||||
exchange,
|
||||
logout,
|
||||
getAppLogin,
|
||||
providerLogin,
|
||||
getAccount,
|
||||
mfaInitiate,
|
||||
mfaVerify,
|
||||
mfaEnable,
|
||||
mfaChallenge,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a user-entered device code to the form IAM generated. IAM mints
|
||||
* user_codes from an UPPERCASE unambiguous alphabet ([A-HJ-NP-Z2-9], no
|
||||
* I/L/O/0/1) and keys its DeviceAuthMap on the exact string. A human may
|
||||
* transcribe it lower-cased or with stray spaces/dashes, so normalize TO
|
||||
* uppercase and strip separators — case-insensitive entry, an exact-match send.
|
||||
*/
|
||||
function normalizeUserCode(raw: string): string {
|
||||
return raw.trim().toUpperCase().replace(/[\s-]+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an IAM provider record to its canonical authorize-endpoint `provider`
|
||||
* key. IAM names providers `provider-<key>` (e.g. `provider-github`); the
|
||||
* `/v1/iam/oauth/authorize?provider=<key>` param wants the bare key. The
|
||||
* Web3Onboard wallet provider maps to `web3`.
|
||||
*/
|
||||
function providerKey(name: string): string {
|
||||
return name.replace(/^provider-/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider is renderable only when IAM holds a real OAuth clientId for it.
|
||||
* The seed ships obvious placeholders (`GITHUB_CLIENT_ID_PLACEHOLDER`,
|
||||
* `placeholder`); an empty or placeholder id means the provider isn't
|
||||
* provisioned, so its button is hidden rather than dead-ending the user. Real
|
||||
* OAuth client ids never contain "placeholder".
|
||||
*/
|
||||
function isConfiguredClientId(clientId: string): boolean {
|
||||
return clientId.length > 0 && !/placeholder/i.test(clientId)
|
||||
}
|
||||
|
||||
/** Shape the `/v1/iam/get-app-login` `data` payload into the {@link AppLogin} view. */
|
||||
function parseAppLogin(
|
||||
data: Record<string, unknown>,
|
||||
fallbackApp: string,
|
||||
fallbackOrg: string,
|
||||
): AppLogin {
|
||||
const rawProviders = Array.isArray(data.providers) ? data.providers : []
|
||||
const providers: AppProvider[] = rawProviders
|
||||
.map((p): AppProvider | null => {
|
||||
if (typeof p !== 'object' || p === null) return null
|
||||
const rec = p as Record<string, unknown>
|
||||
// The provider's IDENTITY is the nested provider record's name
|
||||
// (`rec.provider.name`, e.g. `provider-github`) — that is what the IAM
|
||||
// backend's social-login lookup (`GetProvider(admin/<name>)`) resolves.
|
||||
// The OUTER link object's `name` is the app's provider-LINK label, which
|
||||
// some IAM seeds set to a per-app default (e.g. `<org>-iam`); reading
|
||||
// it as the provider name made the hop POST `provider=<org>-iam`, which
|
||||
// the backend rejects ("The provider: <org>-iam does not exist"). Prefer
|
||||
// the inner record name; fall back to the outer label only when there is
|
||||
// no nested provider record. One source of truth: the provider record.
|
||||
const inner =
|
||||
typeof rec.provider === 'object' && rec.provider !== null
|
||||
? (rec.provider as Record<string, unknown>)
|
||||
: {}
|
||||
const innerName = typeof inner.name === 'string' ? inner.name : ''
|
||||
const outerName = typeof rec.name === 'string' ? rec.name : ''
|
||||
const name = innerName || outerName
|
||||
if (!name) return null
|
||||
const clientId = typeof inner.clientId === 'string' ? inner.clientId : ''
|
||||
return {
|
||||
name,
|
||||
key: providerKey(name),
|
||||
canSignIn: rec.canSignIn !== false,
|
||||
canSignUp: rec.canSignUp !== false,
|
||||
configured: isConfiguredClientId(clientId),
|
||||
type: typeof inner.type === 'string' ? inner.type : '',
|
||||
clientId,
|
||||
scopes: typeof inner.scopes === 'string' ? inner.scopes : '',
|
||||
}
|
||||
})
|
||||
.filter((p): p is AppProvider => p !== null)
|
||||
return {
|
||||
application: typeof data.name === 'string' ? data.name : fallbackApp,
|
||||
organization: typeof data.organization === 'string' ? data.organization : fallbackOrg,
|
||||
enablePassword: data.enablePassword !== false,
|
||||
enableSignUp: data.enableSignUp !== false,
|
||||
enableCodeSignin: data.enableCodeSignin === true,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
const data = body.data
|
||||
|
||||
// Multi-factor signal — IAM answers a successful credential check with a
|
||||
// STRING in `data` (NOT a `mfa_required` boolean): `"RequiredMfa"` when org
|
||||
// policy forces MFA the user has not enrolled, `"NextMfa"` when the user has
|
||||
// MFA and must answer a challenge. Branch BEFORE any session/redirect return:
|
||||
// the password session is not yet usable, so the portal must render the
|
||||
// enrollment/challenge step rather than navigate on.
|
||||
if (data === 'RequiredMfa') {
|
||||
return { mfaRequired: true, mfaStage: 'enroll' }
|
||||
}
|
||||
if (data === 'NextMfa') {
|
||||
const allow = Array.isArray(body.data2) ? body.data2 : []
|
||||
const mfaTypes = allow
|
||||
.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).mfaType : undefined))
|
||||
.filter((t): t is string => typeof t === 'string' && t.length > 0)
|
||||
return { mfaRequired: true, mfaStage: 'challenge', mfaTypes }
|
||||
}
|
||||
|
||||
// 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
|
||||
// post-login onboarding flow. Onboarding's IAM writes ride the same
|
||||
// session cookie (`credentials: include`), so no bearer token is needed
|
||||
// for the password path.
|
||||
if (!req?.redirectUri) {
|
||||
return { redirectUrl: '/onboarding' }
|
||||
}
|
||||
|
||||
// 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 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import { IAM } from '@hanzo/iam/browser'
|
||||
|
||||
/**
|
||||
* One IAM browser-SDK instance per tenant, wired to the portal's own
|
||||
* `/callback` route. This is the single place that constructs the PKCE
|
||||
* client — social/web3 sign-in (here) and the callback handler
|
||||
* (`Callback.tsx`) share it so the PKCE verifier/state the SDK stores on
|
||||
* `signinRedirect` is the same one it reads on `handleCallback`. One way.
|
||||
*
|
||||
* The portal is its own OIDC client (`clientId` = the brand `-id` app), so
|
||||
* every flow it initiates lands back at `${publicOrigin}/callback`.
|
||||
*/
|
||||
export function createIam(tenant: TenantConfig, clientId?: string): IAM {
|
||||
return new IAM({
|
||||
serverUrl: tenant.iamUrl,
|
||||
clientId: clientId ?? tenant.clientId,
|
||||
redirectUri: `${tenant.publicOrigin}/callback`,
|
||||
scope: 'openid profile email',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export {
|
||||
createAuthClient,
|
||||
mfaChannelOf,
|
||||
MFA_TOTP,
|
||||
type AuthClient,
|
||||
type AuthClientOptions,
|
||||
} from './client'
|
||||
export { createIam } from './iam'
|
||||
export {
|
||||
startProviderLogin,
|
||||
buildProviderAuthUrl,
|
||||
isHoppableProvider,
|
||||
encodeState,
|
||||
decodeState,
|
||||
type ProviderLoginParams,
|
||||
} from './social'
|
||||
export {
|
||||
loginWithWalletChain,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletLoginContext,
|
||||
} from './web3'
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChannel,
|
||||
MfaChallengeRequest,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
SignupRequest,
|
||||
ForgotRequest,
|
||||
OAuthAuthorizeRequest,
|
||||
TokenResponse,
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
} from './types'
|
||||
export * from './ui'
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Provider-hop URL builder tests — pure, no network. Run with:
|
||||
* pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Verifies the URL + base64 state match the Hanzo IAM `getAuthUrl`
|
||||
* contract so the backend `/callback` exchange accepts the return. The
|
||||
* end-to-end OAuth round-trip still needs live verification once real provider
|
||||
* creds are seeded — but the URL/state construction is locked down here.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildProviderAuthUrl, isHoppableProvider, matchProviderHint } from './social.ts'
|
||||
|
||||
const ORIGIN = 'https://hanzo.id'
|
||||
// The original OIDC authorize query the portal was bounced here with.
|
||||
const SEARCH = '?client_id=hanzo-id&redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback&response_type=code&scope=openid&state=rp123'
|
||||
|
||||
test('GitHub hop builds the correct endpoint, client_id, redirect_uri, and scope', () => {
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_real_123' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(url.startsWith('https://github.com/login/oauth/authorize?'))
|
||||
assert.ok(url.includes('client_id=gh_real_123'))
|
||||
// No callbackOrigin → defaults to the browser origin.
|
||||
assert.ok(url.includes('redirect_uri=https://hanzo.id/callback'))
|
||||
assert.ok(url.includes('scope=user:email+read:user')) // GitHub default
|
||||
assert.ok(url.includes('response_type=code'))
|
||||
})
|
||||
|
||||
test('GitLab hop builds the correct endpoint, client_id, redirect_uri, and scope', () => {
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-gitlab', type: 'GitLab', clientId: 'gl_real_5a68' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(url.startsWith('https://gitlab.com/oauth/authorize?'))
|
||||
assert.ok(url.includes('client_id=gl_real_5a68'))
|
||||
assert.ok(url.includes('redirect_uri=https://hanzo.id/callback'))
|
||||
assert.ok(url.includes('scope=read_user')) // GitLab identity read
|
||||
assert.ok(url.includes('response_type=code'))
|
||||
})
|
||||
|
||||
test('the registered callback origin overrides the browser origin in redirect_uri', () => {
|
||||
// The shared OAuth client is registered against iam.hanzo.ai/callback, so the
|
||||
// hop must return there even though the SPA runs on hanzo.id — otherwise the
|
||||
// provider rejects the redirect_uri (verified live: Google accepts ONLY
|
||||
// https://iam.hanzo.ai/callback for this client).
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
'https://iam.hanzo.ai',
|
||||
)!
|
||||
assert.ok(url.includes('redirect_uri=https://iam.hanzo.ai/callback'))
|
||||
assert.ok(!url.includes('redirect_uri=https://hanzo.id/callback'))
|
||||
})
|
||||
|
||||
test('state base64-encodes the original OIDC query + application/provider/method (round-trips)', () => {
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_real_123', method: 'signup' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
const state = new URL(url).searchParams.get('state')!
|
||||
const decoded = Buffer.from(state, 'base64').toString('utf8')
|
||||
// The RP's original request survives so the backend can complete it.
|
||||
assert.ok(decoded.includes('client_id=hanzo-id'))
|
||||
assert.ok(decoded.includes('state=rp123'))
|
||||
assert.ok(decoded.includes('application=hanzo-id'))
|
||||
assert.ok(decoded.includes('provider=provider-github'))
|
||||
assert.ok(decoded.includes('method=signup'))
|
||||
})
|
||||
|
||||
test('a pre-existing provider= in the upstream query is stripped — state carries exactly ONE provider', () => {
|
||||
// The console→hanzo.id SSO SDK appends `provider=hanzo-iam` (its per-org IDP
|
||||
// hint) to the upstream authorize query. The hop appends the REAL social
|
||||
// provider; the upstream one MUST be stripped, because `Callback` recovers the
|
||||
// provider with `URLSearchParams.get` (the FIRST match) — two `provider=`
|
||||
// params would make it post `hanzo-iam`, which the IAM backend rejects.
|
||||
const searchWithHint =
|
||||
'?client_id=hanzo-console&redirect_uri=https%3A%2F%2Fiam.hanzo.ai%2Fcallback&response_type=code&scope=openid&state=rp123&provider=hanzo-iam'
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-console', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
searchWithHint,
|
||||
'https://iam.hanzo.ai',
|
||||
)!
|
||||
const state = new URL(url).searchParams.get('state')!
|
||||
const decoded = Buffer.from(state, 'base64').toString('utf8')
|
||||
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
|
||||
// Exactly one provider, and it is the real social one (not the upstream hint).
|
||||
assert.deepEqual(params.getAll('provider'), ['provider-google'])
|
||||
assert.equal(params.get('provider'), 'provider-google') // FIRST match = the social provider
|
||||
assert.ok(!decoded.includes('hanzo-iam')) // the upstream hint is gone entirely
|
||||
// The rest of the upstream OIDC request is preserved so the backend completes it.
|
||||
assert.ok(decoded.includes('client_id=hanzo-console'))
|
||||
assert.ok(decoded.includes('state=rp123'))
|
||||
})
|
||||
|
||||
test('Google uses its own endpoint + scope; a custom provider scope overrides', () => {
|
||||
const g = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(g.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'))
|
||||
assert.ok(g.includes('scope=profile+email'))
|
||||
|
||||
const custom = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_1', scopes: 'repo+user' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(custom.includes('scope=repo+user'))
|
||||
})
|
||||
|
||||
test('an unconfigured (empty clientId) or unknown provider type yields no URL', () => {
|
||||
assert.equal(buildProviderAuthUrl({ application: 'a', providerName: 'p', type: 'GitHub', clientId: '' }, ORIGIN, SEARCH), null)
|
||||
assert.equal(buildProviderAuthUrl({ application: 'a', providerName: 'p', type: 'Mystery', clientId: 'x' }, ORIGIN, SEARCH), null)
|
||||
})
|
||||
|
||||
test('isHoppableProvider knows the OAuth set, not wallet', () => {
|
||||
assert.equal(isHoppableProvider('GitHub'), true)
|
||||
assert.equal(isHoppableProvider('GitLab'), true)
|
||||
assert.equal(isHoppableProvider('Google'), true)
|
||||
assert.equal(isHoppableProvider('Web3Onboard'), false)
|
||||
})
|
||||
|
||||
test('matchProviderHint resolves the console hint, the bare key, and case, else undefined', () => {
|
||||
const providers = [
|
||||
{ name: 'provider-github', key: 'github' },
|
||||
{ name: 'provider-google', key: 'google' },
|
||||
]
|
||||
// The console sends the IAM record name verbatim (`provider-github`).
|
||||
assert.equal(matchProviderHint(providers, 'provider-github')?.key, 'github')
|
||||
assert.equal(matchProviderHint(providers, 'provider-google')?.key, 'google')
|
||||
// The bare key and any case also resolve, so the two sides need no shared constant.
|
||||
assert.equal(matchProviderHint(providers, 'github')?.key, 'github')
|
||||
assert.equal(matchProviderHint(providers, 'GitHub')?.key, 'github')
|
||||
// A hint for a provider this app doesn't offer, or an empty hint, matches nothing.
|
||||
assert.equal(matchProviderHint(providers, 'provider-apple'), undefined)
|
||||
assert.equal(matchProviderHint(providers, ''), undefined)
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Social provider redirect — the "hop" that sends the browser to GitHub /
|
||||
* Google / … to start an OAuth login, replicating the Hanzo IAM
|
||||
* front-end `Provider.getAuthUrl` contract so the IAM backend's `/callback`
|
||||
* exchange accepts the return.
|
||||
*
|
||||
* Why this exists: the IAM backend's OIDC authorize endpoint, for an
|
||||
* unauthenticated request, 302s to its OWN login-page route (`/login/oauth/
|
||||
* authorize`) and expects the FRONT END to read `?provider=` and bounce to the
|
||||
* provider. We replaced that front end with this portal, so the portal must do
|
||||
* the bounce. `iam.signinRedirect({provider})` does NOT — it just re-enters the
|
||||
* authorize endpoint and loops.
|
||||
*
|
||||
* Contract (from `web/src/auth/Provider.tsx::getAuthUrl` + `Util.tsx::
|
||||
* getStateFromQueryParams` in the IAM fork):
|
||||
* url = `${endpoint}?client_id=${clientId}&redirect_uri=${origin}/callback`
|
||||
* `&scope=${scope}&response_type=code&state=${state}`
|
||||
* state = encodeState(`${window.location.search}&application=${app}&provider=`
|
||||
* `${providerName}&method=${method}`) // URL-SAFE base64 of the
|
||||
* // ORIGINAL OIDC query + app/provider/method, so the backend recovers
|
||||
* // the original request when the provider returns to /callback.
|
||||
*
|
||||
* Only the standard OAuth2 set is wired here (the providers a `-id` app
|
||||
* actually enables: github, google, +web3 handled elsewhere). Apple uses the
|
||||
* backend callback and is added when needed.
|
||||
*
|
||||
* NOTE: live-verify this end-to-end once real OAuth credentials are seeded —
|
||||
* it cannot be exercised while every provider carries placeholder creds (the
|
||||
* buttons are hidden until then; see SocialButtons + AppProvider.configured).
|
||||
*/
|
||||
|
||||
/** Provider `type` → OAuth2 authorize endpoint + default scope (IAM `authInfo`). */
|
||||
const AUTH_INFO: Record<string, { endpoint: string; scope: string }> = {
|
||||
GitHub: { endpoint: 'https://github.com/login/oauth/authorize', scope: 'user:email+read:user' },
|
||||
// Canonical Google OAuth2 authorize endpoint. (Google aliases the legacy
|
||||
// `/signin/oauth` path, but `/o/oauth2/v2/auth` is the documented, stable one.)
|
||||
Google: { endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', scope: 'profile+email' },
|
||||
// GitLab OAuth2 authorize endpoint. `read_user` grants the identity read
|
||||
// (profile + email) IAM needs to find-or-create the account — the same
|
||||
// provider record backs the cloud /v1/integrations/gitlab connector.
|
||||
GitLab: { endpoint: 'https://gitlab.com/oauth/authorize', scope: 'read_user' },
|
||||
}
|
||||
|
||||
export interface ProviderLoginParams {
|
||||
/** IAM application name the portal authenticates as (e.g. `hanzo-id`). */
|
||||
readonly application: string
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly providerName: string
|
||||
/** IAM provider `type`, e.g. `GitHub` / `Google` (selects the endpoint). */
|
||||
readonly type: string
|
||||
/** The provider's real OAuth client id (from `get-app-login`). */
|
||||
readonly clientId: string
|
||||
/** Override scope; falls back to the type default. */
|
||||
readonly scopes?: string
|
||||
/**
|
||||
* IAM social-auth method. Defaults to "signup" — the find-or-create-LOGIN
|
||||
* branch (IAM canonical). "signin" is the account-LINK branch and needs
|
||||
* an existing session, so it is NOT used for interactive provider sign-in.
|
||||
*/
|
||||
readonly method?: 'signin' | 'signup'
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-safe base64 (RFC 4648 §5) for the round-tripped `state`.
|
||||
*
|
||||
* The provider reflects `state` back on a URL query string. Standard base64
|
||||
* (`btoa`) emits `+` `/` `=`, and on the return `URLSearchParams` turns `+`
|
||||
* into a space (then `atob` strips it) — silently CORRUPTING the encoded OIDC
|
||||
* request, including the `code_challenge`. A corrupted, non-empty challenge is
|
||||
* exactly what makes the app's later token exchange fail with
|
||||
* `invalid_grant: code_verifier does not match code_challenge`. Encoding the
|
||||
* state with the URL-safe alphabet (and decoding it the same way in `Callback`)
|
||||
* makes the round-trip byte-exact. The payload is ASCII (an OAuth query), so a
|
||||
* plain `btoa`/`atob` core is sufficient.
|
||||
*/
|
||||
export function encodeState(raw: string): string {
|
||||
return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/** Inverse of {@link encodeState}; tolerant of standard or URL-safe input. */
|
||||
export function decodeState(state: string): string {
|
||||
let b64 = state.replace(/-/g, '+').replace(/_/g, '/')
|
||||
while (b64.length % 4) b64 += '='
|
||||
return atob(b64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provider authorize URL (pure; testable without navigating).
|
||||
*
|
||||
* `callbackOrigin` is the origin of the `/callback` that MUST be registered as
|
||||
* the provider's authorized redirect URI. The OAuth client (one per provider)
|
||||
* is registered against a SINGLE callback host — the IAM backend host
|
||||
* (`iam.hanzo.ai`) — shared across every brand portal, so the provider only
|
||||
* accepts that exact `redirect_uri`. Sending the browser's own origin (e.g.
|
||||
* `hanzo.id`) yields `redirect_uri_mismatch`. Callers pass the registered
|
||||
* origin; it defaults to `origin` for the single-host / local-dev case.
|
||||
*
|
||||
* `iam.hanzo.ai/callback` serves the SAME `@hanzo/id` SPA (the headless
|
||||
* `Callback` page — no login UI), which decodes the base64 `state` to recover
|
||||
* the original app's `redirect_uri`, exchanges the provider `code` at the IAM
|
||||
* backend, and forwards the browser back to the originating app.
|
||||
*/
|
||||
export function buildProviderAuthUrl(
|
||||
p: ProviderLoginParams,
|
||||
origin: string,
|
||||
search: string,
|
||||
callbackOrigin: string = origin,
|
||||
): string | null {
|
||||
const info = AUTH_INFO[p.type]
|
||||
if (!info || !p.clientId) return null
|
||||
const scope = p.scopes && p.scopes.trim() !== '' ? p.scopes : info.scope
|
||||
const redirectUri = `${callbackOrigin}/callback`
|
||||
// IAM's social branch does FIND-OR-CREATE-LOGIN only under method `signup`
|
||||
// (the canonical IAM default — web `Util.tsx` getEvent → getAuthUrl(...,
|
||||
// "signup")). Any other value (incl. `signin`) takes the account-LINK branch,
|
||||
// which requires an EXISTING session and 400s a fresh "Continue with Google".
|
||||
const method = p.method ?? 'signup'
|
||||
// The console SSO SDK appends a unified `provider=<org>-iam` hint to the
|
||||
// upstream authorize query (`search`). We append the REAL social provider
|
||||
// below, so strip any pre-existing `provider=` first — otherwise the state
|
||||
// carries TWO `provider=` params and the /callback exchange resolves the
|
||||
// wrong one (`<org>-iam`, which IAM rejects). One provider, one source of
|
||||
// truth — don't rely on "backend reads the last param".
|
||||
const baseQ = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
baseQ.delete('provider')
|
||||
const baseSearch = `?${baseQ.toString()}`
|
||||
// Base64 of the original OIDC query + routing — the backend decodes this on
|
||||
// the /callback return to complete the original authorize request.
|
||||
const state = encodeState(`${baseSearch}&application=${encodeURIComponent(p.application)}&provider=${encodeURIComponent(p.providerName)}&method=${method}`)
|
||||
return `${info.endpoint}?client_id=${p.clientId}&redirect_uri=${redirectUri}&scope=${scope}&response_type=code&state=${state}`
|
||||
}
|
||||
|
||||
/** True when this portal knows how to start an OAuth hop for the given type. */
|
||||
export function isHoppableProvider(type: string): boolean {
|
||||
return type in AUTH_INFO
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `provider_hint` from the authorize query to one of the app's
|
||||
* configured providers. A client that already knows which provider the user
|
||||
* chose (the console passes `?provider_hint=provider-github` when a user clicks
|
||||
* "Continue with GitHub" over there) sends the hint so this portal launches that
|
||||
* provider straight away — no second button press, no bounce through a login
|
||||
* page. Accepts the IAM record name (`provider-github`), the normalized key
|
||||
* (`github`), or the record name with the `provider-` prefix stripped, so the
|
||||
* two sides agree without a shared constant. Returns undefined when nothing
|
||||
* matches (the caller falls back to the interactive form).
|
||||
*/
|
||||
export function matchProviderHint<P extends { name: string; key: string }>(
|
||||
providers: Iterable<P>,
|
||||
hint: string,
|
||||
): P | undefined {
|
||||
const h = hint.trim().toLowerCase()
|
||||
if (h === '') return undefined
|
||||
const bare = h.replace(/^provider-/, '')
|
||||
for (const p of providers) {
|
||||
const name = p.name.toLowerCase()
|
||||
const key = p.key.toLowerCase()
|
||||
if (name === h || key === h || key === bare) return p
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the browser to the provider to begin login. No-op return on bad input.
|
||||
*
|
||||
* `callbackOrigin` (the provider's registered redirect host, e.g.
|
||||
* `https://iam.hanzo.ai`) defaults to the current origin when omitted.
|
||||
*/
|
||||
export function startProviderLogin(p: ProviderLoginParams, callbackOrigin?: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
const url = buildProviderAuthUrl(p, window.location.origin, window.location.search, callbackOrigin ?? window.location.origin)
|
||||
if (url) window.location.assign(url)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
export interface LoginRequest {
|
||||
readonly identifier: string
|
||||
readonly password: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
/**
|
||||
* Org-resolution anchor for the credential lookup. OPTIONAL by design.
|
||||
*
|
||||
* IAM resolves the user by (org, identifier); if the in-org lookup misses it
|
||||
* falls back to a CROSS-ORG lookup by email/username and the session always
|
||||
* encodes the user's REAL owner-org (`GetOrganizationByUser`), never this
|
||||
* value. So this field is a lookup HINT, not the session's org.
|
||||
*
|
||||
* Leaving it empty/undefined makes login ORG-AGNOSTIC: every in-org lookup
|
||||
* misses, the cross-org fallback runs, and an identity that lives in the
|
||||
* global `admin` org (a global admin) resolves to `admin` (→ full multi-org
|
||||
* session) while a brand-only identity resolves to its own brand org. This is
|
||||
* why the portal does NOT pin the brand org here — pinning `hanzo` would
|
||||
* resolve a colliding `hanzo/<name>` row and truncate a global admin to one
|
||||
* org. Set it only to FORCE a specific tenant (e.g. a brand that deliberately
|
||||
* scopes its portal to a single org). Signup, by contrast, MUST carry a
|
||||
* concrete org (you cannot create a user in "no org").
|
||||
*/
|
||||
readonly organization?: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/**
|
||||
* OIDC nonce from the downstream authorize request. MUST be threaded through
|
||||
* the password-login path so the minted code (and resulting id_token) echo it.
|
||||
* Confidential OIDC clients that validate strictly (e.g. LibreChat /
|
||||
* openid-client with OPENID_REUSE_TOKENS) reject an id_token whose nonce
|
||||
* doesn't match the one they sent -> "unexpected JWT claim value" -> callback
|
||||
* 500. Forward, never default — only echo what the authorize URL carried.
|
||||
*/
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs to {@link AuthClient.silentLogin} — the silent-SSO leg.
|
||||
*
|
||||
* Carries NO credentials. When the browser already holds an `iam_session_id`
|
||||
* cookie on the issuer host (the user signed in once for another app), IAM's
|
||||
* Login handler takes its "already signed in" branch and mints an authorization
|
||||
* code for `application` without a password or a provider hop. This is what
|
||||
* makes the 2nd/3rd app log in seamlessly. With no live session IAM returns an
|
||||
* error and the caller falls back to the interactive login form.
|
||||
*/
|
||||
export interface SilentLoginRequest {
|
||||
/** OAuth client id of the requesting app (== application name in Hanzo IAM). */
|
||||
readonly clientId: string
|
||||
/** IAM application name the code is minted for. */
|
||||
readonly application: string
|
||||
/** The requesting app's OAuth redirect_uri — the code is appended to it. */
|
||||
readonly redirectUri: string
|
||||
readonly state?: string
|
||||
readonly scope?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/** OIDC nonce, echoed into the minted code -> id_token (strict consumers). */
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
/** A multi-factor channel the portal can render a code entry for. */
|
||||
export type MfaChannel = 'totp' | 'sms' | 'email'
|
||||
|
||||
export interface LoginResponse {
|
||||
readonly accessToken?: string
|
||||
readonly refreshToken?: string
|
||||
readonly idToken?: string
|
||||
readonly expiresAt?: number
|
||||
readonly redirectUrl?: string
|
||||
/**
|
||||
* Set when IAM answered the login with a multi-factor signal instead of a
|
||||
* session/code. `mfaStage` discriminates the two IAM states:
|
||||
* - `'enroll'` — IAM returned `data:"RequiredMfa"`: org policy forces MFA
|
||||
* and the user has none yet → render forced TOTP enrollment.
|
||||
* - `'challenge'` — IAM returned `data:"NextMfa"`: the user has MFA enabled
|
||||
* → render a code challenge for one of `mfaTypes`.
|
||||
* The password session is NOT established until the enrollment/challenge
|
||||
* completes, so the portal must not navigate past this signal.
|
||||
*/
|
||||
readonly mfaRequired?: boolean
|
||||
readonly mfaStage?: 'enroll' | 'challenge'
|
||||
/**
|
||||
* The IAM MFA types available for a `'challenge'` (from the login response's
|
||||
* `data2`), in IAM's own vocabulary: `app` (TOTP), `sms`, `email`. Empty for
|
||||
* enrollment.
|
||||
*/
|
||||
readonly mfaTypes?: readonly string[]
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of approving an RFC 8628 device-authorization request
|
||||
* ({@link AuthClient.approveDevice}).
|
||||
*
|
||||
* `ok` — the device code was marked signed-in (the CLI's token poll now
|
||||
* succeeds). `required` — the application needs the user to grant consent
|
||||
* before approval can complete (`{status:ok, data:{required:true}}`); rare for
|
||||
* first-party apps. `error` — the IAM-surfaced failure message (e.g.
|
||||
* "UserCode Expired", "DeviceCode Invalid").
|
||||
*/
|
||||
export interface DeviceApprovalResult {
|
||||
readonly ok: boolean
|
||||
readonly required?: boolean
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The TOTP enrollment material minted by `/v1/iam/mfa/setup/initiate`. The
|
||||
* secret + `url` (an `otpauth://` URI) are rendered locally as a QR code — the
|
||||
* secret never leaves the browser to a third party. `recoveryCodes[0]` must be
|
||||
* echoed back to `/v1/iam/mfa/setup/enable`.
|
||||
*/
|
||||
export interface MfaSetup {
|
||||
/** IAM MFA type — `app` for TOTP. */
|
||||
readonly mfaType: string
|
||||
/** Base32 TOTP secret. */
|
||||
readonly secret: string
|
||||
/** `otpauth://totp/...` provisioning URI for the authenticator app. */
|
||||
readonly url: string
|
||||
/** One-time recovery codes issued alongside the secret. */
|
||||
readonly recoveryCodes: readonly string[]
|
||||
}
|
||||
|
||||
/** The signed-in user's identity, resolved from the IAM session for MFA setup. */
|
||||
export interface MfaIdentity {
|
||||
readonly owner: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** A TOTP challenge submission for a user who already enrolled (`NextMfa`). */
|
||||
export interface MfaChallengeRequest {
|
||||
/** IAM MFA type, e.g. `app` (TOTP), `sms`, `email`. */
|
||||
readonly mfaType: string
|
||||
readonly passcode: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/** Honor the org's "remember this device" window after a successful code. */
|
||||
readonly rememberDevice?: boolean
|
||||
}
|
||||
|
||||
export interface SignupRequest {
|
||||
readonly email: string
|
||||
readonly password: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly inviteCode?: string
|
||||
}
|
||||
|
||||
export interface ForgotRequest {
|
||||
readonly identifier: string
|
||||
readonly clientId: string
|
||||
readonly organization: string
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizeRequest {
|
||||
readonly clientId: string
|
||||
readonly redirectUri: string
|
||||
readonly state: string
|
||||
readonly scope?: string
|
||||
readonly nonce?: string
|
||||
readonly responseType?: 'code' | 'token'
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/** Social provider name (e.g. "provider-github"); IAM initiates that provider's OAuth. */
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
/** A third-party / wallet login provider attached to the application. */
|
||||
export interface ProviderInfo {
|
||||
readonly name: string
|
||||
readonly displayName?: string
|
||||
/** IAM provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
|
||||
readonly type?: string
|
||||
/** IAM category, e.g. OAuth, Web3, SAML. */
|
||||
readonly category?: string
|
||||
readonly canSignIn?: boolean
|
||||
readonly canSignUp?: boolean
|
||||
}
|
||||
|
||||
/** A sign-in method offered by the application (Password, Verification code, WebAuthn, …). */
|
||||
export interface SigninMethod {
|
||||
readonly name: string
|
||||
readonly rule?: string
|
||||
}
|
||||
|
||||
/** The subset of the application's login config the portal renders from. */
|
||||
export interface AppLoginInfo {
|
||||
readonly name: string
|
||||
readonly displayName?: string
|
||||
readonly providers: ProviderInfo[]
|
||||
readonly signinMethods: SigninMethod[]
|
||||
readonly enablePassword: boolean
|
||||
readonly enableCodeSignin: boolean
|
||||
readonly enableSignUp: boolean
|
||||
}
|
||||
|
||||
/** Passwordless login with an email/SMS verification code. */
|
||||
export interface CodeLoginRequest {
|
||||
/** Destination already sent a code: an email address or E.164 phone number. */
|
||||
readonly dest: string
|
||||
readonly code: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
readonly accessToken: string
|
||||
readonly refreshToken?: string
|
||||
readonly idToken?: string
|
||||
readonly tokenType: string
|
||||
readonly expiresIn?: number
|
||||
readonly scope?: string
|
||||
}
|
||||
|
||||
/** A social/web3 provider enabled on an IAM application. */
|
||||
export interface AppProvider {
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly name: string
|
||||
/** Normalized provider key passed to the authorize endpoint, e.g. `github`, `google`, `web3`. */
|
||||
readonly key: string
|
||||
/** Whether the provider may be used to sign in. */
|
||||
readonly canSignIn: boolean
|
||||
/** Whether the provider may be used to sign up. */
|
||||
readonly canSignUp: boolean
|
||||
/**
|
||||
* Whether IAM holds a real OAuth credential for this provider (a non-empty,
|
||||
* non-placeholder clientId). The login UI renders ONLY configured providers,
|
||||
* so an unprovisioned button never dead-ends the user — it appears
|
||||
* automatically once real credentials are seeded into IAM. The seed ships
|
||||
* obvious placeholders (`GITHUB_CLIENT_ID_PLACEHOLDER`, `placeholder`), which
|
||||
* read as not-configured.
|
||||
*/
|
||||
readonly configured: boolean
|
||||
/** IAM provider `type`, e.g. `GitHub` / `Google` / `Web3Onboard` (selects the OAuth endpoint). */
|
||||
readonly type: string
|
||||
/** The provider's OAuth client id (used to build the provider redirect; empty when unconfigured). */
|
||||
readonly clientId: string
|
||||
/** Override OAuth scopes, if the provider record sets them. */
|
||||
readonly scopes: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The enabled-auth-methods view of an IAM application, read live from
|
||||
* `/v1/iam/get-app-login`. This is the canonical source of truth for which
|
||||
* buttons to render — it reflects the per-app config in `init_data.json`
|
||||
* (password + GitHub + Google + Web3). The portal renders exactly what IAM
|
||||
* reports enabled, so there is no client/server method drift.
|
||||
*/
|
||||
export interface AppLogin {
|
||||
/** IAM application name (e.g. `hanzo-id`). */
|
||||
readonly application: string
|
||||
/** Owning organization slug. */
|
||||
readonly organization: string
|
||||
/** Email/username + password sign-in is enabled. */
|
||||
readonly enablePassword: boolean
|
||||
/** Self-service signup is enabled. */
|
||||
readonly enableSignUp: boolean
|
||||
/** Email/SMS verification-code sign-in is enabled. */
|
||||
readonly enableCodeSignin: boolean
|
||||
/** Social + Web3 providers enabled on the app, in display order. */
|
||||
readonly providers: readonly AppProvider[]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Labeled horizontal rule, e.g. "or", separating social from email sign-in. */
|
||||
export function Divider({ label = 'or' }: { label?: string }) {
|
||||
return (
|
||||
<div className="hanzo-id-divider" role="separator" aria-label={label}>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
|
||||
export interface ForgotFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly onSent?: () => void
|
||||
}
|
||||
|
||||
export function ForgotForm(props: ForgotFormProps) {
|
||||
const { client } = props
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await client.forgot({
|
||||
identifier,
|
||||
clientId: client.tenant.clientId,
|
||||
organization: client.tenant.orgId,
|
||||
})
|
||||
if (!res.ok) setError(res.error ?? 'send failed')
|
||||
else {
|
||||
setSent(true)
|
||||
props.onSent?.()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return <p className="hanzo-id-info">Check your inbox for a reset link.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-forgot-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input type="email" autoComplete="email" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { LoginResponse } from '../types'
|
||||
|
||||
export interface LoginFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly clientIdOverride?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
readonly nonce?: string
|
||||
readonly onSuccess?: (res: LoginResponse) => void
|
||||
readonly onMfaRequired?: (res: LoginResponse) => void
|
||||
/**
|
||||
* Called after a successful sign-in INSTEAD of the form's default post-login
|
||||
* navigation. When provided, the form does not redirect (neither to a
|
||||
* downstream app nor to `/onboarding`) — the caller owns what happens next.
|
||||
* Used by the device-approval page to stay on-page and show the confirm step.
|
||||
*/
|
||||
readonly onAuthenticated?: (res: LoginResponse) => void
|
||||
}
|
||||
|
||||
export function LoginForm(props: LoginFormProps) {
|
||||
const { client } = props
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Authenticate against the ORG OF THE APP being logged into, not the
|
||||
// portal's own brand. When a downstream app initiates the login it passes
|
||||
// its own `client_id` (props.clientIdOverride); that app may live in a
|
||||
// different org than this brand portal — e.g. the admin-guard
|
||||
// (client_id=hanzo-admin-guard) is in the `admin` org, so its operators
|
||||
// must resolve to the admin/* identity (owner=admin), NOT this brand's
|
||||
// hanzo/* row. get-app-login is the canonical clientId -> {application,
|
||||
// organization} map; resolve through it and post BOTH so IAM scopes the
|
||||
// credential check to the app's org.
|
||||
//
|
||||
// With no override this is the brand portal's OWN bare sign-in: keep it
|
||||
// org-agnostic (`loginOrg` unset -> no `organization` posted) so a global
|
||||
// admin resolves cross-org into the full multi-org session instead of
|
||||
// being truncated to one brand org.
|
||||
let application = client.tenant.appName
|
||||
let organization = client.tenant.loginOrg
|
||||
if (props.clientIdOverride) {
|
||||
const app = await client.getAppLogin(props.clientIdOverride)
|
||||
if (app) {
|
||||
application = app.application
|
||||
organization = app.organization
|
||||
}
|
||||
}
|
||||
const res = await client.login({
|
||||
identifier,
|
||||
password,
|
||||
clientId: props.clientIdOverride ?? client.tenant.clientId,
|
||||
application,
|
||||
organization,
|
||||
redirectUri: props.redirectUri,
|
||||
state: props.state,
|
||||
codeChallenge: props.codeChallenge,
|
||||
codeChallengeMethod: props.codeChallengeMethod,
|
||||
nonce: props.nonce,
|
||||
})
|
||||
if (res.error) {
|
||||
setError(res.error)
|
||||
} else if (res.mfaRequired) {
|
||||
props.onMfaRequired?.(res)
|
||||
} else if (props.onAuthenticated) {
|
||||
// Caller owns the next step (e.g. device approval) — suppress the
|
||||
// default navigation so we stay on-page.
|
||||
props.onAuthenticated(res)
|
||||
} else if (res.redirectUrl) {
|
||||
window.location.href = res.redirectUrl
|
||||
} else {
|
||||
props.onSuccess?.(res)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-login-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email or username</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import encodeQR from '@paulmillr/qr'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { MfaIdentity, MfaSetup } from '../types'
|
||||
import { OTPForm } from './OTPForm'
|
||||
|
||||
export interface MfaEnrollFormProps {
|
||||
readonly client: AuthClient
|
||||
/**
|
||||
* Called once the user has verified a TOTP code AND the enrollment is
|
||||
* persisted. The caller continues the session (onboarding or the OIDC
|
||||
* code redirect).
|
||||
*/
|
||||
readonly onComplete: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Forced TOTP enrollment, shown when IAM answers a login with `RequiredMfa`
|
||||
* (org policy requires MFA and the user has none). There is intentionally NO
|
||||
* skip / dismiss control — the only way past this screen is to enroll an
|
||||
* authenticator. The QR is rendered locally from the `otpauth://` URI, so the
|
||||
* TOTP secret never leaves the browser.
|
||||
*
|
||||
* Flow: `getAccount` (resolve identity from the session IAM set with
|
||||
* `RequiredMfa`) → `mfaInitiate` (secret + QR) → user scans → `mfaVerify`
|
||||
* (prove the code) → `mfaEnable` (persist) → `onComplete`.
|
||||
*/
|
||||
export function MfaEnrollForm({ client, onComplete }: MfaEnrollFormProps) {
|
||||
const [identity, setIdentity] = useState<MfaIdentity | null>(null)
|
||||
const [setup, setSetup] = useState<MfaSetup | null>(null)
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function begin() {
|
||||
try {
|
||||
const id = await client.getAccount()
|
||||
if (!id) throw new Error('Your session could not be resolved. Please sign in again.')
|
||||
const s = await client.mfaInitiate(id)
|
||||
if (cancelled) return
|
||||
setIdentity(id)
|
||||
setSetup(s)
|
||||
} catch (e) {
|
||||
if (!cancelled) setFatal(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
void begin()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const qrSvg = useMemo(() => (setup ? encodeQR(setup.url, 'svg') : ''), [setup])
|
||||
|
||||
async function onCode(code: string) {
|
||||
if (!identity || !setup || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const verified = await client.mfaVerify({ owner: identity.owner, name: identity.name, secret: setup.secret, passcode: code })
|
||||
if (!verified.ok) {
|
||||
setError(verified.error ?? 'That code did not match. Try the current code from your app.')
|
||||
return
|
||||
}
|
||||
const enabled = await client.mfaEnable({
|
||||
owner: identity.owner,
|
||||
name: identity.name,
|
||||
secret: setup.secret,
|
||||
recoveryCode: setup.recoveryCodes[0] ?? '',
|
||||
})
|
||||
if (!enabled.ok) {
|
||||
setError(enabled.error ?? 'Could not enable two-factor authentication.')
|
||||
return
|
||||
}
|
||||
onComplete()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (fatal) {
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Two-factor setup</h2>
|
||||
<p role="alert" className="hanzo-id-error">{fatal}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setup) {
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Two-factor setup</h2>
|
||||
<p className="lede">Preparing your authenticator…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const recoveryCode = setup.recoveryCodes[0]
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Set up two-factor authentication</h2>
|
||||
<p className="lede">
|
||||
Your organization requires two-factor authentication. Scan this QR code with an
|
||||
authenticator app (Google Authenticator, 1Password, Authy), then enter the 6-digit code it
|
||||
shows.
|
||||
</p>
|
||||
<div
|
||||
className="hanzo-id-mfa-qr"
|
||||
role="img"
|
||||
aria-label="TOTP enrollment QR code"
|
||||
// Local SVG from @paulmillr/qr — the otpauth secret never leaves the browser.
|
||||
dangerouslySetInnerHTML={{ __html: qrSvg }}
|
||||
/>
|
||||
<details className="hanzo-id-mfa-manual">
|
||||
<summary>Can't scan? Enter this key manually</summary>
|
||||
<code className="hanzo-id-mfa-secret">{setup.secret}</code>
|
||||
</details>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<OTPForm channel="totp" onSubmit={onCode} />
|
||||
{recoveryCode ? (
|
||||
<p className="hanzo-id-mfa-recovery">
|
||||
Save this recovery code somewhere safe — it lets you sign in if you lose your device:
|
||||
<br />
|
||||
<code>{recoveryCode}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { SmsConsentNotice } from './SmsConsent'
|
||||
|
||||
export interface OTPFormProps {
|
||||
readonly onSubmit: (code: string) => void | Promise<void>
|
||||
readonly length?: number
|
||||
readonly channel?: 'totp' | 'sms' | 'email'
|
||||
}
|
||||
|
||||
export function OTPForm(props: OTPFormProps) {
|
||||
const { length = 6, channel = 'totp' } = props
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (code.length !== length) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await props.onSubmit(code)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const label = channel === 'sms' ? 'SMS code' : channel === 'email' ? 'Email code' : 'Authenticator code'
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-otp-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern={`\\d{${length}}`}
|
||||
maxLength={length}
|
||||
autoComplete="one-time-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, length))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{channel === 'sms' ? <SmsConsentNotice /> : null}
|
||||
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AuthClient } from '../client'
|
||||
import type { ProviderInfo } from '../types'
|
||||
|
||||
interface Meta {
|
||||
readonly label: string
|
||||
/** stable brand token used for the CSS class + data attribute (for icon styling) */
|
||||
readonly brand: string
|
||||
}
|
||||
|
||||
// Keyed by a normalized provider token (type or name, lowercased, alnum-only,
|
||||
// "provider" prefix stripped). Falls back to a generic label for anything new.
|
||||
const META: Record<string, Meta> = {
|
||||
google: { label: 'Continue with Google', brand: 'google' },
|
||||
github: { label: 'Continue with GitHub', brand: 'github' },
|
||||
apple: { label: 'Continue with Apple', brand: 'apple' },
|
||||
facebook: { label: 'Continue with Facebook', brand: 'facebook' },
|
||||
web3: { label: 'Connect wallet', brand: 'web3' },
|
||||
web3onboard: { label: 'Connect wallet', brand: 'web3' },
|
||||
metamask: { label: 'Connect wallet', brand: 'web3' },
|
||||
}
|
||||
|
||||
function metaFor(p: ProviderInfo): Meta {
|
||||
const key = (p.type || p.name || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.replace(/^provider/, '')
|
||||
return META[key] ?? { label: `Continue with ${p.displayName || p.name}`, brand: 'generic' }
|
||||
}
|
||||
|
||||
export interface ProviderButtonsProps {
|
||||
readonly client: AuthClient
|
||||
readonly providers: ProviderInfo[]
|
||||
readonly mode: 'login' | 'signup'
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly clientIdOverride?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one button per social/wallet provider attached to the application.
|
||||
* Each links to the IAM authorize endpoint with `provider=<name>`, which
|
||||
* initiates that provider's OAuth and returns to `${publicOrigin}/callback`.
|
||||
* The set is driven by the live app config (AuthClient.appLogin) — no
|
||||
* hardcoded provider list — so enabling a provider in IAM surfaces it here.
|
||||
*/
|
||||
export function ProviderButtons(props: ProviderButtonsProps) {
|
||||
const { client, providers, mode } = props
|
||||
const usable = providers.filter((p) =>
|
||||
mode === 'signup' ? p.canSignUp !== false : p.canSignIn !== false,
|
||||
)
|
||||
if (usable.length === 0) return null
|
||||
|
||||
const redirectUri = props.redirectUri ?? `${client.tenant.publicOrigin}/callback`
|
||||
return (
|
||||
<div className="hanzo-id-providers">
|
||||
{usable.map((p) => {
|
||||
const m = metaFor(p)
|
||||
const href = client.authorize({
|
||||
clientId: props.clientIdOverride ?? client.tenant.clientId,
|
||||
redirectUri,
|
||||
state: props.state ?? mode,
|
||||
provider: p.name,
|
||||
})
|
||||
return (
|
||||
<a
|
||||
key={p.name}
|
||||
className={`hanzo-id-provider-btn hanzo-id-provider-${m.brand}`}
|
||||
href={href}
|
||||
data-provider={m.brand}
|
||||
>
|
||||
{m.label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
<div className="hanzo-id-or">
|
||||
<span>or</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
|
||||
export interface SignupFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly inviteCode?: string
|
||||
readonly onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function SignupForm(props: SignupFormProps) {
|
||||
const { client } = props
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await client.signup({
|
||||
email,
|
||||
password,
|
||||
clientId: client.tenant.clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
inviteCode: props.inviteCode,
|
||||
})
|
||||
if (res.error) setError(res.error)
|
||||
else if (res.redirectUrl) window.location.href = res.redirectUrl
|
||||
else props.onSuccess?.()
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-signup-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Canonical A2P 10DLC consent copy. This EXACT disclosure is reused at every
|
||||
// point where Hanzo collects or uses a phone number for messaging. It MUST stay
|
||||
// verbatim-identical to the public opt-in page (hanzo.ai/sms-opt-in,
|
||||
// `SMS_CONSENT_TEXT`) and to the IAM phone-login UI — Twilio / carrier campaign
|
||||
// review compares the wording across surfaces. One string, reused everywhere.
|
||||
export const SMS_CONSENT_TEXT =
|
||||
'I agree to receive text messages (SMS) from Hanzo AI at the number provided, ' +
|
||||
'including one-time passcodes and two-factor authentication, account and security ' +
|
||||
'alerts, and transactional notifications. Message frequency varies. Message and data ' +
|
||||
'rates may apply. Reply STOP to opt out at any time, or HELP for help. Consent is not ' +
|
||||
'a condition of any purchase.'
|
||||
|
||||
const TERMS_URL = 'https://hanzo.ai/terms'
|
||||
const PRIVACY_URL = 'https://hanzo.ai/privacy'
|
||||
|
||||
/**
|
||||
* SMS consent disclosure shown beneath any phone/SMS surface (disclosure-only,
|
||||
* no checkbox — the portal's SMS step is reached only after the user already
|
||||
* provided/opted-in their number in IAM, and after a code was sent).
|
||||
*
|
||||
* For a phone-number COLLECTION surface that requires affirmative opt-in (A2P),
|
||||
* gate the submit on a checkbox and reuse {@link SMS_CONSENT_TEXT} — see the IAM
|
||||
* SignupPage `SmsConsentCheckbox`. The portal does not yet render its own phone
|
||||
* field (collection happens in the IAM-hosted UI), so only the notice is used
|
||||
* here today.
|
||||
*/
|
||||
export function SmsConsentNotice() {
|
||||
return (
|
||||
<div className="hanzo-id-sms-consent" role="note">
|
||||
<p>{SMS_CONSENT_TEXT}</p>
|
||||
<p className="hanzo-id-sms-consent-links">
|
||||
By continuing, you agree to our{' '}
|
||||
<a href={TERMS_URL} target="_blank" rel="noreferrer">Terms of Service</a> and{' '}
|
||||
<a href={PRIVACY_URL} target="_blank" rel="noreferrer">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import type { Chain } from '@hanzo/id-connect'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { AppProvider } from '../types'
|
||||
import { startProviderLogin, isHoppableProvider, matchProviderHint } from '../social'
|
||||
import { loginWithWalletChain, ENABLED_WALLET_CHAINS, WALLET_CHAIN_LABELS } from '../web3'
|
||||
import { GitHubIcon, GitLabIcon, GoogleIcon, WalletIcon } from './icons'
|
||||
import { Divider } from './Divider'
|
||||
|
||||
/**
|
||||
* Social + multi-chain wallet sign-in buttons.
|
||||
*
|
||||
* The enabled set is read live from `/v1/iam/get-app-login` (via
|
||||
* `client.getAppLogin()`) — the canonical source of truth that mirrors the
|
||||
* per-app provider config in `init_data.json`. We render ONLY providers IAM
|
||||
* holds real credentials for (`AppProvider.configured`); a provider seeded with
|
||||
* placeholder creds is hidden so its button never dead-ends, and reappears once
|
||||
* real creds land. When the config is unreadable we render none.
|
||||
*
|
||||
* Two sign-in shapes, decomplected:
|
||||
* - OAuth (github/google) → the provider "hop" (`startProviderLogin`): redirect
|
||||
* straight to the provider; the IAM backend `/callback` exchange completes it.
|
||||
* - Web3/wallet → native Sign-In-With-X (`loginWithWalletChain`): connect a
|
||||
* wallet with `@hanzo/id-connect` (no WalletConnect, no projectId), sign the
|
||||
* IAM-minted challenge, POST `/v1/iam/web3/verify`, then follow the SAME
|
||||
* redirect the password flow returns. The wallet provider expands into one
|
||||
* button per ENABLED chain.
|
||||
*/
|
||||
export interface SocialButtonsProps {
|
||||
readonly client: AuthClient
|
||||
/** Override the OAuth client_id (e.g. a downstream app's id). */
|
||||
readonly clientIdOverride?: string
|
||||
/** "signin" (default) or "signup" — only changes button copy. */
|
||||
readonly intent?: 'signin' | 'signup'
|
||||
/**
|
||||
* Downstream app's `redirect_uri`, if this portal is mid-flow for another
|
||||
* app. OAuth sign-in returns to the portal's own `/callback` (stashed here
|
||||
* and forwarded by `Callback`); wallet sign-in threads it straight into the
|
||||
* verify POST so IAM mints the auth-code redirect back to the app. Absent →
|
||||
* a bare portal sign-in that lands on onboarding.
|
||||
*/
|
||||
readonly postLoginRedirect?: string
|
||||
/**
|
||||
* A `provider_hint` from the authorize query — the console passes
|
||||
* `?provider_hint=provider-github` when a user clicks "Continue with GitHub"
|
||||
* over there. When set, once the app config resolves this component launches
|
||||
* the matching provider's hop straight away (the SAME hop the button runs) and
|
||||
* renders NOTHING: it is headless, a pure side-effect, so the caller shows its
|
||||
* own "signing you in" state. If the hint matches no configured provider,
|
||||
* `onAutoStartResolved(false)` fires so the caller can fall back to the form.
|
||||
*/
|
||||
readonly autoStart?: string
|
||||
/**
|
||||
* Called once, in `autoStart` mode, after the app config resolves: `true` when
|
||||
* the hinted provider launched, `false` when the hint matched nothing (so the
|
||||
* caller can drop to the interactive form instead of a blank redirect state).
|
||||
*/
|
||||
readonly onAutoStartResolved?: (started: boolean) => void
|
||||
}
|
||||
|
||||
interface ProviderMeta {
|
||||
readonly key: string
|
||||
readonly label: string
|
||||
readonly Icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
}
|
||||
|
||||
/** Display metadata for the providers the portal knows how to render. */
|
||||
const PROVIDER_META: Record<string, ProviderMeta> = {
|
||||
github: { key: 'github', label: 'GitHub', Icon: GitHubIcon },
|
||||
gitlab: { key: 'gitlab', label: 'GitLab', Icon: GitLabIcon },
|
||||
google: { key: 'google', label: 'Google', Icon: GoogleIcon },
|
||||
web3: { key: 'web3', label: 'Wallet', Icon: WalletIcon },
|
||||
}
|
||||
|
||||
/** Canonical render order. */
|
||||
const ORDER = ['github', 'gitlab', 'google', 'web3']
|
||||
|
||||
interface Resolved {
|
||||
/** IAM application name (for the provider-hop state). */
|
||||
readonly application: string
|
||||
/** Configured + renderable providers, keyed by their normalized key. */
|
||||
readonly providers: Record<string, AppProvider>
|
||||
}
|
||||
|
||||
export function SocialButtons({
|
||||
client,
|
||||
clientIdOverride,
|
||||
intent = 'signin',
|
||||
postLoginRedirect,
|
||||
autoStart,
|
||||
onAutoStartResolved,
|
||||
}: SocialButtonsProps) {
|
||||
const [resolved, setResolved] = useState<Resolved | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyChain, setBusyChain] = useState<Chain | null>(null)
|
||||
const autoStarted = useRef(false)
|
||||
|
||||
// The provider "hop": persist the downstream target across the IAM round-trip
|
||||
// (`Callback` reads it back and forwards tokens there, else lands on
|
||||
// onboarding), then redirect to the provider. Shared by the button click and
|
||||
// the `autoStart` auto-launch so both take the identical path.
|
||||
function hop(application: string, provider: AppProvider) {
|
||||
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
|
||||
else sessionStorage.removeItem('post_login_redirect')
|
||||
startProviderLogin(
|
||||
{
|
||||
application,
|
||||
providerName: provider.name,
|
||||
type: provider.type,
|
||||
clientId: provider.clientId,
|
||||
scopes: provider.scopes,
|
||||
// Interactive social login is ALWAYS find-or-create-login — IAM runs that
|
||||
// branch only under `signup` (auth.go: existing 3rd-party identity → sign
|
||||
// in, else create). `signin` is the account-LINK branch, which needs a
|
||||
// live session and errors "user doesn't exist" on a fresh "Continue with
|
||||
// GitHub". So both the sign-in and sign-up pages use `signup` here; the
|
||||
// intent only changes button copy, never the IAM method.
|
||||
method: 'signup',
|
||||
},
|
||||
// The shared OAuth client is registered against the IAM backend's
|
||||
// /callback (not this brand host), so the hop must return there or the
|
||||
// provider rejects the redirect_uri. Catalog-driven; defaults to host.
|
||||
client.tenant.oauthCallbackOrigin,
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
// Read the app config against the DOWNSTREAM app's own redirect_uri (carried
|
||||
// on the authorize query in the SSO flow), not the portal's /callback — IAM
|
||||
// validates it against the app's registered list, and a cross-app clientId
|
||||
// (e.g. console's `hanzo-cloud` viewed from hanzo.id) does NOT register the
|
||||
// portal callback, so hardcoding it drops the whole response and no social
|
||||
// resolves. Absent (bare portal / device flow) → getAppLogin defaults it.
|
||||
const oidcRedirectUri =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('redirect_uri') ?? undefined
|
||||
: undefined
|
||||
client
|
||||
.getAppLogin(clientIdOverride, oidcRedirectUri)
|
||||
.then((app) => {
|
||||
if (cancelled) return
|
||||
if (!app) {
|
||||
// Can't read the app config → render no social rather than risk a
|
||||
// dead-end button. Password / email-code still render.
|
||||
setResolved({ application: '', providers: {} })
|
||||
onAutoStartResolved?.(false)
|
||||
return
|
||||
}
|
||||
const want = intent === 'signup' ? (p: AppProvider) => p.canSignUp : (p: AppProvider) => p.canSignIn
|
||||
// Render ONLY providers IAM actually holds credentials for. A provider
|
||||
// with placeholder/empty creds would dead-end the OAuth redirect, so we
|
||||
// hide it; it reappears automatically once real creds are seeded. Web3
|
||||
// needs no IAM-side OAuth credential (the wallet IS the credential), so
|
||||
// it renders whenever the app enables it.
|
||||
const providers: Record<string, AppProvider> = {}
|
||||
for (const p of app.providers) {
|
||||
const enabled = p.key === 'web3' ? want(p) : want(p) && p.configured
|
||||
if (enabled && p.key in PROVIDER_META) providers[p.key] = p
|
||||
}
|
||||
setResolved({ application: app.application, providers })
|
||||
// A client that already knows the provider (console `?provider_hint=…`)
|
||||
// launches it straight away — the SAME hop the button runs, so a click
|
||||
// over there lands directly in the provider flow, no second press and no
|
||||
// bounce through this login page.
|
||||
if (autoStart && !autoStarted.current) {
|
||||
autoStarted.current = true
|
||||
const target = matchProviderHint(Object.values(providers), autoStart)
|
||||
if (target && isHoppableProvider(target.type)) {
|
||||
onAutoStartResolved?.(true)
|
||||
hop(app.application, target)
|
||||
} else {
|
||||
// Hint names a provider this app doesn't offer → let the caller show
|
||||
// the form rather than dead-end on a blank "signing you in".
|
||||
onAutoStartResolved?.(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setResolved({ application: '', providers: {} })
|
||||
onAutoStartResolved?.(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Run once on mount: getAppLogin is a one-shot and autoStart is fixed for
|
||||
// the life of the page; the ref guards the hop against a double-fire.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, clientIdOverride, intent])
|
||||
|
||||
// In autoStart mode the component is headless — it exists only to run the hop
|
||||
// above; the caller renders its own "signing you in" state. Render nothing.
|
||||
if (autoStart) return null
|
||||
if (resolved === null) return null // resolving — render nothing rather than flicker
|
||||
const ordered = ORDER.filter((k) => k in resolved.providers)
|
||||
if (ordered.length === 0) return null
|
||||
|
||||
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
|
||||
|
||||
function startOAuth(provider: AppProvider) {
|
||||
setError(null)
|
||||
hop(resolved!.application, provider)
|
||||
}
|
||||
|
||||
async function startWallet(chain: Chain) {
|
||||
setError(null)
|
||||
setBusyChain(chain)
|
||||
try {
|
||||
const sp = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
|
||||
const res = await loginWithWalletChain(client, chain, {
|
||||
clientId: clientIdOverride,
|
||||
redirectUri: postLoginRedirect,
|
||||
state: sp.get('state') ?? undefined,
|
||||
nonce: sp.get('nonce') ?? undefined,
|
||||
codeChallenge: sp.get('code_challenge') ?? undefined,
|
||||
codeChallengeMethod: (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined,
|
||||
})
|
||||
if (res.error) {
|
||||
setError(res.error)
|
||||
} else if (res.redirectUrl) {
|
||||
// Same post-login redirect the password flow performs.
|
||||
window.location.href = res.redirectUrl
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setBusyChain(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hanzo-id-social">
|
||||
{ordered.map((k) => {
|
||||
const provider = resolved.providers[k]!
|
||||
// Web3 expands into one connect button per ENABLED chain; OAuth
|
||||
// providers render a single hop button.
|
||||
if (k === 'web3') {
|
||||
return ENABLED_WALLET_CHAINS.map((chain) => (
|
||||
<button
|
||||
key={`web3-${chain}`}
|
||||
type="button"
|
||||
className="hanzo-id-social-btn"
|
||||
data-provider="web3"
|
||||
data-chain={chain}
|
||||
disabled={busyChain !== null}
|
||||
onClick={() => startWallet(chain)}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span>
|
||||
{busyChain === chain ? 'Connecting…' : `${verb} with ${WALLET_CHAIN_LABELS[chain]}`}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
}
|
||||
if (!isHoppableProvider(provider.type)) return null
|
||||
const meta = PROVIDER_META[k]!
|
||||
const { Icon } = meta
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="hanzo-id-social-btn"
|
||||
data-provider={k}
|
||||
onClick={() => startOAuth(provider)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{verb} with {meta.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
</div>
|
||||
{/* The "or" separator belongs WITH the social block — render it only when
|
||||
there are buttons, so it never dangles above the password form when
|
||||
no providers are configured. */}
|
||||
<Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Minimal inline provider marks. Brand-neutral, currentColor-driven, no
|
||||
* external icon dependency. One 18px glyph per supported sign-in provider.
|
||||
*/
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
const base = (props: SVGProps<SVGSVGElement>) => ({
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: '0 0 24 24',
|
||||
'aria-hidden': true,
|
||||
focusable: false as const,
|
||||
...props,
|
||||
})
|
||||
|
||||
export function GitHubIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="currentColor">
|
||||
<path d="M12 .5C5.73.5.5 5.73.5 12a11.5 11.5 0 0 0 7.86 10.92c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.53-1.34-1.3-1.7-1.3-1.7-1.05-.72.08-.7.08-.7 1.17.08 1.78 1.2 1.78 1.2 1.04 1.78 2.73 1.27 3.4.97.1-.75.4-1.27.73-1.56-2.56-.29-5.26-1.28-5.26-5.7 0-1.26.45-2.29 1.2-3.1-.12-.3-.52-1.48.11-3.08 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.8 0c2.2-1.5 3.17-1.18 3.17-1.18.63 1.6.23 2.78.11 3.08.75.81 1.2 1.84 1.2 3.1 0 4.43-2.7 5.4-5.28 5.69.42.36.79 1.07.79 2.16v3.2c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path fill="#4285F4" d="M23.52 12.27c0-.82-.07-1.6-.21-2.36H12v4.46h6.46a5.52 5.52 0 0 1-2.4 3.62v3h3.88c2.27-2.09 3.58-5.17 3.58-8.72Z" />
|
||||
<path fill="#34A853" d="M12 24c3.24 0 5.96-1.08 7.94-2.91l-3.88-3c-1.08.72-2.45 1.15-4.06 1.15-3.12 0-5.77-2.11-6.71-4.95H1.28v3.1A12 12 0 0 0 12 24Z" />
|
||||
<path fill="#FBBC05" d="M5.29 14.29A7.2 7.2 0 0 1 4.91 12c0-.8.14-1.57.38-2.29v-3.1H1.28A12 12 0 0 0 0 12c0 1.94.46 3.77 1.28 5.39l4.01-3.1Z" />
|
||||
<path fill="#EA4335" d="M12 4.76c1.76 0 3.34.61 4.58 1.8l3.43-3.43A11.99 11.99 0 0 0 12 0 12 12 0 0 0 1.28 6.61l4.01 3.1C6.23 6.87 8.88 4.76 12 4.76Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitLabIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="currentColor">
|
||||
<path d="M23.955 13.587l-1.342-4.135-2.664-8.189a.455.455 0 0 0-.867 0L16.418 9.45H7.582L4.919 1.263a.455.455 0 0 0-.867 0L1.388 9.452-.001 13.587a.924.924 0 0 0 .331 1.023L12 23.054l11.625-8.443a.92.92 0 0 0 .33-1.024" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1H5a2 2 0 0 0-2 2V7Z" />
|
||||
<path d="M3 9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z" />
|
||||
<circle cx="16.5" cy="13" r="1.25" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { LoginForm } from './LoginForm'
|
||||
export { SignupForm } from './SignupForm'
|
||||
export { ForgotForm } from './ForgotForm'
|
||||
export { OTPForm } from './OTPForm'
|
||||
export { MfaEnrollForm, type MfaEnrollFormProps } from './MfaEnrollForm'
|
||||
export { SmsConsentNotice, SMS_CONSENT_TEXT } from './SmsConsent'
|
||||
export { SocialButtons, type SocialButtonsProps } from './SocialButtons'
|
||||
export { Divider } from './Divider'
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Multi-chain wallet login orchestration tests — pure, no network, no wallet
|
||||
* libs. Run with: pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Locks the connect→nonce→sign→verify→redirect contract against
|
||||
* `hanzoai/iam` controllers/web3_auth.go using a capturing fetch double and a
|
||||
* fake signer (the injectable `WalletSigner` seam — the real one lazy-loads the
|
||||
* wallet libs, which this test never touches).
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import {
|
||||
loginWithWalletChain,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletSigner,
|
||||
} from './web3.ts'
|
||||
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
|
||||
function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
|
||||
return {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-id',
|
||||
appName: 'hanzo-id',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
oauthCallbackOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const CHALLENGE: LoginChallenge = {
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id',
|
||||
nonce: 'NONCE1234567890A',
|
||||
issuedAt: '2026-01-01T00:00:00.000Z',
|
||||
expirationTime: '2026-01-01T00:10:00.000Z',
|
||||
version: '1',
|
||||
}
|
||||
|
||||
/**
|
||||
* Capturing fetch: returns the minted CHALLENGE for the nonce GET, and a canned
|
||||
* IAM "ok" body for the verify POST (an auth code in `data`, like /v1/iam/login).
|
||||
* Records every call so the test can assert the exact wire shape.
|
||||
*/
|
||||
function capturingFetch(verifyData: unknown = 'AUTHCODE') {
|
||||
const calls: { url: string; method: string; body: Record<string, unknown> }[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
const method = init?.method ?? 'GET'
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, method, body })
|
||||
if (url.includes('/v1/iam/web3/nonce')) {
|
||||
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
// verify
|
||||
return new Response(JSON.stringify({ status: 'ok', data: verifyData }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
/** Fake signer: records the (chain, challenge) it was handed, returns a proof. */
|
||||
function fakeSigner() {
|
||||
const seen: { chain: Chain; challenge: LoginChallenge }[] = []
|
||||
const proof: SignedProof = {
|
||||
chain: 'evm',
|
||||
scheme: 'secp256k1-eip191',
|
||||
address: '0xabc0000000000000000000000000000000000def',
|
||||
message: 'rendered CAIP-122 message',
|
||||
signature: '0xdeadbeef',
|
||||
}
|
||||
const sign: WalletSigner = async (chain, challenge) => {
|
||||
seen.push({ chain, challenge })
|
||||
return { ...proof, chain }
|
||||
}
|
||||
return { seen, sign, proof }
|
||||
}
|
||||
|
||||
test('fetches the nonce for the chosen chain, signs the returned challenge, POSTs the proof', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const { seen, sign } = fakeSigner()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
|
||||
// 1) nonce GET first, scoped to the chain, on the brand's own iamUrl.
|
||||
assert.equal(calls[0]!.method, 'GET')
|
||||
assert.match(calls[0]!.url, /^https:\/\/hanzo\.id\/v1\/iam\/web3\/nonce\?chain=evm$/)
|
||||
|
||||
// 2) the server challenge was handed to the signer (nonce/domain/uri/times
|
||||
// intact — the server-minted, single-use values the proof must bind).
|
||||
assert.equal(seen.length, 1)
|
||||
assert.equal(seen[0]!.chain, 'evm')
|
||||
assert.equal(seen[0]!.challenge.nonce, CHALLENGE.nonce)
|
||||
assert.equal(seen[0]!.challenge.domain, CHALLENGE.domain)
|
||||
assert.equal(seen[0]!.challenge.uri, CHALLENGE.uri)
|
||||
assert.equal(seen[0]!.challenge.issuedAt, CHALLENGE.issuedAt)
|
||||
assert.equal(seen[0]!.challenge.expirationTime, CHALLENGE.expirationTime)
|
||||
|
||||
// 3) the proof + routing was POSTed to verify.
|
||||
const verify = calls[1]!
|
||||
assert.equal(verify.method, 'POST')
|
||||
assert.match(verify.url, /\/v1\/iam\/web3\/verify$/)
|
||||
assert.equal(verify.body.chain, 'evm')
|
||||
assert.equal(verify.body.scheme, 'secp256k1-eip191')
|
||||
assert.equal(verify.body.address, '0xabc0000000000000000000000000000000000def')
|
||||
assert.equal(verify.body.message, 'rendered CAIP-122 message')
|
||||
assert.equal(verify.body.signature, '0xdeadbeef')
|
||||
// routing fields the controller needs.
|
||||
assert.equal(verify.body.application, 'hanzo-id')
|
||||
assert.equal(verify.body.method, 'login')
|
||||
assert.equal(verify.body.clientId, 'hanzo-id')
|
||||
// bare sign-in (no downstream redirectUri) → type=login.
|
||||
assert.equal(verify.body.type, 'login')
|
||||
|
||||
// 4) bare sign-in lands on onboarding — same destination as the password flow.
|
||||
assert.equal(res.redirectUrl, '/onboarding')
|
||||
assert.equal(res.error, undefined)
|
||||
})
|
||||
|
||||
test('SSO flow (downstream redirectUri) sends type=code and returns the app redirect with the minted code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch('CODE_XYZ')
|
||||
const { sign } = fakeSigner()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
const res = await loginWithWalletChain(
|
||||
client,
|
||||
'evm',
|
||||
{ redirectUri: 'https://console.hanzo.ai/auth/iam/callback', state: 'rp123', clientId: 'hanzo-console' },
|
||||
fetchImpl,
|
||||
sign,
|
||||
)
|
||||
|
||||
const verify = calls[1]!
|
||||
assert.equal(verify.body.type, 'code')
|
||||
assert.equal(verify.body.redirectUri, 'https://console.hanzo.ai/auth/iam/callback')
|
||||
assert.equal(verify.body.state, 'rp123')
|
||||
assert.equal(verify.body.clientId, 'hanzo-console')
|
||||
assert.equal(
|
||||
res.redirectUrl,
|
||||
'https://console.hanzo.ai/auth/iam/callback?code=CODE_XYZ&state=rp123',
|
||||
)
|
||||
})
|
||||
|
||||
test('disabled chains are not offered and fail closed without any network or signer call', async () => {
|
||||
// The stub-verifier chains must NOT be in the enabled set...
|
||||
for (const stub of ['ton', 'xrp', 'bitcoin'] as const) {
|
||||
assert.equal(ENABLED_WALLET_CHAINS.includes(stub), false, `${stub} must be disabled`)
|
||||
}
|
||||
// ...and only the production-verifier chains are.
|
||||
assert.deepEqual([...ENABLED_WALLET_CHAINS], ['evm', 'solana'])
|
||||
// every enabled chain has a render label.
|
||||
for (const c of ENABLED_WALLET_CHAINS) assert.ok(WALLET_CHAIN_LABELS[c])
|
||||
|
||||
// Calling a disabled chain returns an error and touches neither fetch nor signer.
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
let signed = false
|
||||
const sign: WalletSigner = async () => {
|
||||
signed = true
|
||||
throw new Error('signer must not run for a disabled chain')
|
||||
}
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'ton', {}, fetchImpl, sign)
|
||||
assert.match(res.error ?? '', /not enabled/)
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(signed, false)
|
||||
})
|
||||
|
||||
test('a wallet rejection surfaces as { error } (not a throw), and verify is never called', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const sign: WalletSigner = async () => {
|
||||
throw new Error('User rejected the request')
|
||||
}
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.error, 'User rejected the request')
|
||||
// nonce was fetched (1 call) but verify was NOT (no 2nd call).
|
||||
assert.equal(calls.length, 1)
|
||||
assert.match(calls[0]!.url, /\/v1\/iam\/web3\/nonce/)
|
||||
})
|
||||
|
||||
test('an IAM verify error is returned as { error }', async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
calls.push(url)
|
||||
if (url.includes('/web3/nonce')) {
|
||||
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), { status: 200 })
|
||||
}
|
||||
return new Response(JSON.stringify({ status: 'error', msg: 'web3: bad signature' }), { status: 200 })
|
||||
}
|
||||
const { sign } = fakeSigner()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.error, 'web3: bad signature')
|
||||
assert.equal(res.redirectUrl, undefined)
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Multi-chain wallet Sign-In-With-X — the ONE client-side orchestration.
|
||||
*
|
||||
* Decomplected: connect+sign is the BROWSER's job (native `@hanzo/id-connect`
|
||||
* connectors — viem / @solana injected wallets, no WalletConnect, no projectId),
|
||||
* verify is the SERVER's job (IAM `POST /v1/iam/web3/verify`, which runs the same
|
||||
* `walletconnect.VerifyProof` the connectors target). This module ties the two
|
||||
* into a single call so the UI only picks a chain and follows the redirect.
|
||||
*
|
||||
* client: nonce ─► connect ─► signLogin(challenge) ─► SignedProof
|
||||
* ─► POST verify ─► IAM signs in ─► same redirect the password flow uses
|
||||
*
|
||||
* Wire contract (verified against `hanzoai/iam` controllers/web3_auth.go):
|
||||
* GET {iamUrl}/v1/iam/web3/nonce?chain=<c>&address=<a>
|
||||
* → {status:'ok', data:{domain,uri,statement,nonce,issuedAt,
|
||||
* expirationTime,version}} (a LoginChallenge)
|
||||
* POST {iamUrl}/v1/iam/web3/verify body = SignedProof + routing fields
|
||||
* → same success shape as /v1/iam/login (auth code | session cookie).
|
||||
*/
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
|
||||
import type { AuthClient } from './client'
|
||||
import type { LoginResponse } from './types'
|
||||
|
||||
/**
|
||||
* Connect a wallet on `chain` and sign `challenge`, returning the proof. The
|
||||
* single seam between this orchestrator and the browser wallet libs: the default
|
||||
* lazy-loads `@hanzo/id-connect/login` (so importing this module never pulls
|
||||
* viem/sats-connect, and the wallet bundle is code-split until first use); tests
|
||||
* inject a fake. One signature, one way.
|
||||
*/
|
||||
export type WalletSigner = (chain: Chain, challenge: LoginChallenge) => Promise<SignedProof>
|
||||
|
||||
const defaultSigner: WalletSigner = async (chain, challenge) => {
|
||||
const { loginWithWallet } = await import('@hanzo/id-connect/login')
|
||||
const { proof } = await loginWithWallet({ chain, challenge })
|
||||
return proof
|
||||
}
|
||||
|
||||
/**
|
||||
* Chains whose wallet login is ENABLED. The server-side verifiers for EVM and
|
||||
* Solana are production-grade; TON / XRP / Bitcoin verifiers are still stubs and
|
||||
* would fail closed, so they are NOT offered. Adding a chain later is one line
|
||||
* here (once its Go verifier is real). Single source of truth — the UI renders
|
||||
* exactly this set.
|
||||
*/
|
||||
export const ENABLED_WALLET_CHAINS: readonly Chain[] = ['evm', 'solana']
|
||||
|
||||
/** Display label per chain, shown on each connect button. */
|
||||
export const WALLET_CHAIN_LABELS: Record<Chain, string> = {
|
||||
evm: 'Ethereum / EVM',
|
||||
solana: 'Solana',
|
||||
bitcoin: 'Bitcoin',
|
||||
ton: 'TON',
|
||||
xrp: 'XRP',
|
||||
}
|
||||
|
||||
/** Routing context for the verify POST — exactly what the password flow carries. */
|
||||
export interface WalletLoginContext {
|
||||
/** Override the OAuth client_id (downstream app); defaults to tenant.clientId. */
|
||||
readonly clientId?: string
|
||||
/** Downstream app `redirect_uri`; presence flips the flow to the auth-code (SSO) path. */
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
/** OIDC nonce from the downstream authorize request, echoed into the minted code. */
|
||||
readonly nonce?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a wallet on `chain`, sign the IAM-minted challenge, and verify it —
|
||||
* resolving to the SAME {@link LoginResponse} the password login returns (so the
|
||||
* caller reuses one redirect path). Throws only on a programming/transport error
|
||||
* the UI can't act on; expected failures (user rejects, bad signature) come back
|
||||
* as `{ error }`.
|
||||
*
|
||||
* `client.tenant.iamUrl` is the fetch base (HIP-0111 host-relative — the brand's
|
||||
* own `*.id` host), matching every other AuthClient call.
|
||||
*/
|
||||
export async function loginWithWalletChain(
|
||||
client: AuthClient,
|
||||
chain: Chain,
|
||||
ctx: WalletLoginContext = {},
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
sign: WalletSigner = defaultSigner,
|
||||
): Promise<LoginResponse> {
|
||||
const tenant = client.tenant
|
||||
if (!ENABLED_WALLET_CHAINS.includes(chain)) {
|
||||
return { error: `wallet login not enabled for ${chain}` }
|
||||
}
|
||||
|
||||
// 1. Mint the challenge, then connect+sign atomically (the connector
|
||||
// disconnects on failure). The nonce is fetched without an address — the
|
||||
// controller treats (chain,address) as advisory and binds the real address
|
||||
// from the SIGNED message, so there is no second round-trip to scope it.
|
||||
let proof: SignedProof
|
||||
try {
|
||||
const challenge = await fetchNonce(tenant, chain, fetchImpl)
|
||||
proof = await sign(chain, challenge)
|
||||
} catch (err) {
|
||||
return { error: errMessage(err) }
|
||||
}
|
||||
|
||||
// 2. Verify the proof + routing at IAM. Type defaults to "login" (session
|
||||
// cookie) server-side; a downstream redirectUri makes it the code flow.
|
||||
const url = new URL('/v1/iam/web3/verify', tenant.iamUrl)
|
||||
const res = await fetchImpl(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
// routing
|
||||
organization: tenant.loginOrg ?? '',
|
||||
application: tenant.appName,
|
||||
method: 'login',
|
||||
clientId: ctx.clientId ?? tenant.clientId,
|
||||
redirectUri: ctx.redirectUri ?? '',
|
||||
state: ctx.state ?? '',
|
||||
scope: 'openid profile email',
|
||||
type: ctx.redirectUri ? 'code' : 'login',
|
||||
nonce: ctx.nonce ?? '',
|
||||
codeChallenge: ctx.codeChallenge ?? '',
|
||||
codeChallengeMethod: ctx.codeChallengeMethod ?? '',
|
||||
// proof
|
||||
chain: proof.chain,
|
||||
scheme: proof.scheme,
|
||||
address: proof.address,
|
||||
publicKey: proof.publicKey ?? '',
|
||||
message: proof.message,
|
||||
signature: proof.signature,
|
||||
extra: proof.extra ?? {},
|
||||
}),
|
||||
})
|
||||
|
||||
return parseVerifyResponse(res, ctx)
|
||||
}
|
||||
|
||||
/** GET the CAIP-122 challenge for (chain) from IAM; throws on a non-ok payload. */
|
||||
async function fetchNonce(
|
||||
tenant: TenantConfig,
|
||||
chain: Chain,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<LoginChallenge> {
|
||||
const url = new URL('/v1/iam/web3/nonce', tenant.iamUrl)
|
||||
url.searchParams.set('chain', chain)
|
||||
const res = await fetchImpl(url.toString(), { headers: { Accept: 'application/json' } })
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw new Error(`web3 nonce: HTTP ${res.status} non-JSON response`)
|
||||
}
|
||||
if (!res.ok || body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) {
|
||||
throw new Error(typeof body.msg === 'string' ? body.msg : `web3 nonce: HTTP ${res.status}`)
|
||||
}
|
||||
const d = body.data as Record<string, unknown>
|
||||
return {
|
||||
domain: String(d.domain ?? ''),
|
||||
uri: String(d.uri ?? ''),
|
||||
statement: typeof d.statement === 'string' ? d.statement : undefined,
|
||||
nonce: String(d.nonce ?? ''),
|
||||
issuedAt: String(d.issuedAt ?? ''),
|
||||
expirationTime: typeof d.expirationTime === 'string' ? d.expirationTime : undefined,
|
||||
version: typeof d.version === 'string' ? d.version : '1',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape the `/v1/iam/web3/verify` response into a {@link LoginResponse}, mirroring
|
||||
* the password flow's `parseLoginResponse`: auth-code flow → a redirect back to
|
||||
* the downstream app; bare sign-in → land on onboarding.
|
||||
*/
|
||||
async function parseVerifyResponse(
|
||||
res: Response,
|
||||
ctx: WalletLoginContext,
|
||||
): Promise<LoginResponse> {
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
const data = body.data
|
||||
|
||||
// Auth-code (SSO) flow: a downstream redirectUri is present and `data` is the
|
||||
// minted code — hand back a fully-formed redirect to the app.
|
||||
if (ctx.redirectUri && typeof data === 'string' && data.length > 0) {
|
||||
const sep = ctx.redirectUri.includes('?') ? '&' : '?'
|
||||
return {
|
||||
redirectUrl: `${ctx.redirectUri}${sep}code=${encodeURIComponent(data)}&state=${encodeURIComponent(ctx.state ?? '')}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Bare portal sign-in: the IAM session cookie is set; land on onboarding —
|
||||
// identical to the password path so there is one post-login destination.
|
||||
return { redirectUrl: '/onboarding' }
|
||||
}
|
||||
|
||||
function errMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message
|
||||
return String(err)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# @luxwallet/connect
|
||||
|
||||
Multi-chain wallet connect + **Sign-In-With-X** for **EVM, Solana, Bitcoin, TON, XRP**.
|
||||
|
||||
One vocabulary, one canonical login message ([CAIP-122](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md)),
|
||||
one verifier. **MIT licensed — zero GPL.** This is the clean wallet stack; the
|
||||
Uniswap-derived GPL bones stay quarantined in `luxfi/exchange`.
|
||||
|
||||
## Why
|
||||
|
||||
`@luxfi/wallet` (Uniswap "Universe" fork) is GPL-3.0 and EVM-only. This package
|
||||
is a from-scratch, permissively-licensed connector that any Hanzo/Lux/Zoo/Pars
|
||||
surface — `hanzo.id` login, the browser extension, web and mobile apps — can use
|
||||
to authenticate a wallet on **any** supported chain.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
connect(chain) ─► Account ─► signLogin(challenge) ─► SignedProof ─► verifyProof()
|
||||
(browser, per-chain connector) (server, one pure fn)
|
||||
```
|
||||
|
||||
- **`caip122.ts`** — render/parse the canonical login message. `build ∘ parse` round-trips.
|
||||
- **`verify.ts`** — `verifyProof(proof, expected)`: parse → enforce domain/nonce/time → dispatch to the per-chain crypto verifier. Pure, fails closed, never throws.
|
||||
- **`<chain>/`** — per-chain connector (browser) + verifier (pure crypto).
|
||||
- **`go/walletconnect`** — Go port of `verifyProof`, imported by Hanzo IAM so the server verifies identically.
|
||||
|
||||
## Chain support
|
||||
|
||||
| Chain | Connect lib (license) | Login proof | Connector | Verifier |
|
||||
|-------|-----------------------|-------------|-----------|----------|
|
||||
| EVM | `viem` (MIT) — EIP-6963 / `window.ethereum` | EIP-191 `personal_sign` | ✅ `evm/connect.ts` | ✅ secp256k1 recover |
|
||||
| Solana | injected provider (Phantom/Solflare/Backpack) | ed25519 `signMessage` | ✅ `solana/connect.ts` | ✅ ed25519 |
|
||||
| Bitcoin | `sats-connect` (MIT) — Xverse/Leather/Unisat | BIP-322 | ✅ `bitcoin/connect.ts` | ✅ legacy + BIP-322 |
|
||||
| TON | `@tonconnect/sdk` (Apache-2.0) | `ton_proof` | ✅ `ton/connect.ts` | ✅ ed25519 envelope |
|
||||
| XRP | `@crossmarkio/sdk` (MIT) — Crossmark | `signInAndWait` | ✅ `xrp/connect.ts` | ✅ secp256k1 + ed25519 |
|
||||
|
||||
All connect libs are MIT/Apache/ISC — **no GPL anywhere** in the dependency tree.
|
||||
GemWallet is intentionally not wired: its only client, `@gemwallet/api`, ships
|
||||
under a custom dual license requiring GemWallet's permission for public/commercial
|
||||
use — incompatible with the MIT/Apache/ISC-only rule. Crossmark covers both XRPL
|
||||
key types, so the XRP path is complete without it.
|
||||
|
||||
### Architecture: server verify never pulls a wallet lib
|
||||
|
||||
The wallet libraries are **optional peer dependencies**. The server-side
|
||||
`verifyProof` path imports only `@noble/*` + `bs58`:
|
||||
|
||||
```ts
|
||||
import { verifyProof } from '@luxwallet/connect/verify'; // zero wallet libs
|
||||
import { buildSiwxMessage } from '@luxwallet/connect/caip122'; // zero deps
|
||||
```
|
||||
|
||||
Connectors live behind separate entrypoints, so a server bundle stays clean:
|
||||
|
||||
```ts
|
||||
import { loginWithWallet, getConnector } from '@luxwallet/connect/connectors';
|
||||
import { EvmConnector } from '@luxwallet/connect/evm/connect';
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
```ts
|
||||
// Server: mint a challenge
|
||||
import { newChallenge, verifyProof } from '@luxwallet/connect';
|
||||
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login' });
|
||||
// → store challenge.nonce, send challenge to the client
|
||||
|
||||
// Server: verify what comes back
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: challenge.nonce });
|
||||
if (res.ok) { /* res.address is authenticated on res.chain */ }
|
||||
```
|
||||
|
||||
```ts
|
||||
// Client (browser): connect a wallet and sign the challenge in one call.
|
||||
import { loginWithWallet } from '@luxwallet/connect/connectors';
|
||||
|
||||
const { account, proof } = await loginWithWallet({ chain: 'evm', challenge });
|
||||
// → POST `proof` to the server, which calls verifyProof(proof, { domain, nonce }).
|
||||
|
||||
// Or drive a connector directly:
|
||||
import { getConnector } from '@luxwallet/connect/connectors';
|
||||
const c = getConnector('solana');
|
||||
const acct = await c.connect(); // provider.connect()
|
||||
const p = await c.signLogin(acct, challenge); // ed25519 signMessage → SignedProof
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm test # vitest — crypto verifiers run against generated keypairs
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © Lux Industries Inc.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@hanzo/id-connect",
|
||||
"version": "0.1.0",
|
||||
"description": "Multi-chain wallet connect + Sign-In-With-X (EVM, Solana, Bitcoin, TON, XRP). Vendored from luxwallet/connect (MIT).",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./verify": "./src/verify.ts",
|
||||
"./caip122": "./src/caip122.ts",
|
||||
"./connectors": "./src/connectors.ts",
|
||||
"./login": "./src/login.ts",
|
||||
"./evm/connect": "./src/evm/connect.ts",
|
||||
"./solana/connect": "./src/solana/connect.ts",
|
||||
"./bitcoin/connect": "./src/bitcoin/connect.ts",
|
||||
"./ton/connect": "./src/ton/connect.ts",
|
||||
"./xrp/connect": "./src/xrp/connect.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.6.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"bs58": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@crossmarkio/sdk": "^0.4.0",
|
||||
"@tonconnect/sdk": "^4.0.0",
|
||||
"sats-connect": "^4.2.1",
|
||||
"viem": "^2.53.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Bitcoin verifier tests.
|
||||
*
|
||||
* Coverage:
|
||||
* • Legacy "Bitcoin Signed Message" (recoverable ECDSA) over a real CAIP-122
|
||||
* login message — for P2PKH, P2WPKH and P2TR (BIP-86) addresses.
|
||||
* • BIP-322 "simple" for P2WPKH (ECDSA / BIP-143) and P2TR key-path
|
||||
* (Schnorr / BIP-341).
|
||||
* • Tamper / wrong-address / wrong-type negatives (fail closed).
|
||||
* • Anchors: the BIP-322 message hash + to_spend txid + P2WPKH derivation
|
||||
* are pinned to the official Bitcoin Core BIP-322 test vectors, so the
|
||||
* sighash construction is verified against a known-answer source rather
|
||||
* than only self-consistently.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { secp256k1, schnorr } from '@noble/curves/secp256k1';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { ripemd160 } from '@noble/hashes/legacy';
|
||||
import { verifyBitcoin } from '../bitcoin/verify.js';
|
||||
import { encodeSegwitAddress } from '../bitcoin/bech32.js';
|
||||
import { base58checkEncode } from '../bitcoin/base58check.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { utf8ToBytes, concatBytes, bytesToHex } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// ── local crypto helpers (independent of the verifier internals) ─────────────
|
||||
|
||||
const enc = (s: string) => utf8ToBytes(s);
|
||||
const sha256d = (b: Uint8Array) => sha256(sha256(b));
|
||||
const hash160 = (b: Uint8Array) => ripemd160(sha256(b));
|
||||
|
||||
function taggedHash(tag: string, ...m: Uint8Array[]): Uint8Array {
|
||||
const t = sha256(enc(tag));
|
||||
return sha256(concatBytes(t, t, ...m));
|
||||
}
|
||||
|
||||
function compactSize(n: number): Uint8Array {
|
||||
if (n < 0xfd) return new Uint8Array([n]);
|
||||
if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >>> 8) & 0xff]);
|
||||
return new Uint8Array([0xfe, n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
}
|
||||
const u32le = (n: number) =>
|
||||
new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
const u64le = (n: bigint) => {
|
||||
const o = new Uint8Array(8);
|
||||
let v = n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
o[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return o;
|
||||
};
|
||||
const varBytes = (b: Uint8Array) => concatBytes(compactSize(b.length), b);
|
||||
|
||||
function bytesToBig(b: Uint8Array): bigint {
|
||||
let v = 0n;
|
||||
for (const x of b) v = (v << 8n) | BigInt(x);
|
||||
return v;
|
||||
}
|
||||
function bigToXonly(x: bigint): Uint8Array {
|
||||
const o = new Uint8Array(32);
|
||||
let v = x;
|
||||
for (let i = 31; i >= 0; i--) {
|
||||
o[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function toBase64(b: Uint8Array): string {
|
||||
return Buffer.from(b).toString('base64');
|
||||
}
|
||||
|
||||
// Address derivations (must match the verifier's, independently written).
|
||||
function p2pkh(pubkey: Uint8Array): string {
|
||||
return base58checkEncode(0x00, hash160(pubkey));
|
||||
}
|
||||
function p2wpkh(pubCompressed: Uint8Array): string {
|
||||
return encodeSegwitAddress('bc', 0, hash160(pubCompressed))!;
|
||||
}
|
||||
function taprootTweak(internalXonly: Uint8Array): Uint8Array {
|
||||
const n = secp256k1.CURVE.n;
|
||||
const P = schnorr.utils.lift_x(bytesToBig(internalXonly));
|
||||
const t = bytesToBig(taggedHash('TapTweak', internalXonly)) % n;
|
||||
const Q = P.add(secp256k1.Point.BASE.multiply(t));
|
||||
return bigToXonly(Q.toAffine().x);
|
||||
}
|
||||
function p2tr(internalXonly: Uint8Array): { address: string; program: Uint8Array } {
|
||||
const program = taprootTweak(internalXonly);
|
||||
return { address: encodeSegwitAddress('bc', 1, program)!, program };
|
||||
}
|
||||
|
||||
// ── legacy "Bitcoin Signed Message" signer ───────────────────────────────────
|
||||
|
||||
function legacyDigest(message: string): Uint8Array {
|
||||
const msg = enc(message);
|
||||
const magic = enc('\x18Bitcoin Signed Message:\n');
|
||||
return sha256d(concatBytes(magic, compactSize(msg.length), msg));
|
||||
}
|
||||
|
||||
/** Produce a 65-byte [header || r || s] legacy signature. */
|
||||
function signLegacy(priv: Uint8Array, message: string, compressed: boolean): Uint8Array {
|
||||
const digest = legacyDigest(message);
|
||||
const sig = secp256k1.sign(digest, priv);
|
||||
const recid = sig.recovery!;
|
||||
const header = 27 + recid + (compressed ? 4 : 0);
|
||||
return concatBytes(new Uint8Array([header]), sig.toBytes('compact'));
|
||||
}
|
||||
|
||||
// ── BIP-322 simple signers (sign exactly the verifier's sighash) ─────────────
|
||||
|
||||
function toSpendTxid(message: string, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const msgHash = taggedHash('BIP0322-signed-message', enc(message));
|
||||
const scriptSig = concatBytes(new Uint8Array([0x00, 0x20]), msgHash);
|
||||
const ser = concatBytes(
|
||||
u32le(0),
|
||||
compactSize(1),
|
||||
new Uint8Array(32),
|
||||
u32le(0xffffffff),
|
||||
varBytes(scriptSig),
|
||||
u32le(0),
|
||||
compactSize(1),
|
||||
u64le(0n),
|
||||
varBytes(scriptPubKey),
|
||||
u32le(0),
|
||||
);
|
||||
return sha256d(ser);
|
||||
}
|
||||
|
||||
function bip143SighashP2WPKH(txid: Uint8Array, h160: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(txid, u32le(0));
|
||||
const nSequence = u32le(0);
|
||||
const hashPrevouts = sha256d(outpoint);
|
||||
const hashSequence = sha256d(nSequence);
|
||||
const scriptCode = concatBytes(
|
||||
new Uint8Array([0x19, 0x76, 0xa9, 0x14]),
|
||||
h160,
|
||||
new Uint8Array([0x88, 0xac]),
|
||||
);
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
|
||||
const hashOutputs = sha256d(output);
|
||||
const preimage = concatBytes(
|
||||
u32le(0),
|
||||
hashPrevouts,
|
||||
hashSequence,
|
||||
outpoint,
|
||||
scriptCode,
|
||||
u64le(0n),
|
||||
nSequence,
|
||||
hashOutputs,
|
||||
u32le(0),
|
||||
u32le(1),
|
||||
);
|
||||
return sha256d(preimage);
|
||||
}
|
||||
|
||||
function bip341SighashP2TR(txid: Uint8Array, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(txid, u32le(0));
|
||||
const nSequence = u32le(0);
|
||||
const shaPrevouts = sha256(outpoint);
|
||||
const shaAmounts = sha256(u64le(0n));
|
||||
const shaScriptPubkeys = sha256(varBytes(scriptPubKey));
|
||||
const shaSequences = sha256(nSequence);
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
|
||||
const shaOutputs = sha256(output);
|
||||
const sigMsg = concatBytes(
|
||||
new Uint8Array([0x00]), // hash_type SIGHASH_DEFAULT
|
||||
u32le(0),
|
||||
u32le(0),
|
||||
shaPrevouts,
|
||||
shaAmounts,
|
||||
shaScriptPubkeys,
|
||||
shaSequences,
|
||||
shaOutputs,
|
||||
new Uint8Array([0x00]), // spend_type
|
||||
u32le(0), // input index
|
||||
);
|
||||
return taggedHash('TapSighash', concatBytes(new Uint8Array([0x00]), sigMsg));
|
||||
}
|
||||
|
||||
function serializeWitness(items: Uint8Array[]): Uint8Array {
|
||||
let out = compactSize(items.length);
|
||||
for (const it of items) out = concatBytes(out, varBytes(it));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** BIP-322 simple P2WPKH signature (serialized witness [sig||SIGHASH_ALL, pubkey]). */
|
||||
function signBip322P2WPKH(priv: Uint8Array, message: string): Uint8Array {
|
||||
const pub = secp256k1.getPublicKey(priv, true);
|
||||
const h160 = hash160(pub);
|
||||
const spk = concatBytes(new Uint8Array([0x00, 0x14]), h160);
|
||||
const txid = toSpendTxid(message, spk);
|
||||
const sighash = bip143SighashP2WPKH(txid, h160);
|
||||
const sig = secp256k1.sign(sighash, priv, { lowS: true });
|
||||
const der = concatBytes(sig.toBytes('der'), new Uint8Array([0x01])); // SIGHASH_ALL
|
||||
return serializeWitness([der, pub]);
|
||||
}
|
||||
|
||||
/** BIP-322 simple P2TR key-path signature (serialized witness [schnorr_sig]). */
|
||||
function signBip322P2TR(priv: Uint8Array, message: string): { sig: Uint8Array; address: string } {
|
||||
const internalXonly = secp256k1.getPublicKey(priv, true).slice(1);
|
||||
const { address, program } = p2tr(internalXonly);
|
||||
const spk = concatBytes(new Uint8Array([0x51, 0x20]), program);
|
||||
const txid = toSpendTxid(message, spk);
|
||||
const sighash = bip341SighashP2TR(txid, spk);
|
||||
// Taproot key-path must sign with the *tweaked* private key.
|
||||
const n = secp256k1.CURVE.n;
|
||||
let d = bytesToBig(priv) % n;
|
||||
// BIP-340: if the internal pubkey has odd Y, negate d.
|
||||
const Pfull = secp256k1.Point.BASE.multiply(d);
|
||||
if (Pfull.toAffine().y % 2n === 1n) d = n - d;
|
||||
const t = bytesToBig(taggedHash('TapTweak', internalXonly)) % n;
|
||||
const tweaked = (d + t) % n;
|
||||
const tweakedBytes = bigToXonly(tweaked);
|
||||
const sig = schnorr.sign(sighash, tweakedBytes);
|
||||
return { sig: serializeWitness([sig]), address };
|
||||
}
|
||||
|
||||
// ── fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMessage(address: string): string {
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
statement: 'Sign in to Hanzo',
|
||||
nonce: 'abc123XYZ789',
|
||||
now: Date.UTC(2026, 0, 1),
|
||||
});
|
||||
return buildSiwxMessage({ challenge, address, chain: 'bitcoin' });
|
||||
}
|
||||
|
||||
// A fixed key so failures are reproducible.
|
||||
const PRIV = new Uint8Array(32).fill(0);
|
||||
PRIV[31] = 0x2a; // d = 42
|
||||
|
||||
describe('verifyBitcoin — anchors against Bitcoin Core BIP-322 vectors', () => {
|
||||
it('message_hash + to_spend txid match the official vectors', () => {
|
||||
const addr = 'bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l';
|
||||
// Reconstruct that address's witness program from the known WIF private key.
|
||||
// WIF L3VFe…: 0x80 || priv(32) || 0x01 || checksum(4) → priv extracted below.
|
||||
const wifPriv = hexToBytesLocal(
|
||||
'bb051cd0dda0246f33c5a9e133ebd8e7bc02a92af6c41adc131ccd7826c5b004',
|
||||
);
|
||||
const pub = secp256k1.getPublicKey(wifPriv, true);
|
||||
expect(p2wpkh(pub)).toBe(addr); // P2WPKH derivation anchor
|
||||
|
||||
expect(bytesToHex(taggedHash('BIP0322-signed-message', enc('')))).toBe(
|
||||
'c90c269c4f8fcbe6880f72a721ddfbf1914268a794cbb21cfafee13770ae19f1',
|
||||
);
|
||||
expect(bytesToHex(taggedHash('BIP0322-signed-message', enc('Hello World')))).toBe(
|
||||
'f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a',
|
||||
);
|
||||
const spk = concatBytes(new Uint8Array([0x00, 0x14]), hash160(pub));
|
||||
const display = (b: Uint8Array) => bytesToHex(Uint8Array.from([...b].reverse()));
|
||||
expect(display(toSpendTxid('', spk))).toBe(
|
||||
'c5680aa69bb8d860bf82d4e9cd3504b55dde018de765a91bb566283c545a99a7',
|
||||
);
|
||||
expect(display(toSpendTxid('Hello World', spk))).toBe(
|
||||
'b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function hexToBytesLocal(hex: string): Uint8Array {
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('verifyBitcoin — legacy Bitcoin Signed Message', () => {
|
||||
const pubC = secp256k1.getPublicKey(PRIV, true);
|
||||
const pubU = secp256k1.getPublicKey(PRIV, false);
|
||||
|
||||
it('P2PKH (compressed) verifies, tamper + wrong address fail', () => {
|
||||
const address = p2pkh(pubC);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, true);
|
||||
const proof: SignedProof = {
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
};
|
||||
expect(verifyBitcoin(proof)).toBe(true);
|
||||
|
||||
// Tamper the signature (flip a byte in r).
|
||||
const bad = sig.slice();
|
||||
bad[5] = bad[5]! ^ 0xff;
|
||||
expect(verifyBitcoin({ ...proof, signature: toBase64(bad) })).toBe(false);
|
||||
|
||||
// Tamper the message.
|
||||
expect(verifyBitcoin({ ...proof, message: message + ' ' })).toBe(false);
|
||||
|
||||
// Wrong address (different key's P2PKH).
|
||||
const other = secp256k1.getPublicKey(hexToBytesLocal('11'.repeat(32)), true);
|
||||
expect(verifyBitcoin({ ...proof, address: p2pkh(other) })).toBe(false);
|
||||
});
|
||||
|
||||
it('P2PKH (uncompressed) verifies and is key-encoding-bound', () => {
|
||||
const address = p2pkh(pubU);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, false);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
|
||||
).toBe(true);
|
||||
|
||||
// The compressed-key address must NOT verify against an uncompressed-header sig.
|
||||
const cAddr = p2pkh(pubC);
|
||||
const cMsg = makeMessage(cAddr);
|
||||
const uncompSig = signLegacy(PRIV, cMsg, false);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address: cAddr, message: cMsg, signature: toBase64(uncompSig) }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('P2WPKH verifies via legacy recoverable sig, rejects uncompressed header', () => {
|
||||
const address = p2wpkh(pubC);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, true);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
|
||||
).toBe(true);
|
||||
|
||||
// Uncompressed header can't back a segwit address → reject.
|
||||
const uncompSig = signLegacy(PRIV, message, false);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(uncompSig) }),
|
||||
).toBe(false);
|
||||
|
||||
// A P2WPKH address from a DIFFERENT key must not verify against this sig.
|
||||
const otherP2wpkh = p2wpkh(secp256k1.getPublicKey(hexToBytesLocal('05'.repeat(32)), true));
|
||||
expect(
|
||||
verifyBitcoin({
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address: otherP2wpkh,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('P2TR (BIP-86) verifies via legacy recoverable sig', () => {
|
||||
const internalXonly = pubC.slice(1);
|
||||
const { address } = p2tr(internalXonly);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, true);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
|
||||
).toBe(true);
|
||||
|
||||
// Tamper → false.
|
||||
const bad = sig.slice();
|
||||
bad[40] = bad[40]! ^ 0x01;
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(bad) }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBitcoin — BIP-322 simple', () => {
|
||||
it('P2WPKH (BIP-143 / ECDSA) verifies, tamper + wrong address fail', () => {
|
||||
const pub = secp256k1.getPublicKey(PRIV, true);
|
||||
const address = p2wpkh(pub);
|
||||
const message = makeMessage(address);
|
||||
const sig = signBip322P2WPKH(PRIV, message);
|
||||
const proof: SignedProof = {
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
extra: { addressType: 'p2wpkh' },
|
||||
};
|
||||
expect(verifyBitcoin(proof)).toBe(true);
|
||||
|
||||
// Tamper the message → sighash changes → false.
|
||||
expect(verifyBitcoin({ ...proof, message: message + 'x' })).toBe(false);
|
||||
|
||||
// Wrong address → witness pubkey no longer hashes to it → false.
|
||||
const other = p2wpkh(secp256k1.getPublicKey(hexToBytesLocal('07'.repeat(32)), true));
|
||||
expect(verifyBitcoin({ ...proof, address: other })).toBe(false);
|
||||
|
||||
// Truncated witness → false.
|
||||
expect(verifyBitcoin({ ...proof, signature: toBase64(sig.slice(0, sig.length - 3)) })).toBe(false);
|
||||
});
|
||||
|
||||
it('P2TR key-path (BIP-341 / Schnorr) verifies, tamper fails', () => {
|
||||
const message0 = 'placeholder';
|
||||
const { address } = signBip322P2TR(PRIV, message0); // get the address first
|
||||
const message = makeMessage(address);
|
||||
const { sig } = signBip322P2TR(PRIV, message);
|
||||
const proof: SignedProof = {
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
extra: { addressType: 'p2tr' },
|
||||
};
|
||||
expect(verifyBitcoin(proof)).toBe(true);
|
||||
|
||||
// Tamper the message → false.
|
||||
expect(verifyBitcoin({ ...proof, message: message + 'z' })).toBe(false);
|
||||
|
||||
// Flip a byte in the schnorr sig → false.
|
||||
const bad = sig.slice();
|
||||
bad[bad.length - 1] = bad[bad.length - 1]! ^ 0x01;
|
||||
expect(verifyBitcoin({ ...proof, signature: toBase64(bad) })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBitcoin — malformed input fails closed', () => {
|
||||
const address = p2wpkh(secp256k1.getPublicKey(PRIV, true));
|
||||
const message = makeMessage(address);
|
||||
const base: SignedProof = { chain: 'bitcoin', scheme: 'bip322', address, message, signature: '' };
|
||||
|
||||
it('empty signature → false', () => {
|
||||
expect(verifyBitcoin(base)).toBe(false);
|
||||
});
|
||||
it('garbage base64 → false', () => {
|
||||
expect(verifyBitcoin({ ...base, signature: '!!!notbase64!!!' })).toBe(false);
|
||||
});
|
||||
it('unknown address prefix → false', () => {
|
||||
expect(verifyBitcoin({ ...base, address: '3unsupportedP2SHaddress', signature: 'AQID' })).toBe(false);
|
||||
});
|
||||
it('legacy sig with out-of-range header → false', () => {
|
||||
const sig = new Uint8Array(65);
|
||||
sig[0] = 99; // invalid header
|
||||
expect(verifyBitcoin({ ...base, signature: toBase64(sig) })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSiwxMessage, parseSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
|
||||
describe('CAIP-122 message', () => {
|
||||
const now = 1_700_000_000_000; // fixed epoch for determinism
|
||||
|
||||
it('build → parse round-trips all fields', () => {
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
statement: 'Sign in to Hanzo.',
|
||||
nonce: 'abc12345',
|
||||
now,
|
||||
ttlSeconds: 600,
|
||||
requestId: 'req-1',
|
||||
resources: ['https://hanzo.ai/api', 'https://hanzo.chat'],
|
||||
});
|
||||
const msg = buildSiwxMessage({
|
||||
challenge,
|
||||
address: '0x1111111111111111111111111111111111111111',
|
||||
chain: 'evm',
|
||||
chainId: 'eip155:1',
|
||||
});
|
||||
const p = parseSiwxMessage(msg);
|
||||
expect(p.domain).toBe('hanzo.id');
|
||||
expect(p.address).toBe('0x1111111111111111111111111111111111111111');
|
||||
expect(p.statement).toBe('Sign in to Hanzo.');
|
||||
expect(p.uri).toBe('https://hanzo.id/login');
|
||||
expect(p.version).toBe('1');
|
||||
expect(p.chainId).toBe('eip155:1');
|
||||
expect(p.nonce).toBe('abc12345');
|
||||
expect(p.issuedAt).toBe(new Date(now).toISOString());
|
||||
expect(p.expirationTime).toBe(new Date(now + 600_000).toISOString());
|
||||
expect(p.requestId).toBe('req-1');
|
||||
expect(p.resources).toEqual(['https://hanzo.ai/api', 'https://hanzo.chat']);
|
||||
});
|
||||
|
||||
it('renders the chain label on the header line', () => {
|
||||
const c = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id', nonce: 'nonce123', now });
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'So1aNa', chain: 'solana' })).toContain(
|
||||
'wants you to sign in with your Solana account:',
|
||||
);
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'bc1q', chain: 'bitcoin' })).toContain(
|
||||
'with your Bitcoin account:',
|
||||
);
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'EQxx', chain: 'ton' })).toContain(
|
||||
'with your TON account:',
|
||||
);
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'rXYZ', chain: 'xrp' })).toContain(
|
||||
'with your XRP Ledger account:',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits optional fields when absent', () => {
|
||||
const c = newChallenge({ domain: 'd', uri: 'https://d', nonce: 'nonce123', now });
|
||||
const msg = buildSiwxMessage({ challenge: { ...c, expirationTime: undefined }, address: 'a', chain: 'evm' });
|
||||
expect(msg).not.toContain('Chain ID:');
|
||||
expect(msg).not.toContain('Request ID:');
|
||||
expect(msg).not.toContain('Resources:');
|
||||
});
|
||||
|
||||
it('rejects a multi-line statement', () => {
|
||||
const c = newChallenge({ domain: 'd', uri: 'https://d', nonce: 'nonce123', now, statement: 'a\nb' });
|
||||
expect(() => buildSiwxMessage({ challenge: c, address: 'a', chain: 'evm' })).toThrow();
|
||||
});
|
||||
|
||||
it('throws on malformed message', () => {
|
||||
expect(() => parseSiwxMessage('not a siwx message')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Connector tests.
|
||||
*
|
||||
* Two things are exercised without a real wallet or browser:
|
||||
* 1. getConnector(chain) returns a connector whose `.chain` matches.
|
||||
* 2. EVM + Solana round-trip: a MOCKED injected provider (backed by a real
|
||||
* keypair) signs the CAIP-122 message via the connector's signLogin, and
|
||||
* the resulting SignedProof passes the server-side verifyProof.
|
||||
*
|
||||
* The other connectors (Bitcoin/TON/XRP) drive third-party SDKs whose wallet
|
||||
* handshakes cannot be faithfully mocked headlessly; they are covered by their
|
||||
* verifiers' round-trip tests and need a real wallet to exercise end-to-end.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import bs58 from 'bs58';
|
||||
import { getConnector, allConnectors } from '../connectors.js';
|
||||
import { EvmConnector } from '../evm/connect.js';
|
||||
import { SolanaConnector } from '../solana/connect.js';
|
||||
import { verifyProof } from '../verify.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { CHAINS, type Chain } from '../types.js';
|
||||
import {
|
||||
eip191Digest,
|
||||
addressFromPublicKey,
|
||||
recoverEvmAddress,
|
||||
} from '../evm/verify.js';
|
||||
import { bytesToHex, utf8ToBytes, hexToBytes } from '../bytes.js';
|
||||
|
||||
// ── factory ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getConnector', () => {
|
||||
it('returns a connector whose chain matches, for every chain', () => {
|
||||
for (const chain of CHAINS) {
|
||||
const c = getConnector(chain);
|
||||
expect(c.chain).toBe(chain);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds the right class per chain', () => {
|
||||
expect(getConnector('evm')).toBeInstanceOf(EvmConnector);
|
||||
expect(getConnector('solana')).toBeInstanceOf(SolanaConnector);
|
||||
});
|
||||
|
||||
it('allConnectors() yields one connector per chain, in canonical order', () => {
|
||||
const all = allConnectors();
|
||||
expect(all.map((c) => c.chain)).toEqual(CHAINS as Chain[]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── shared window shim ───────────────────────────────────────────────────────
|
||||
// A bare window without addEventListener/dispatchEvent so the EVM connector's
|
||||
// EIP-6963 discovery short-circuits to the legacy window.ethereum path (fast,
|
||||
// deterministic — no 300ms announce wait).
|
||||
|
||||
const realWindow = (globalThis as Record<string, unknown>).window;
|
||||
|
||||
function setWindow(props: Record<string, unknown>): void {
|
||||
(globalThis as Record<string, unknown>).window = props;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (realWindow === undefined) {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
} else {
|
||||
(globalThis as Record<string, unknown>).window = realWindow;
|
||||
}
|
||||
});
|
||||
|
||||
// ── EVM mock provider (EIP-1193, real secp256k1 key) ─────────────────────────
|
||||
|
||||
function makeEvmProvider() {
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const pub = secp256k1.getPublicKey(priv, false);
|
||||
const address = addressFromPublicKey(pub); // lowercased 0x…
|
||||
|
||||
const provider = {
|
||||
async request({ method, params }: { method: string; params?: unknown[] }): Promise<unknown> {
|
||||
switch (method) {
|
||||
case 'eth_requestAccounts':
|
||||
case 'eth_accounts':
|
||||
return [address];
|
||||
case 'eth_chainId':
|
||||
return '0x1';
|
||||
case 'personal_sign': {
|
||||
// viem sends [data, account]; data is the 0x-hex of the UTF-8 message.
|
||||
const dataHex = params?.[0] as string;
|
||||
const msgBytes = hexToBytes(dataHex);
|
||||
const message = new TextDecoder().decode(msgBytes);
|
||||
const sig = secp256k1.sign(eip191Digest(message), priv);
|
||||
const full = new Uint8Array(65);
|
||||
full.set(sig.toCompactRawBytes(), 0);
|
||||
full[64] = (sig.recovery ?? 0) + 27;
|
||||
return '0x' + bytesToHex(full);
|
||||
}
|
||||
default:
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
return { provider, address };
|
||||
}
|
||||
|
||||
describe('EvmConnector round-trip (mocked injected wallet)', () => {
|
||||
beforeEach(() => {
|
||||
const { provider } = makeEvmProvider();
|
||||
setWindow({ ethereum: provider });
|
||||
});
|
||||
|
||||
it('connects, signs the CAIP-122 message, and verifyProof accepts it', async () => {
|
||||
const c = new EvmConnector();
|
||||
const account = await c.connect();
|
||||
expect(account.chain).toBe('evm');
|
||||
expect(account.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
||||
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'evmNonce123',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
|
||||
expect(proof.scheme).toBe('secp256k1-eip191');
|
||||
expect(proof.chain).toBe('evm');
|
||||
// The signature recovers the connected address (the verifier's core check).
|
||||
expect(recoverEvmAddress(proof.message, proof.signature)?.toLowerCase()).toBe(
|
||||
account.address.toLowerCase(),
|
||||
);
|
||||
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'evmNonce123', now });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.address?.toLowerCase()).toBe(account.address.toLowerCase());
|
||||
expect(res.chain).toBe('evm');
|
||||
});
|
||||
|
||||
it('available() discovers the injected wallet via the legacy path', async () => {
|
||||
const c = new EvmConnector();
|
||||
const wallets = await c.available();
|
||||
expect(wallets.length).toBe(1);
|
||||
expect(wallets[0]?.chain).toBe('evm');
|
||||
expect(wallets[0]?.installed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Solana mock provider (real ed25519 key) ──────────────────────────────────
|
||||
|
||||
function makeSolanaProvider() {
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const address = bs58.encode(pub);
|
||||
|
||||
const publicKey = {
|
||||
toBytes: () => pub,
|
||||
toString: () => address,
|
||||
};
|
||||
const provider = {
|
||||
isPhantom: true,
|
||||
publicKey,
|
||||
async connect() {
|
||||
return { publicKey };
|
||||
},
|
||||
async signMessage(message: Uint8Array, _encoding?: string) {
|
||||
return { signature: ed25519.sign(message, priv) };
|
||||
},
|
||||
async disconnect() {},
|
||||
};
|
||||
return { provider, address };
|
||||
}
|
||||
|
||||
describe('SolanaConnector round-trip (mocked injected wallet)', () => {
|
||||
let expectedAddress: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const { provider, address } = makeSolanaProvider();
|
||||
expectedAddress = address;
|
||||
setWindow({ solana: provider });
|
||||
});
|
||||
|
||||
it('connects, signs the CAIP-122 message, and verifyProof accepts it', async () => {
|
||||
const c = new SolanaConnector();
|
||||
const account = await c.connect();
|
||||
expect(account.chain).toBe('solana');
|
||||
expect(account.address).toBe(expectedAddress);
|
||||
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'solNonce4567',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
|
||||
expect(proof.scheme).toBe('ed25519');
|
||||
expect(proof.chain).toBe('solana');
|
||||
expect(proof.address).toBe(expectedAddress);
|
||||
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'solNonce4567', now });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.address).toBe(expectedAddress);
|
||||
expect(res.chain).toBe('solana');
|
||||
});
|
||||
|
||||
it('handles the bare-Uint8Array signMessage return shape', async () => {
|
||||
// Some wallets return the raw signature bytes instead of {signature}.
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const address = bs58.encode(pub);
|
||||
const publicKey = { toBytes: () => pub, toString: () => address };
|
||||
setWindow({
|
||||
solana: {
|
||||
isPhantom: true,
|
||||
publicKey,
|
||||
connect: async () => ({ publicKey }),
|
||||
signMessage: async (m: Uint8Array) => ed25519.sign(m, priv),
|
||||
},
|
||||
});
|
||||
|
||||
const c = new SolanaConnector();
|
||||
const account = await c.connect();
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'solBareSig99',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'solBareSig99', now }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a proof whose nonce was tampered after signing', async () => {
|
||||
const c = new SolanaConnector();
|
||||
const account = await c.connect();
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'solGood0001',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
// Server expects a different nonce → rejected before crypto.
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'solOther0002', now }).reason).toBe(
|
||||
'nonce-mismatch',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── browser-only guard ───────────────────────────────────────────────────────
|
||||
|
||||
describe('connectors are browser-only', () => {
|
||||
it('EVM connect throws without a window', async () => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
await expect(new EvmConnector().connect()).rejects.toThrow(/browser-only/);
|
||||
});
|
||||
|
||||
it('Solana connect throws without a window', async () => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
await expect(new SolanaConnector().connect()).rejects.toThrow(/browser-only/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { verifyTon } from '../ton/verify.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { bytesToHex, bytesToBase64, utf8ToBytes, concatBytes } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// --- Reproduce the TON Connect ton_proof signing algorithm (the wallet side). ---
|
||||
// This MUST mirror src/ton/verify.ts byte-for-byte; if they ever drift, the
|
||||
// round-trip "accepts a valid proof" test fails — which is the whole point.
|
||||
|
||||
interface ProofEnvelope {
|
||||
timestamp: number;
|
||||
domain: string;
|
||||
payload: string;
|
||||
workchain: number;
|
||||
addressHashHex: string;
|
||||
}
|
||||
|
||||
function tonProofDigest(env: ProofEnvelope): Uint8Array {
|
||||
const addressHash = hexFix(env.addressHashHex);
|
||||
|
||||
const wc = new Uint8Array(4);
|
||||
new DataView(wc.buffer).setInt32(0, env.workchain, false); // big-endian, signed
|
||||
|
||||
const domainBytes = utf8ToBytes(env.domain);
|
||||
const dlen = new Uint8Array(4);
|
||||
new DataView(dlen.buffer).setUint32(0, domainBytes.length, true); // little-endian
|
||||
|
||||
const ts = new Uint8Array(8);
|
||||
new DataView(ts.buffer).setBigUint64(0, BigInt(env.timestamp), true); // little-endian
|
||||
|
||||
const message = concatBytes(
|
||||
utf8ToBytes('ton-proof-item-v2/'),
|
||||
wc,
|
||||
addressHash,
|
||||
dlen,
|
||||
domainBytes,
|
||||
ts,
|
||||
utf8ToBytes(env.payload),
|
||||
);
|
||||
|
||||
const fullMsg = concatBytes(Uint8Array.of(0xff, 0xff), utf8ToBytes('ton-connect'), sha256(message));
|
||||
return sha256(fullMsg);
|
||||
}
|
||||
|
||||
/** Local hex→bytes (no 0x) so the test does not depend on verifier internals. */
|
||||
function hexFix(hex: string): Uint8Array {
|
||||
const h = hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||
const out = new Uint8Array(h.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh, self-consistent TON proof: random ed25519 key, a CAIP-122
|
||||
* message whose Nonce equals the ton_proof payload, and a real signature over
|
||||
* the reconstructed digest.
|
||||
*/
|
||||
function mintProof(overrides?: {
|
||||
workchain?: number;
|
||||
domain?: string;
|
||||
now?: number;
|
||||
}): { proof: SignedProof; env: ProofEnvelope; priv: Uint8Array; pub: Uint8Array } {
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
|
||||
// A TON address-hash (account state-init hash). For the verifier it is just
|
||||
// 32 opaque bytes; use a deterministic-but-arbitrary value here.
|
||||
const addressHash = sha256(pub); // 32 bytes
|
||||
const addressHashHex = bytesToHex(addressHash);
|
||||
|
||||
const workchain = overrides?.workchain ?? 0;
|
||||
const domain = overrides?.domain ?? 'hanzo.id';
|
||||
const now = overrides?.now ?? 1_700_000_000_000;
|
||||
const timestamp = Math.floor(now / 1000);
|
||||
|
||||
// Server mints the nonce; the connector reuses it as the ton_proof payload.
|
||||
const challenge = newChallenge({ domain, uri: `https://${domain}/login`, now });
|
||||
const payload = challenge.nonce;
|
||||
|
||||
// The on-chain "address" we use for binding: friendly form would be base64url,
|
||||
// but for verifier purposes the address must simply equal the SIWx address
|
||||
// line. Use "<workchain>:<addressHashHex>" (raw TON address form).
|
||||
const address = `${workchain}:${addressHashHex}`;
|
||||
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'ton' });
|
||||
|
||||
const env: ProofEnvelope = { timestamp, domain, payload, workchain, addressHashHex };
|
||||
const digest = tonProofDigest(env);
|
||||
const signature = bytesToBase64(ed25519.sign(digest, priv));
|
||||
|
||||
const proof: SignedProof = {
|
||||
chain: 'ton',
|
||||
scheme: 'ton-proof',
|
||||
address,
|
||||
publicKey: bytesToHex(pub),
|
||||
message,
|
||||
signature,
|
||||
extra: { ...env },
|
||||
};
|
||||
return { proof, env, priv, pub };
|
||||
}
|
||||
|
||||
describe('TON ton_proof verify', () => {
|
||||
it('accepts a valid proof (full round-trip)', () => {
|
||||
const { proof } = mintProof();
|
||||
expect(verifyTon(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a valid proof on the masterchain (workchain = -1)', () => {
|
||||
// Exercises signed int32BE encoding: -1 must serialize as 0xFFFFFFFF on
|
||||
// both the signing and verifying sides.
|
||||
const { proof } = mintProof({ workchain: -1 });
|
||||
expect(verifyTon(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered timestamp', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), timestamp: (proof.extra as any).timestamp + 1 },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a tampered domain', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), domain: 'evil.com' },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a tampered payload (signature no longer matches)', () => {
|
||||
const { proof } = mintProof();
|
||||
// Mutate BOTH the SIWx nonce and the envelope payload so the binding check
|
||||
// passes and we isolate the cryptographic rejection.
|
||||
const tamperedPayload = (proof.extra as any).payload + 'X';
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
message: proof.message.replace(/Nonce: .*/, `Nonce: ${tamperedPayload}`),
|
||||
address: proof.address,
|
||||
extra: { ...(proof.extra as object), payload: tamperedPayload },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong public key', () => {
|
||||
const { proof } = mintProof();
|
||||
const otherPub = ed25519.getPublicKey(ed25519.utils.randomPrivateKey());
|
||||
const bad: SignedProof = { ...proof, publicKey: bytesToHex(otherPub) };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a nonce/payload mismatch (binding failure, before crypto)', () => {
|
||||
const { proof } = mintProof();
|
||||
// Envelope payload no longer equals the SIWx Nonce → binding rejects it
|
||||
// even though the (still-valid-for-old-payload) signature is untouched.
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), payload: 'a-different-nonce' },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an address that does not match the SIWx message', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, address: '0:deadbeef' };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed signature (wrong length)', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, signature: bytesToBase64(new Uint8Array(63)) };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed public key (wrong length)', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, publicKey: bytesToHex(new Uint8Array(31)) };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed address hash (wrong length)', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), addressHashHex: 'dead' },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing envelope', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, extra: undefined };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing public key', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad = { ...proof, publicKey: undefined } as SignedProof;
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw on garbage input', () => {
|
||||
const garbage = {
|
||||
chain: 'ton',
|
||||
scheme: 'ton-proof',
|
||||
address: 'x',
|
||||
publicKey: 'nothex',
|
||||
message: 'not a siwx message',
|
||||
signature: '!!!!',
|
||||
extra: { timestamp: 'soon', domain: 1, payload: null, workchain: 0.5, addressHashHex: 7 },
|
||||
} as unknown as SignedProof;
|
||||
expect(() => verifyTon(garbage)).not.toThrow();
|
||||
expect(verifyTon(garbage)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import bs58 from 'bs58';
|
||||
import { verifyEvm, eip191Digest, addressFromPublicKey } from '../evm/verify.js';
|
||||
import { verifySolana } from '../solana/verify.js';
|
||||
import { verifyProof } from '../verify.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { bytesToHex, bytesToBase64, utf8ToBytes } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// --- test signers (produce real signatures the verifier must accept) ---
|
||||
|
||||
function evmSign(message: string) {
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const pub = secp256k1.getPublicKey(priv, false);
|
||||
const address = addressFromPublicKey(pub);
|
||||
const sig = secp256k1.sign(eip191Digest(message), priv);
|
||||
const full = new Uint8Array(65);
|
||||
full.set(sig.toCompactRawBytes(), 0);
|
||||
full[64] = (sig.recovery ?? 0) + 27;
|
||||
return { address, signature: '0x' + bytesToHex(full) };
|
||||
}
|
||||
|
||||
function solanaSign(message: string) {
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const address = bs58.encode(pub);
|
||||
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
|
||||
return { address, signature };
|
||||
}
|
||||
|
||||
describe('EVM EIP-191 verify', () => {
|
||||
it('accepts a valid signature', () => {
|
||||
const message = 'hello hanzo';
|
||||
const { address, signature } = evmSign(message);
|
||||
expect(verifyEvm(message, signature, address)).toBe(true);
|
||||
});
|
||||
it('is case-insensitive on the address', () => {
|
||||
const message = 'hello';
|
||||
const { address, signature } = evmSign(message);
|
||||
expect(verifyEvm(message, signature, address.toUpperCase().replace('0X', '0x'))).toBe(true);
|
||||
});
|
||||
it('rejects a tampered message', () => {
|
||||
const { address, signature } = evmSign('original');
|
||||
expect(verifyEvm('tampered', signature, address)).toBe(false);
|
||||
});
|
||||
it('rejects a wrong address', () => {
|
||||
const { signature } = evmSign('m');
|
||||
expect(verifyEvm('m', signature, '0x0000000000000000000000000000000000000000')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Solana ed25519 verify', () => {
|
||||
it('accepts a valid signature', () => {
|
||||
const message = 'hello solana';
|
||||
const { address, signature } = solanaSign(message);
|
||||
expect(verifySolana(message, signature, address)).toBe(true);
|
||||
});
|
||||
it('rejects a tampered message', () => {
|
||||
const { address, signature } = solanaSign('original');
|
||||
expect(verifySolana('tampered', signature, address)).toBe(false);
|
||||
});
|
||||
it('rejects a wrong address', () => {
|
||||
const { signature } = solanaSign('m');
|
||||
const other = bs58.encode(ed25519.getPublicKey(ed25519.utils.randomPrivateKey()));
|
||||
expect(verifySolana('m', signature, other)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyProof end-to-end', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const base = { domain: 'hanzo.id', uri: 'https://hanzo.id/login', nonce: 'abc12345', now };
|
||||
|
||||
it('accepts a fresh EVM proof', () => {
|
||||
const challenge = newChallenge(base);
|
||||
// EVM: derive the address from the key, embed it in the message, then sign.
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const address = addressFromPublicKey(secp256k1.getPublicKey(priv, false));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'evm' });
|
||||
const sig = secp256k1.sign(eip191Digest(message), priv);
|
||||
const full = new Uint8Array(65);
|
||||
full.set(sig.toCompactRawBytes(), 0);
|
||||
full[64] = (sig.recovery ?? 0) + 27;
|
||||
const proof: SignedProof = {
|
||||
chain: 'evm', scheme: 'secp256k1-eip191', address, message,
|
||||
signature: '0x' + bytesToHex(full),
|
||||
};
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.address).toBe(address);
|
||||
});
|
||||
|
||||
it('accepts a fresh Solana proof', () => {
|
||||
const challenge = newChallenge(base);
|
||||
// address is the pubkey; sign the message that embeds that address
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const address = bs58.encode(ed25519.getPublicKey(priv));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
|
||||
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
|
||||
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature };
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong nonce / domain', () => {
|
||||
const challenge = newChallenge(base);
|
||||
const address = bs58.encode(ed25519.getPublicKey(ed25519.utils.randomPrivateKey()));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
|
||||
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature: 'AAAA' };
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'WRONG', now }).reason).toBe('nonce-mismatch');
|
||||
expect(verifyProof(proof, { domain: 'evil.com', nonce: 'abc12345', now }).reason).toBe('domain-mismatch');
|
||||
});
|
||||
|
||||
it('rejects an expired proof', () => {
|
||||
const challenge = newChallenge({ ...base, ttlSeconds: 60 });
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const address = bs58.encode(ed25519.getPublicKey(priv));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
|
||||
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
|
||||
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature };
|
||||
// now is 1h after issuance, well past 60s ttl + skew
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now: now + 3_600_000 });
|
||||
expect(res.reason).toBe('expired');
|
||||
});
|
||||
|
||||
it('fails closed on an unknown scheme', () => {
|
||||
const challenge = newChallenge(base);
|
||||
const message = buildSiwxMessage({ challenge, address: 'rXYZ', chain: 'xrp' });
|
||||
const proof = { chain: 'xrp', scheme: 'totally-unknown', address: 'rXYZ', message, signature: '00' } as unknown as SignedProof;
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).reason).toBe('unsupported-scheme');
|
||||
});
|
||||
|
||||
it('fails closed (bad-signature) on a wired-but-unverifiable proof', () => {
|
||||
const challenge = newChallenge(base);
|
||||
const message = buildSiwxMessage({ challenge, address: 'rXYZ', chain: 'xrp' });
|
||||
const proof: SignedProof = { chain: 'xrp', scheme: 'secp256k1-xrpl', address: 'rXYZ', message, signature: '00' };
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { sha512 } from '@noble/hashes/sha512';
|
||||
import { ripemd160 } from '@noble/hashes/ripemd160';
|
||||
import { verifyXrp } from '../xrp/verify.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { bytesToHex, concatBytes, utf8ToBytes } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// --- Reproduce the XRPL signing + r-address derivation (the wallet side). ---
|
||||
// This is an INDEPENDENT implementation of the same spec the verifier uses; if
|
||||
// the two ever drift, the round-trip "accepts a valid proof" test fails. That
|
||||
// is the whole point of mirroring rather than importing verifier internals.
|
||||
|
||||
const XRPL_ALPHABET = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz';
|
||||
|
||||
/** Independent XRPL base58check (account/version-prefixed payload in). */
|
||||
function base58CheckXrpl(payload: Uint8Array): string {
|
||||
const checksum = sha256(sha256(payload)).slice(0, 4);
|
||||
const full = concatBytes(payload, checksum);
|
||||
let acc = 0n;
|
||||
for (const b of full) acc = (acc << 8n) | BigInt(b);
|
||||
let out = '';
|
||||
while (acc > 0n) {
|
||||
out = XRPL_ALPHABET[Number(acc % 58n)] + out;
|
||||
acc /= 58n;
|
||||
}
|
||||
for (let i = 0; i < full.length && full[i] === 0; i++) out = XRPL_ALPHABET[0] + out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** r-address from a full 33-byte XRPL public key. */
|
||||
function rAddressFromPubkey(pubkey33: Uint8Array): string {
|
||||
const accountId = ripemd160(sha256(pubkey33));
|
||||
return base58CheckXrpl(concatBytes(Uint8Array.of(0x00), accountId));
|
||||
}
|
||||
|
||||
function sha512Half(d: Uint8Array): Uint8Array {
|
||||
return sha512(d).slice(0, 32);
|
||||
}
|
||||
|
||||
interface Minted {
|
||||
proof: SignedProof;
|
||||
address: string;
|
||||
}
|
||||
|
||||
/** Mint a self-consistent ed25519-xrpl proof: key → r-address → SIWx → raw sig. */
|
||||
function mintEd25519(now = 1_700_000_000_000): Minted {
|
||||
const seed = ed25519.utils.randomPrivateKey();
|
||||
const raw32 = ed25519.getPublicKey(seed);
|
||||
// XRPL ed25519 public key = 0xED || 32-byte Edwards key.
|
||||
const pubkey33 = concatBytes(Uint8Array.of(0xed), raw32);
|
||||
const address = rAddressFromPubkey(pubkey33);
|
||||
|
||||
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login', now });
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'xrp' });
|
||||
|
||||
// ed25519-xrpl signs the raw UTF-8 message bytes.
|
||||
const sig = ed25519.sign(utf8ToBytes(message), seed);
|
||||
|
||||
return {
|
||||
address,
|
||||
proof: {
|
||||
chain: 'xrp',
|
||||
scheme: 'ed25519-xrpl',
|
||||
address,
|
||||
publicKey: bytesToHex(pubkey33),
|
||||
message,
|
||||
signature: bytesToHex(sig),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Mint a self-consistent secp256k1-xrpl proof: key → r-address → SIWx → DER sig. */
|
||||
function mintSecp256k1(now = 1_700_000_000_000): Minted {
|
||||
// Reject keys whose compressed form is not the usual length (defensive).
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const pubkey33 = secp256k1.getPublicKey(priv, true); // compressed: 0x02/0x03 || 32
|
||||
const address = rAddressFromPubkey(pubkey33);
|
||||
|
||||
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login', now });
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'xrp' });
|
||||
|
||||
// secp256k1-xrpl signs the sha512half of the message, DER-encoded.
|
||||
const digest = sha512Half(utf8ToBytes(message));
|
||||
const sig = secp256k1.sign(digest, priv, { prehash: false, lowS: true });
|
||||
const der = bytesToHex(sig.toBytes('der'));
|
||||
|
||||
return {
|
||||
address,
|
||||
proof: {
|
||||
chain: 'xrp',
|
||||
scheme: 'secp256k1-xrpl',
|
||||
address,
|
||||
publicKey: bytesToHex(pubkey33),
|
||||
message,
|
||||
signature: der,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('XRPL base58check (known-answer vector)', () => {
|
||||
it('encodes the canonical xrpl.org AccountID example', () => {
|
||||
// From https://xrpl.org/addresses.html (Address Encoding worked example):
|
||||
// AccountID = BA8E78626EE42C41B46D46C3048DF3A1C3C87072
|
||||
// r-address = rJrRMgiRgrU6hDF4pgu5DXQdWyPbY35ErN
|
||||
const accountId = Uint8Array.from(
|
||||
'BA8E78626EE42C41B46D46C3048DF3A1C3C87072'.match(/../g)!.map((h) => parseInt(h, 16)),
|
||||
);
|
||||
const addr = base58CheckXrpl(concatBytes(Uint8Array.of(0x00), accountId));
|
||||
expect(addr).toBe('rJrRMgiRgrU6hDF4pgu5DXQdWyPbY35ErN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyXrp — ed25519-xrpl', () => {
|
||||
it('accepts a valid proof (full round-trip)', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered message (signature no longer matches)', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const bad: SignedProof = { ...proof, message: proof.message + ' ' };
|
||||
expect(verifyXrp(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a flipped signature bit', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const sig = Uint8Array.from(proof.signature.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
sig[0] = (sig[0]! ^ 0x01) & 0xff;
|
||||
expect(verifyXrp({ ...proof, signature: bytesToHex(sig) })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong address (binding failure, key/sig still valid)', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const other = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, address: other.address })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a mismatched public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const other = mintEd25519();
|
||||
// Valid 0xED-prefixed key but not the signer → sig verify fails.
|
||||
expect(verifyXrp({ ...proof, publicKey: other.proof.publicKey })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing 0xED prefix on the public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const bytes = Uint8Array.from(proof.publicKey!.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
bytes[0] = 0xee; // wrong family tag
|
||||
expect(verifyXrp({ ...proof, publicKey: bytesToHex(bytes) })).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, publicKey: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyXrp — secp256k1-xrpl', () => {
|
||||
it('accepts a valid proof (full round-trip)', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
expect(verifyXrp(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered message (signature no longer matches)', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const bad: SignedProof = { ...proof, message: proof.message + ' ' };
|
||||
expect(verifyXrp(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a corrupted DER signature', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const der = Uint8Array.from(proof.signature.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
const last = der.length - 1;
|
||||
der[last] = (der[last]! ^ 0x01) & 0xff; // mangle last byte of s
|
||||
expect(verifyXrp({ ...proof, signature: bytesToHex(der) })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong address (binding failure, key/sig still valid)', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const other = mintSecp256k1();
|
||||
expect(verifyXrp({ ...proof, address: other.address })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a mismatched public key', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const other = mintSecp256k1();
|
||||
expect(verifyXrp({ ...proof, publicKey: other.proof.publicKey })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an ed25519-tagged key under the secp256k1 scheme', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const bytes = Uint8Array.from(proof.publicKey!.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
bytes[0] = 0xed; // not a valid compressed-point tag
|
||||
expect(verifyXrp({ ...proof, publicKey: bytesToHex(bytes) })).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing public key', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
expect(verifyXrp({ ...proof, publicKey: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyXrp — fail-closed hardening', () => {
|
||||
it('rejects an unknown scheme via this verifier', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, scheme: 'ed25519' as SignedProof['scheme'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw on garbage input', () => {
|
||||
const garbage = {
|
||||
chain: 'xrp',
|
||||
scheme: 'ed25519-xrpl',
|
||||
address: 'x',
|
||||
publicKey: 'nothex',
|
||||
message: 'not a siwx message',
|
||||
signature: '!!!!',
|
||||
} as unknown as SignedProof;
|
||||
expect(() => verifyXrp(garbage)).not.toThrow();
|
||||
expect(verifyXrp(garbage)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong-length public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, publicKey: bytesToHex(new Uint8Array(31)) })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Base58Check (Bitcoin alphabet) — inline, no deps. Encode-only: we build a
|
||||
* P2PKH address from a recovered pubkey-hash and compare it byte-for-byte
|
||||
* against `proof.address`. Checksum = first 4 bytes of sha256d(payload).
|
||||
*/
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { concatBytes } from '../bytes.js';
|
||||
|
||||
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
/** Plain base58 (big-endian) encode. */
|
||||
function base58encode(bytes: Uint8Array): string {
|
||||
// Count leading zero bytes → leading '1's.
|
||||
let zeros = 0;
|
||||
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
||||
|
||||
// Convert base-256 → base-58 via repeated division on a digit buffer.
|
||||
const digits: number[] = [0];
|
||||
for (let i = zeros; i < bytes.length; i++) {
|
||||
let carry = bytes[i]!;
|
||||
for (let j = 0; j < digits.length; j++) {
|
||||
carry += digits[j]! << 8;
|
||||
digits[j] = carry % 58;
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
while (carry > 0) {
|
||||
digits.push(carry % 58);
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
}
|
||||
|
||||
let out = '';
|
||||
for (let i = 0; i < zeros; i++) out += '1';
|
||||
for (let i = digits.length - 1; i >= 0; i--) out += ALPHABET[digits[i]!];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** version-byte || payload, append sha256d checksum, base58-encode. */
|
||||
export function base58checkEncode(version: number, payload: Uint8Array): string {
|
||||
const data = concatBytes(new Uint8Array([version & 0xff]), payload);
|
||||
const checksum = sha256(sha256(data)).slice(0, 4);
|
||||
return base58encode(concatBytes(data, checksum));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* bech32 (BIP-173) and bech32m (BIP-350) segwit address encoding — inline, no
|
||||
* deps. Encode-only: we derive an address from a recovered pubkey and compare
|
||||
* it against the claimed `proof.address` string, so we never need to decode.
|
||||
*
|
||||
* A SegWit v0 address (P2WPKH) uses the bech32 constant; v1+ (P2TR) uses
|
||||
* bech32m. The two differ only in the final XOR constant of the checksum —
|
||||
* the source of the 2017-era "bech32 is malleable for v1" fix.
|
||||
*/
|
||||
|
||||
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
|
||||
const BECH32_CONST = 1;
|
||||
const BECH32M_CONST = 0x2bc830a3;
|
||||
|
||||
function polymod(values: number[]): number {
|
||||
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
let chk = 1;
|
||||
for (const v of values) {
|
||||
const top = chk >>> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if ((top >>> i) & 1) chk ^= GEN[i]!;
|
||||
}
|
||||
}
|
||||
return chk >>> 0;
|
||||
}
|
||||
|
||||
function hrpExpand(hrp: string): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) >>> 5);
|
||||
out.push(0);
|
||||
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) & 31);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert a byte array (8-bit) to 5-bit groups (frombits=8, tobits=5, pad=true). */
|
||||
function convert8to5(data: Uint8Array): number[] | null {
|
||||
let acc = 0;
|
||||
let bits = 0;
|
||||
const out: number[] = [];
|
||||
const maxv = 31;
|
||||
for (const value of data) {
|
||||
if (value < 0 || value >> 8 !== 0) return null;
|
||||
acc = ((acc << 8) | value) & 0xffffffff;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
out.push((acc >>> bits) & maxv);
|
||||
}
|
||||
}
|
||||
if (bits > 0) out.push((acc << (5 - bits)) & maxv);
|
||||
return out;
|
||||
}
|
||||
|
||||
function createChecksum(hrp: string, data5: number[], constant: number): number[] {
|
||||
const values = hrpExpand(hrp).concat(data5);
|
||||
const mod = polymod(values.concat([0, 0, 0, 0, 0, 0])) ^ constant;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < 6; i++) out.push((mod >>> (5 * (5 - i))) & 31);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a SegWit address. witver 0 → bech32 (P2WPKH); witver 1 → bech32m
|
||||
* (P2TR). Returns null on any invalid input (fail closed; never throws).
|
||||
*/
|
||||
export function encodeSegwitAddress(
|
||||
hrp: string,
|
||||
witver: number,
|
||||
program: Uint8Array,
|
||||
): string | null {
|
||||
if (witver < 0 || witver > 16) return null;
|
||||
// BIP-141 program length bounds: 2..40 bytes; v0 must be 20 or 32.
|
||||
if (program.length < 2 || program.length > 40) return null;
|
||||
if (witver === 0 && program.length !== 20 && program.length !== 32) return null;
|
||||
|
||||
const data5 = convert8to5(program);
|
||||
if (data5 === null) return null;
|
||||
const payload = [witver, ...data5];
|
||||
const constant = witver === 0 ? BECH32_CONST : BECH32M_CONST;
|
||||
const checksum = createChecksum(hrp, payload, constant);
|
||||
const combined = payload.concat(checksum);
|
||||
|
||||
let out = hrp + '1';
|
||||
for (const d of combined) {
|
||||
if (d < 0 || d > 31) return null;
|
||||
out += CHARSET[d];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Bitcoin wallet connector — message signing via `sats-connect` (Xverse,
|
||||
* Leather, Unisat, and any wallet implementing the sats-connect provider RPC).
|
||||
*
|
||||
* Connect requests the wallet's addresses and prefers a P2WPKH ('bc1q…')
|
||||
* payment address (the broadest-compatibility key-path login form). Signing
|
||||
* uses `signMessage` with the BIP-322 protocol, which returns a base64
|
||||
* signature — a serialized witness stack for segwit/taproot addresses, or a
|
||||
* recoverable ECDSA sig for legacy. {@link verifyBitcoin} dispatches on that
|
||||
* shape, so the proof here carries the address *type* in `extra.addressType`
|
||||
* and scheme `bip322`.
|
||||
*/
|
||||
import Wallet, {
|
||||
AddressPurpose,
|
||||
MessageSigningProtocols,
|
||||
type Address,
|
||||
} from 'sats-connect';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
|
||||
/** sats-connect addressType → the hint string {@link verifyBitcoin} expects. */
|
||||
type BtcAddressTypeHint = 'p2pkh' | 'p2wpkh' | 'p2tr';
|
||||
|
||||
function toAddressTypeHint(addressType: string): BtcAddressTypeHint | null {
|
||||
if (addressType === 'p2pkh' || addressType === 'p2wpkh' || addressType === 'p2tr') {
|
||||
return addressType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the address to sign with. Order of preference:
|
||||
* 1. P2WPKH payment ('bc1q…') — widest wallet + verifier support
|
||||
* 2. P2TR payment/ordinals — taproot key-path
|
||||
* 3. P2PKH — legacy
|
||||
* Returns the chosen entry plus its verifier address-type hint.
|
||||
*/
|
||||
function chooseAddress(addresses: readonly Address[]): { addr: Address; hint: BtcAddressTypeHint } | null {
|
||||
const ranked: BtcAddressTypeHint[] = ['p2wpkh', 'p2tr', 'p2pkh'];
|
||||
for (const want of ranked) {
|
||||
const found = addresses.find((a) => toAddressTypeHint(a.addressType) === want);
|
||||
if (found) return { addr: found, hint: want };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class BitcoinConnector implements WalletConnector {
|
||||
readonly chain = 'bitcoin' as const;
|
||||
|
||||
#address: string | null = null;
|
||||
#addressType: BtcAddressTypeHint | null = null;
|
||||
#walletId = 'sats-connect';
|
||||
|
||||
/**
|
||||
* sats-connect resolves the concrete wallet at request time (it shows its own
|
||||
* provider picker), so discovery here advertises the aggregate provider.
|
||||
*/
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
if (typeof window === 'undefined') return [];
|
||||
return [
|
||||
{
|
||||
id: 'sats-connect',
|
||||
name: 'Bitcoin Wallet (Xverse / Leather / Unisat)',
|
||||
chain: this.chain,
|
||||
installed: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect and pick a signing address. `walletId` is forwarded to sats-connect
|
||||
* as the provider id when given; otherwise its built-in picker is used.
|
||||
*/
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('bitcoin: no window — connectors are browser-only');
|
||||
}
|
||||
if (walletId != null) this.#walletId = walletId;
|
||||
|
||||
const res = await Wallet.request('getAddresses', {
|
||||
purposes: [AddressPurpose.Payment, AddressPurpose.Ordinals],
|
||||
message: 'Connect to sign in',
|
||||
});
|
||||
if (res.status !== 'success') {
|
||||
throw new Error(`bitcoin: getAddresses failed (${res.error?.message ?? 'rejected'})`);
|
||||
}
|
||||
|
||||
const chosen = chooseAddress(res.result.addresses);
|
||||
if (!chosen) throw new Error('bitcoin: wallet returned no usable address');
|
||||
|
||||
this.#address = chosen.addr.address;
|
||||
this.#addressType = chosen.hint;
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
address: chosen.addr.address,
|
||||
publicKey: chosen.addr.publicKey,
|
||||
walletId: this.#walletId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CAIP-122 message and have the wallet sign it (BIP-322).
|
||||
* Produces a `bip322` proof carrying `extra.addressType` so the verifier
|
||||
* derives the correct address form.
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
if (!this.#address || !this.#addressType) {
|
||||
throw new Error('bitcoin: not connected — call connect() first');
|
||||
}
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
});
|
||||
|
||||
const res = await Wallet.request('signMessage', {
|
||||
address: account.address,
|
||||
message,
|
||||
protocol: MessageSigningProtocols.BIP322,
|
||||
});
|
||||
if (res.status !== 'success') {
|
||||
throw new Error(`bitcoin: signMessage failed (${res.error?.message ?? 'rejected'})`);
|
||||
}
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: 'bip322',
|
||||
address: account.address,
|
||||
message,
|
||||
signature: res.result.signature, // base64
|
||||
extra: { addressType: this.#addressType },
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
try {
|
||||
await Wallet.disconnect();
|
||||
} catch {
|
||||
// sats-connect throws if no session; ignore on teardown.
|
||||
} finally {
|
||||
this.#address = null;
|
||||
this.#addressType = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Bitcoin login-signature verifier.
|
||||
*
|
||||
* Two signing conventions are supported, both verifying a CAIP-122 message
|
||||
* against a BTC address (P2PKH '1…', P2WPKH 'bc1q…', P2TR 'bc1p…'):
|
||||
*
|
||||
* 1. Legacy "Bitcoin Signed Message" — recoverable ECDSA over the
|
||||
* double-SHA256 of the magic-prefixed message. Primary path; works for
|
||||
* every address type (the wallet picks the key form via the header byte).
|
||||
*
|
||||
* 2. BIP-322 "simple" — a virtual to_spend/to_sign transaction pair whose
|
||||
* witness is verified with BIP-143 (P2WPKH, ECDSA) or BIP-341 (P2TR,
|
||||
* Schnorr) sighash. Used when the signature is a serialized witness stack
|
||||
* rather than a 65-byte recoverable sig.
|
||||
*
|
||||
* Security posture: fail closed. Every parse/branch returns `false` on the
|
||||
* slightest irregularity and the whole function is wrapped so it never throws.
|
||||
* The recovered/derived address must match the *type* claimed by the proof and
|
||||
* be byte-for-byte equal to `proof.address`.
|
||||
*/
|
||||
import { secp256k1, schnorr } from '@noble/curves/secp256k1';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { ripemd160 } from '@noble/hashes/legacy';
|
||||
import type { SignedProof } from '../types.js';
|
||||
import { base64ToBytes, utf8ToBytes, concatBytes } from '../bytes.js';
|
||||
import { encodeSegwitAddress } from './bech32.js';
|
||||
import { base58checkEncode } from './base58check.js';
|
||||
|
||||
// ── address type ──────────────────────────────────────────────────────────
|
||||
|
||||
type BtcAddressType = 'p2pkh' | 'p2wpkh' | 'p2tr';
|
||||
|
||||
/** Bitcoin mainnet bech32 human-readable part. */
|
||||
const HRP = 'bc';
|
||||
|
||||
/** Determine the address type from an explicit hint, else from the prefix. */
|
||||
function addressType(proof: SignedProof): BtcAddressType | null {
|
||||
const hint = (proof.extra?.addressType as string | undefined)?.toLowerCase();
|
||||
if (hint === 'p2pkh' || hint === 'p2wpkh' || hint === 'p2tr') return hint;
|
||||
|
||||
const a = proof.address;
|
||||
if (a.startsWith('bc1p')) return 'p2tr';
|
||||
if (a.startsWith('bc1q')) return 'p2wpkh';
|
||||
if (a.startsWith('1')) return 'p2pkh';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── hashing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const sha256d = (b: Uint8Array): Uint8Array => sha256(sha256(b));
|
||||
const hash160 = (b: Uint8Array): Uint8Array => ripemd160(sha256(b));
|
||||
|
||||
/** BIP-340 tagged hash: sha256(sha256(tag) || sha256(tag) || msg…). */
|
||||
function taggedHash(tag: string, ...messages: Uint8Array[]): Uint8Array {
|
||||
const tagHash = sha256(utf8ToBytes(tag));
|
||||
return sha256(concatBytes(tagHash, tagHash, ...messages));
|
||||
}
|
||||
|
||||
// ── address derivation (pubkey → address string) ────────────────────────────
|
||||
|
||||
function deriveP2PKH(pubkey: Uint8Array): string {
|
||||
// base58check( 0x00 || hash160(pubkey) )
|
||||
return base58checkEncode(0x00, hash160(pubkey));
|
||||
}
|
||||
|
||||
function deriveP2WPKH(pubkeyCompressed: Uint8Array): string | null {
|
||||
// bech32(hrp='bc', witver=0, program=hash160(compressed pubkey))
|
||||
return encodeSegwitAddress(HRP, 0, hash160(pubkeyCompressed));
|
||||
}
|
||||
|
||||
/** x-only (32-byte) coordinate of a point given as its affine x bigint. */
|
||||
function xonlyFromBigInt(x: bigint): Uint8Array {
|
||||
const out = new Uint8Array(32);
|
||||
let v = x;
|
||||
for (let i = 31; i >= 0; i--) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-86 key-path taproot output for an internal pubkey.
|
||||
* t = taggedHash('TapTweak', xonly(P))
|
||||
* Q = lift_x(xonly(P)) + t*G // internal key is the even-Y lift
|
||||
* program = xonly(Q)
|
||||
* Returns the 32-byte tweaked x-only program, or null on any failure.
|
||||
*/
|
||||
function taprootTweak(internalXonly: Uint8Array): Uint8Array | null {
|
||||
try {
|
||||
const Point = secp256k1.Point;
|
||||
const n = secp256k1.CURVE.n;
|
||||
const x = bytesToBigInt(internalXonly);
|
||||
const P = schnorr.utils.lift_x(x); // even-Y point with this x
|
||||
const t = bytesToBigInt(taggedHash('TapTweak', internalXonly)) % n;
|
||||
if (t === 0n) return null;
|
||||
const Q = P.add(Point.BASE.multiply(t));
|
||||
const qx = Q.toAffine().x;
|
||||
return xonlyFromBigInt(qx);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveP2TR(internalXonly: Uint8Array): string | null {
|
||||
const program = taprootTweak(internalXonly);
|
||||
if (program === null) return null;
|
||||
return encodeSegwitAddress(HRP, 1, program);
|
||||
}
|
||||
|
||||
function bytesToBigInt(b: Uint8Array): bigint {
|
||||
let v = 0n;
|
||||
for (const byte of b) v = (v << 8n) | BigInt(byte);
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── Bitcoin var-int / serialization (CompactSize) ───────────────────────────
|
||||
|
||||
function compactSize(n: number): Uint8Array {
|
||||
if (n < 0) throw new Error('negative compactSize');
|
||||
if (n < 0xfd) return new Uint8Array([n]);
|
||||
if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >>> 8) & 0xff]);
|
||||
if (n <= 0xffffffff) {
|
||||
return new Uint8Array([0xfe, n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
}
|
||||
// 64-bit lengths are never needed for login messages.
|
||||
const out = new Uint8Array(9);
|
||||
out[0] = 0xff;
|
||||
let v = BigInt(n);
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function u32le(n: number): Uint8Array {
|
||||
return new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
}
|
||||
|
||||
function u64le(n: bigint): Uint8Array {
|
||||
const out = new Uint8Array(8);
|
||||
let v = n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** length-prefixed (CompactSize) byte string. */
|
||||
function varBytes(b: Uint8Array): Uint8Array {
|
||||
return concatBytes(compactSize(b.length), b);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 1. LEGACY "Bitcoin Signed Message" (recoverable ECDSA)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const MSG_MAGIC = utf8ToBytes('\x18Bitcoin Signed Message:\n');
|
||||
|
||||
/** digest = sha256d( magic || varint(len) || message ). */
|
||||
function legacyMessageDigest(message: string): Uint8Array {
|
||||
const msg = utf8ToBytes(message);
|
||||
return sha256d(concatBytes(MSG_MAGIC, compactSize(msg.length), msg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a 65-byte recoverable signature [header || r || s] over the legacy
|
||||
* message digest, deriving the address of `type` and comparing to `address`.
|
||||
*/
|
||||
function verifyLegacy(
|
||||
sig: Uint8Array,
|
||||
message: string,
|
||||
address: string,
|
||||
type: BtcAddressType,
|
||||
): boolean {
|
||||
if (sig.length !== 65) return false;
|
||||
const header = sig[0]!;
|
||||
// 27-30: uncompressed key; 31-34: compressed key. (BIP-137 also defines
|
||||
// 35-42 for segwit, but the recovered key form is what matters here, so we
|
||||
// accept the canonical 27-34 range and infer compression from it.)
|
||||
if (header < 27 || header > 34) return false;
|
||||
const recid = (header - 27) & 3;
|
||||
const compressed = header >= 31;
|
||||
|
||||
const r = sig.slice(1, 33);
|
||||
const s = sig.slice(33, 65);
|
||||
const digest = legacyMessageDigest(message);
|
||||
|
||||
let point;
|
||||
try {
|
||||
point = secp256k1.Signature.fromCompact(concatBytes(r, s))
|
||||
.addRecoveryBit(recid)
|
||||
.recoverPublicKey(digest);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For segwit address types the key MUST be compressed — an uncompressed key
|
||||
// cannot back a P2WPKH/P2TR script, so reject rather than silently coercing.
|
||||
if ((type === 'p2wpkh' || type === 'p2tr') && !compressed) return false;
|
||||
|
||||
let derived: string | null;
|
||||
switch (type) {
|
||||
case 'p2pkh': {
|
||||
// P2PKH commits to the exact key encoding chosen by the header byte.
|
||||
const pub = point.toBytes(compressed);
|
||||
derived = deriveP2PKH(pub);
|
||||
break;
|
||||
}
|
||||
case 'p2wpkh': {
|
||||
derived = deriveP2WPKH(point.toBytes(true));
|
||||
break;
|
||||
}
|
||||
case 'p2tr': {
|
||||
// Internal key = x-only of the recovered (compressed) key.
|
||||
const xonly = point.toBytes(true).slice(1);
|
||||
derived = deriveP2TR(xonly);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return derived !== null && constTimeStrEq(derived, address);
|
||||
}
|
||||
|
||||
/** Length-checked, content-comparing string equality (addresses are public). */
|
||||
function constTimeStrEq(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 2. BIP-322 "simple"
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Parse a serialized witness stack: count, then length-prefixed elements. */
|
||||
function parseWitness(buf: Uint8Array): Uint8Array[] | null {
|
||||
let off = 0;
|
||||
const readCompact = (): number | null => {
|
||||
if (off >= buf.length) return null;
|
||||
const first = buf[off++]!;
|
||||
if (first < 0xfd) return first;
|
||||
if (first === 0xfd) {
|
||||
if (off + 2 > buf.length) return null;
|
||||
const v = buf[off]! | (buf[off + 1]! << 8);
|
||||
off += 2;
|
||||
return v;
|
||||
}
|
||||
if (first === 0xfe) {
|
||||
if (off + 4 > buf.length) return null;
|
||||
const v = buf[off]! | (buf[off + 1]! << 8) | (buf[off + 2]! << 16) | (buf[off + 3]! * 0x1000000);
|
||||
off += 4;
|
||||
return v;
|
||||
}
|
||||
return null; // 64-bit lengths never appear in witness logins
|
||||
};
|
||||
|
||||
const count = readCompact();
|
||||
if (count === null || count < 1 || count > 4) return null;
|
||||
const items: Uint8Array[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const len = readCompact();
|
||||
if (len === null || len < 0 || off + len > buf.length) return null;
|
||||
items.push(buf.slice(off, off + len));
|
||||
off += len;
|
||||
}
|
||||
if (off !== buf.length) return null; // no trailing garbage
|
||||
return items;
|
||||
}
|
||||
|
||||
/** scriptPubKey bytes for each supported address type given its program. */
|
||||
function scriptPubKeyP2WPKH(hash160Pub: Uint8Array): Uint8Array {
|
||||
// OP_0 PUSH20 <hash160>
|
||||
return concatBytes(new Uint8Array([0x00, 0x14]), hash160Pub);
|
||||
}
|
||||
function scriptPubKeyP2TR(programXonly: Uint8Array): Uint8Array {
|
||||
// OP_1 PUSH32 <tweaked xonly>
|
||||
return concatBytes(new Uint8Array([0x51, 0x20]), programXonly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the BIP-322 to_spend txid.
|
||||
* to_spend: nVersion=0, vin=[ {prevout=0..00:0xFFFFFFFF,
|
||||
* scriptSig=OP_0 PUSH32 <msgHash>, nSequence=0} ],
|
||||
* vout=[ {value=0, scriptPubKey} ], nLockTime=0
|
||||
* txid = sha256d(serialization without witness).
|
||||
*/
|
||||
function toSpendTxid(message: string, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const msgHash = taggedHash('BIP0322-signed-message', utf8ToBytes(message));
|
||||
const scriptSig = concatBytes(new Uint8Array([0x00, 0x20]), msgHash); // OP_0 PUSH32
|
||||
|
||||
const ser = concatBytes(
|
||||
u32le(0), // nVersion = 0
|
||||
compactSize(1), // vin count
|
||||
new Uint8Array(32), // prevout hash = 0
|
||||
u32le(0xffffffff), // prevout index = 0xFFFFFFFF
|
||||
varBytes(scriptSig),
|
||||
u32le(0), // nSequence = 0
|
||||
compactSize(1), // vout count
|
||||
u64le(0n), // value = 0
|
||||
varBytes(scriptPubKey),
|
||||
u32le(0), // nLockTime = 0
|
||||
);
|
||||
return sha256d(ser);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-143 sighash for the single input of the BIP-322 to_sign tx (P2WPKH).
|
||||
* SIGHASH_ALL. scriptCode for P2WPKH = OP_DUP OP_HASH160 PUSH20 <h160>
|
||||
* OP_EQUALVERIFY OP_CHECKSIG.
|
||||
*/
|
||||
function bip143SighashP2WPKH(toSpendTxid: Uint8Array, hash160Pub: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(toSpendTxid, u32le(0)); // to_spend:0
|
||||
const nSequence = u32le(0);
|
||||
const hashPrevouts = sha256d(outpoint);
|
||||
const hashSequence = sha256d(nSequence);
|
||||
|
||||
const scriptCode = concatBytes(
|
||||
new Uint8Array([0x19, 0x76, 0xa9, 0x14]), // len(25) OP_DUP OP_HASH160 PUSH20
|
||||
hash160Pub,
|
||||
new Uint8Array([0x88, 0xac]), // OP_EQUALVERIFY OP_CHECKSIG
|
||||
);
|
||||
|
||||
// to_sign single output: value=0, scriptPubKey = OP_RETURN (0x6a).
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
|
||||
const hashOutputs = sha256d(output);
|
||||
|
||||
const preimage = concatBytes(
|
||||
u32le(0), // nVersion = 0
|
||||
hashPrevouts,
|
||||
hashSequence,
|
||||
outpoint,
|
||||
scriptCode,
|
||||
u64le(0n), // amount of the spent output = 0
|
||||
nSequence,
|
||||
hashOutputs,
|
||||
u32le(0), // nLockTime = 0
|
||||
u32le(1), // SIGHASH_ALL
|
||||
);
|
||||
return sha256d(preimage);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-341 (taproot key-path) sighash for the single input of the to_sign tx,
|
||||
* SIGHASH_DEFAULT (0x00). Single P2TR input, single OP_RETURN output.
|
||||
*/
|
||||
function bip341SighashP2TR(toSpendTxid: Uint8Array, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(toSpendTxid, u32le(0));
|
||||
const nSequence = u32le(0);
|
||||
|
||||
const shaPrevouts = sha256(outpoint);
|
||||
const shaAmounts = sha256(u64le(0n)); // single spent amount = 0
|
||||
const shaScriptPubkeys = sha256(varBytes(scriptPubKey));
|
||||
const shaSequences = sha256(nSequence);
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a]))); // value=0, OP_RETURN
|
||||
const shaOutputs = sha256(output);
|
||||
|
||||
const epoch = new Uint8Array([0x00]);
|
||||
const hashType = new Uint8Array([0x00]); // SIGHASH_DEFAULT
|
||||
const spendType = new Uint8Array([0x00]); // no annex, key-path
|
||||
const inputIndex = u32le(0);
|
||||
|
||||
const sigMsg = concatBytes(
|
||||
hashType,
|
||||
u32le(0), // nVersion = 0
|
||||
u32le(0), // nLockTime = 0
|
||||
shaPrevouts,
|
||||
shaAmounts,
|
||||
shaScriptPubkeys,
|
||||
shaSequences,
|
||||
shaOutputs,
|
||||
spendType,
|
||||
inputIndex,
|
||||
);
|
||||
// BIP-341: tagged hash "TapSighash" over (epoch || sigMsg).
|
||||
return taggedHash('TapSighash', concatBytes(epoch, sigMsg));
|
||||
}
|
||||
|
||||
/** Strip a trailing SIGHASH byte from a DER ECDSA signature, returning (der, sighash). */
|
||||
function splitDerSighash(witnessSig: Uint8Array): { der: Uint8Array; sighash: number } | null {
|
||||
if (witnessSig.length < 1) return null;
|
||||
const sighash = witnessSig[witnessSig.length - 1]!;
|
||||
return { der: witnessSig.slice(0, witnessSig.length - 1), sighash };
|
||||
}
|
||||
|
||||
/** Parse a 64- or 65-byte BIP-340 schnorr sig (optional trailing sighash). */
|
||||
function splitSchnorrSighash(witnessSig: Uint8Array): { sig: Uint8Array; sighash: number } | null {
|
||||
if (witnessSig.length === 64) return { sig: witnessSig, sighash: 0x00 };
|
||||
if (witnessSig.length === 65) {
|
||||
const sighash = witnessSig[64]!;
|
||||
return { sig: witnessSig.slice(0, 64), sighash };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function verifyBip322P2WPKH(
|
||||
witness: Uint8Array[],
|
||||
message: string,
|
||||
address: string,
|
||||
): boolean {
|
||||
// Witness stack for P2WPKH is exactly [signature, pubkey].
|
||||
if (witness.length !== 2) return false;
|
||||
const [sigBytes, pubkey] = witness as [Uint8Array, Uint8Array];
|
||||
if (pubkey.length !== 33 || (pubkey[0] !== 0x02 && pubkey[0] !== 0x03)) return false;
|
||||
|
||||
// Address binding: the witness pubkey must hash to the claimed P2WPKH address.
|
||||
const h160 = hash160(pubkey);
|
||||
const derived = deriveP2WPKH(pubkey);
|
||||
if (derived === null || !constTimeStrEq(derived, address)) return false;
|
||||
|
||||
const parsed = splitDerSighash(sigBytes);
|
||||
if (parsed === null) return false;
|
||||
// BIP-322 simple for single-key uses SIGHASH_ALL.
|
||||
if (parsed.sighash !== 0x01) return false;
|
||||
|
||||
const txid = toSpendTxid(message, scriptPubKeyP2WPKH(h160));
|
||||
const sighash = bip143SighashP2WPKH(txid, h160);
|
||||
|
||||
try {
|
||||
const sig = secp256k1.Signature.fromDER(parsed.der);
|
||||
// Reject high-S (BIP-146 / consensus-standardness, anti-malleability).
|
||||
if (sig.hasHighS()) return false;
|
||||
return secp256k1.verify(sig.toCompactRawBytes(), sighash, pubkey, { lowS: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function verifyBip322P2TR(
|
||||
witness: Uint8Array[],
|
||||
message: string,
|
||||
address: string,
|
||||
): boolean {
|
||||
// Key-path spend: witness is exactly [schnorr_sig].
|
||||
if (witness.length !== 1) return false;
|
||||
const parsed = splitSchnorrSighash(witness[0]!);
|
||||
if (parsed === null) return false;
|
||||
if (parsed.sighash !== 0x00) return false; // SIGHASH_DEFAULT only
|
||||
|
||||
// Recover the tweaked output key from the claimed address by re-deriving the
|
||||
// scriptPubKey from… the address itself: we must decode the program. Since we
|
||||
// only have the address string, derive the program by trusting the bech32m
|
||||
// body is the output key. We re-encode and compare, then verify schnorr
|
||||
// against that x-only output key.
|
||||
const program = decodeP2TRProgram(address);
|
||||
if (program === null) return false;
|
||||
|
||||
const txid = toSpendTxid(message, scriptPubKeyP2TR(program));
|
||||
const sighash = bip341SighashP2TR(txid, scriptPubKeyP2TR(program));
|
||||
|
||||
try {
|
||||
return schnorr.verify(parsed.sig, sighash, program);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a bech32m P2TR ('bc1p…') address to its 32-byte witness program.
|
||||
* Minimal decoder used only to recover the output key for schnorr verify; it
|
||||
* re-validates the checksum by re-encoding and comparing (fail closed).
|
||||
*/
|
||||
function decodeP2TRProgram(address: string): Uint8Array | null {
|
||||
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
const lower = address.toLowerCase();
|
||||
if (lower !== address && address.toUpperCase() !== address) return null; // mixed case
|
||||
const pos = lower.lastIndexOf('1');
|
||||
if (pos < 1) return null;
|
||||
const hrp = lower.slice(0, pos);
|
||||
if (hrp !== HRP) return null;
|
||||
const dataPart = lower.slice(pos + 1);
|
||||
if (dataPart.length < 7) return null; // 1 (witver) + program + 6 checksum
|
||||
|
||||
const values: number[] = [];
|
||||
for (const ch of dataPart) {
|
||||
const v = CHARSET.indexOf(ch);
|
||||
if (v === -1) return null;
|
||||
values.push(v);
|
||||
}
|
||||
const witver = values[0]!;
|
||||
if (witver !== 1) return null; // only taproot here
|
||||
|
||||
// Convert 5-bit data (excluding witver and 6-byte checksum) → 8-bit program.
|
||||
const data5 = values.slice(1, values.length - 6);
|
||||
const program = convert5to8(data5);
|
||||
if (program === null || program.length !== 32) return null;
|
||||
|
||||
// Re-encode with bech32m and compare to validate the checksum.
|
||||
const reencoded = encodeSegwitAddress(HRP, 1, program);
|
||||
if (reencoded === null || reencoded !== lower) return null;
|
||||
return program;
|
||||
}
|
||||
|
||||
/** 5-bit groups → 8-bit bytes (frombits=5, tobits=8, pad=false). */
|
||||
function convert5to8(data: number[]): Uint8Array | null {
|
||||
let acc = 0;
|
||||
let bits = 0;
|
||||
const out: number[] = [];
|
||||
for (const value of data) {
|
||||
if (value < 0 || value >> 5 !== 0) return null;
|
||||
acc = ((acc << 5) | value) & 0xffffffff;
|
||||
bits += 5;
|
||||
while (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push((acc >>> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
// Reject if leftover bits form a non-zero pad (strict, per BIP-173).
|
||||
if (bits >= 5) return null;
|
||||
if ((acc << (8 - bits)) & 0xff) return null;
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// dispatch
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function verifyBitcoin(proof: SignedProof): boolean {
|
||||
try {
|
||||
const type = addressType(proof);
|
||||
if (type === null) return false;
|
||||
|
||||
let sig: Uint8Array;
|
||||
try {
|
||||
sig = base64ToBytes(proof.signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (sig.length === 0) return false;
|
||||
|
||||
// Shape-based dispatch (exactly one path per shape):
|
||||
// • 65 bytes with a valid header → legacy recoverable ECDSA.
|
||||
// • otherwise → BIP-322 simple (serialized witness stack).
|
||||
const header = sig[0]!;
|
||||
const looksLegacy = sig.length === 65 && header >= 27 && header <= 34;
|
||||
|
||||
if (looksLegacy) {
|
||||
return verifyLegacy(sig, proof.message, proof.address, type);
|
||||
}
|
||||
|
||||
// BIP-322 simple — only P2WPKH and P2TR are defined for key-path here.
|
||||
const witness = parseWitness(sig);
|
||||
if (witness === null) return false;
|
||||
if (type === 'p2wpkh') return verifyBip322P2WPKH(witness, proof.message, proof.address);
|
||||
if (type === 'p2tr') return verifyBip322P2TR(witness, proof.message, proof.address);
|
||||
// BIP-322 for P2PKH is not standardized for "simple"; legacy covers it.
|
||||
return false;
|
||||
} catch {
|
||||
// Absolute backstop: never throw out of a verifier.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Byte helpers shared by every verifier. Cross-runtime (Node + browser). */
|
||||
import { hexToBytes as nobleHexToBytes, bytesToHex, utf8ToBytes, concatBytes } from '@noble/hashes/utils';
|
||||
|
||||
export { bytesToHex, utf8ToBytes, concatBytes };
|
||||
|
||||
/** Hex → bytes, tolerant of a leading 0x. */
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
return nobleHexToBytes(hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex);
|
||||
}
|
||||
|
||||
/** base64 (standard, with padding) → bytes. */
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
if (typeof atob === 'function') {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
// Node
|
||||
return new Uint8Array(Buffer.from(b64, 'base64'));
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
if (typeof btoa === 'function') {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin);
|
||||
}
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
/** Decode a signature that may be hex (0x…) or base64 into raw bytes. */
|
||||
export function decodeSignature(sig: string): Uint8Array {
|
||||
const s = sig.trim();
|
||||
if (s.startsWith('0x') || s.startsWith('0X')) return hexToBytes(s);
|
||||
// Heuristic: pure hex (even length, [0-9a-f]) → hex, else base64.
|
||||
if (/^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0) return hexToBytes(s);
|
||||
return base64ToBytes(s);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* CAIP-122 "Sign-In-With-X" message — the one canonical login string for every
|
||||
* chain. Generalizes EIP-4361 (SIWE) so a Solana / Bitcoin / TON / XRP wallet
|
||||
* signs the exact same human-readable assertion an Ethereum wallet does.
|
||||
*
|
||||
* Spec: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md
|
||||
* (which itself generalizes https://eips.ethereum.org/EIPS/eip-4361)
|
||||
*
|
||||
* build ∘ parse round-trips. Both are pure — no I/O, no clock — so the Go port
|
||||
* for IAM can mirror them byte-for-byte.
|
||||
*/
|
||||
import type { Chain, LoginChallenge } from './types.js';
|
||||
|
||||
/** Human chain label used on the first line of the message. */
|
||||
const CHAIN_LABEL: Record<Chain, string> = {
|
||||
evm: 'Ethereum',
|
||||
solana: 'Solana',
|
||||
bitcoin: 'Bitcoin',
|
||||
ton: 'TON',
|
||||
xrp: 'XRP Ledger',
|
||||
};
|
||||
|
||||
export interface ParsedSiwx {
|
||||
domain: string;
|
||||
address: string;
|
||||
statement?: string;
|
||||
uri: string;
|
||||
version?: string;
|
||||
chainId?: string;
|
||||
nonce: string;
|
||||
issuedAt: string;
|
||||
expirationTime?: string;
|
||||
notBefore?: string;
|
||||
requestId?: string;
|
||||
resources?: string[];
|
||||
}
|
||||
|
||||
export interface BuildParams {
|
||||
challenge: LoginChallenge;
|
||||
address: string;
|
||||
chain: Chain;
|
||||
/** CAIP-2 network id, e.g. 'eip155:1', 'solana:5eykt...'. Optional. */
|
||||
chainId?: string;
|
||||
}
|
||||
|
||||
/** Render a {@link LoginChallenge} to the canonical CAIP-122 message string. */
|
||||
export function buildSiwxMessage(params: BuildParams): string {
|
||||
const { challenge: c, address, chain, chainId } = params;
|
||||
const label = CHAIN_LABEL[chain];
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`${c.domain} wants you to sign in with your ${label} account:`);
|
||||
lines.push(address);
|
||||
lines.push('');
|
||||
// Statement block is optional. When present it sits on its own line between
|
||||
// two blank lines (per EIP-4361 ABNF).
|
||||
if (c.statement != null && c.statement.length > 0) {
|
||||
if (c.statement.includes('\n')) {
|
||||
throw new Error('caip122: statement must be a single line');
|
||||
}
|
||||
lines.push(c.statement);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(`URI: ${c.uri}`);
|
||||
lines.push(`Version: ${c.version ?? '1'}`);
|
||||
if (chainId != null) {
|
||||
lines.push(`Chain ID: ${chainId}`);
|
||||
}
|
||||
lines.push(`Nonce: ${c.nonce}`);
|
||||
lines.push(`Issued At: ${c.issuedAt}`);
|
||||
if (c.expirationTime != null) {
|
||||
lines.push(`Expiration Time: ${c.expirationTime}`);
|
||||
}
|
||||
if (c.notBefore != null) {
|
||||
lines.push(`Not Before: ${c.notBefore}`);
|
||||
}
|
||||
if (c.requestId != null) {
|
||||
lines.push(`Request ID: ${c.requestId}`);
|
||||
}
|
||||
if (c.resources != null && c.resources.length > 0) {
|
||||
lines.push('Resources:');
|
||||
for (const r of c.resources) {
|
||||
lines.push(`- ${r}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const HEADER_RE = /^(?<domain>[^\n]+?) wants you to sign in with your .+ account:$/;
|
||||
const FIELD_RE = /^(?<key>URI|Version|Chain ID|Nonce|Issued At|Expiration Time|Not Before|Request ID): (?<val>.*)$/;
|
||||
|
||||
/** Parse a CAIP-122 message back into its fields. Throws on malformed input. */
|
||||
export function parseSiwxMessage(message: string): ParsedSiwx {
|
||||
const raw = message.split('\n');
|
||||
if (raw.length < 2) {
|
||||
throw new Error('caip122: message too short');
|
||||
}
|
||||
const header = HEADER_RE.exec(raw[0] ?? '');
|
||||
if (!header?.groups) {
|
||||
throw new Error('caip122: malformed header line');
|
||||
}
|
||||
const domain = header.groups.domain;
|
||||
const address = (raw[1] ?? '').trim();
|
||||
if (address.length === 0) {
|
||||
throw new Error('caip122: missing address line');
|
||||
}
|
||||
|
||||
// Everything from line 2 onward: an optional statement block, then fields.
|
||||
const out: Partial<ParsedSiwx> = { domain, address };
|
||||
const resources: string[] = [];
|
||||
let inResources = false;
|
||||
let statementParts: string[] = [];
|
||||
let sawField = false;
|
||||
|
||||
for (let i = 2; i < raw.length; i++) {
|
||||
const line = raw[i] ?? '';
|
||||
if (inResources) {
|
||||
if (line.startsWith('- ')) {
|
||||
resources.push(line.slice(2));
|
||||
continue;
|
||||
}
|
||||
inResources = false;
|
||||
}
|
||||
if (line === 'Resources:') {
|
||||
inResources = true;
|
||||
sawField = true;
|
||||
continue;
|
||||
}
|
||||
const f = FIELD_RE.exec(line);
|
||||
if (f?.groups) {
|
||||
sawField = true;
|
||||
const v = f.groups.val;
|
||||
switch (f.groups.key) {
|
||||
case 'URI': out.uri = v; break;
|
||||
case 'Version': out.version = v; break;
|
||||
case 'Chain ID': out.chainId = v; break;
|
||||
case 'Nonce': out.nonce = v; break;
|
||||
case 'Issued At': out.issuedAt = v; break;
|
||||
case 'Expiration Time': out.expirationTime = v; break;
|
||||
case 'Not Before': out.notBefore = v; break;
|
||||
case 'Request ID': out.requestId = v; break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Pre-field, non-empty, non-field lines are the statement.
|
||||
if (!sawField && line.length > 0) {
|
||||
statementParts.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (statementParts.length > 0) {
|
||||
out.statement = statementParts.join('\n');
|
||||
}
|
||||
if (resources.length > 0) {
|
||||
out.resources = resources;
|
||||
}
|
||||
|
||||
if (out.uri == null || out.nonce == null || out.issuedAt == null) {
|
||||
throw new Error('caip122: missing required field (URI / Nonce / Issued At)');
|
||||
}
|
||||
return out as ParsedSiwx;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Browser wallet connectors — the client side of @luxwallet/connect.
|
||||
*
|
||||
* One factory, one vocabulary: {@link getConnector} returns the
|
||||
* {@link WalletConnector} for a {@link Chain}; every connector produces a
|
||||
* {@link SignedProof} the matching server-side verifier accepts.
|
||||
*
|
||||
* IMPORTANT: this module (and everything it imports) pulls the wallet libraries
|
||||
* (viem, sats-connect, @tonconnect/sdk, @crossmarkio/sdk). The server verify
|
||||
* path (`@luxwallet/connect/verify`) imports NONE of this — keep it that way.
|
||||
*/
|
||||
import type { Chain, WalletConnector } from './types.js';
|
||||
import { EvmConnector } from './evm/connect.js';
|
||||
import { SolanaConnector } from './solana/connect.js';
|
||||
import { BitcoinConnector } from './bitcoin/connect.js';
|
||||
import { TonConnector, type TonConnectorOptions } from './ton/connect.js';
|
||||
import { XrpConnector } from './xrp/connect.js';
|
||||
|
||||
export { EvmConnector } from './evm/connect.js';
|
||||
export { SolanaConnector } from './solana/connect.js';
|
||||
export { BitcoinConnector } from './bitcoin/connect.js';
|
||||
export { TonConnector, type TonConnectorOptions } from './ton/connect.js';
|
||||
export { XrpConnector } from './xrp/connect.js';
|
||||
|
||||
/** Per-chain construction options. Only TON needs one (its dApp manifest URL). */
|
||||
export interface ConnectorOptions {
|
||||
ton?: TonConnectorOptions;
|
||||
}
|
||||
|
||||
/** Build the connector for a chain. Pure construction — no I/O, no window touch. */
|
||||
export function getConnector(chain: Chain, options: ConnectorOptions = {}): WalletConnector {
|
||||
switch (chain) {
|
||||
case 'evm':
|
||||
return new EvmConnector();
|
||||
case 'solana':
|
||||
return new SolanaConnector();
|
||||
case 'bitcoin':
|
||||
return new BitcoinConnector();
|
||||
case 'ton':
|
||||
return new TonConnector(options.ton);
|
||||
case 'xrp':
|
||||
return new XrpConnector();
|
||||
default: {
|
||||
// Exhaustiveness: a new Chain must be handled here.
|
||||
const _never: never = chain;
|
||||
throw new Error(`no connector for chain '${String(_never)}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One connector per supported chain, in canonical order. */
|
||||
export function allConnectors(options: ConnectorOptions = {}): WalletConnector[] {
|
||||
return [
|
||||
getConnector('evm', options),
|
||||
getConnector('solana', options),
|
||||
getConnector('bitcoin', options),
|
||||
getConnector('ton', options),
|
||||
getConnector('xrp', options),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* EVM wallet connector — EIP-191 `personal_sign` over the CAIP-122 message.
|
||||
*
|
||||
* Discovery: EIP-6963 multi-injection (`window.dispatchEvent` /
|
||||
* `eip6963:requestProvider`) when wallets announce themselves, with a fallback
|
||||
* to the legacy single `window.ethereum`. Connection uses viem's `custom`
|
||||
* transport over the chosen EIP-1193 provider; signing uses `personal_sign`.
|
||||
*
|
||||
* The produced {@link SignedProof} is exactly what {@link verifyEvm} accepts:
|
||||
* a 65-byte hex signature, address recoverable from it, scheme
|
||||
* `secp256k1-eip191`. viem stays out of the verify core (see ../verify.ts) —
|
||||
* it lives here, on the browser side only.
|
||||
*/
|
||||
import {
|
||||
createWalletClient,
|
||||
custom,
|
||||
getAddress as toChecksum,
|
||||
type WalletClient,
|
||||
type EIP1193Provider,
|
||||
} from 'viem';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
|
||||
/** EIP-6963 provider announcement detail. */
|
||||
interface Eip6963ProviderInfo {
|
||||
uuid: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
rdns: string;
|
||||
}
|
||||
interface Eip6963ProviderDetail {
|
||||
info: Eip6963ProviderInfo;
|
||||
provider: EIP1193Provider;
|
||||
}
|
||||
|
||||
interface Eip6963AnnounceEvent extends Event {
|
||||
detail: Eip6963ProviderDetail;
|
||||
}
|
||||
|
||||
/** A discovered injected provider, keyed by a stable id. */
|
||||
interface DiscoveredProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
provider: EIP1193Provider;
|
||||
}
|
||||
|
||||
/** window shape we touch — kept local so the browser libs stay optional. */
|
||||
interface EvmWindow {
|
||||
ethereum?: EIP1193Provider & { providers?: EIP1193Provider[] };
|
||||
addEventListener?: typeof addEventListener;
|
||||
removeEventListener?: typeof removeEventListener;
|
||||
dispatchEvent?: typeof dispatchEvent;
|
||||
}
|
||||
|
||||
function getWindow(): EvmWindow | undefined {
|
||||
return typeof window === 'undefined' ? undefined : (window as unknown as EvmWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect EIP-6963 providers. Wallets respond to `eip6963:requestProvider`
|
||||
* synchronously by dispatching `eip6963:announceProvider`; we listen for a
|
||||
* short window and dedupe by rdns.
|
||||
*/
|
||||
function discoverEip6963(win: EvmWindow, waitMs = 300): Promise<DiscoveredProvider[]> {
|
||||
if (typeof win.addEventListener !== 'function' || typeof win.dispatchEvent !== 'function') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const byRdns = new Map<string, DiscoveredProvider>();
|
||||
const onAnnounce = (ev: Event): void => {
|
||||
const e = ev as Eip6963AnnounceEvent;
|
||||
const d = e.detail;
|
||||
if (d?.info?.rdns && d.provider && !byRdns.has(d.info.rdns)) {
|
||||
byRdns.set(d.info.rdns, {
|
||||
id: d.info.rdns,
|
||||
name: d.info.name,
|
||||
icon: d.info.icon,
|
||||
provider: d.provider,
|
||||
});
|
||||
}
|
||||
};
|
||||
win.addEventListener!('eip6963:announceProvider', onAnnounce as EventListener);
|
||||
win.dispatchEvent!(new Event('eip6963:requestProvider'));
|
||||
setTimeout(() => {
|
||||
win.removeEventListener?.('eip6963:announceProvider', onAnnounce as EventListener);
|
||||
resolve([...byRdns.values()]);
|
||||
}, waitMs);
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy fallback: window.ethereum (and any window.ethereum.providers fan-out). */
|
||||
function discoverLegacy(win: EvmWindow): DiscoveredProvider[] {
|
||||
const eth = win.ethereum;
|
||||
if (!eth) return [];
|
||||
const list = Array.isArray(eth.providers) && eth.providers.length > 0 ? eth.providers : [eth];
|
||||
return list.map((provider, i) => ({
|
||||
id: i === 0 ? 'injected' : `injected-${i}`,
|
||||
name: 'Injected Wallet',
|
||||
provider,
|
||||
}));
|
||||
}
|
||||
|
||||
export class EvmConnector implements WalletConnector {
|
||||
readonly chain = 'evm' as const;
|
||||
|
||||
#provider: EIP1193Provider | null = null;
|
||||
#client: WalletClient | null = null;
|
||||
|
||||
/** Discover injected EVM wallets via EIP-6963, falling back to window.ethereum. */
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
const win = getWindow();
|
||||
if (!win) return [];
|
||||
const discovered = await this.#discover(win);
|
||||
return discovered.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
chain: this.chain,
|
||||
icon: d.icon,
|
||||
installed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
async #discover(win: EvmWindow): Promise<DiscoveredProvider[]> {
|
||||
const announced = await discoverEip6963(win);
|
||||
if (announced.length > 0) return announced;
|
||||
return discoverLegacy(win);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an injected wallet. `walletId` selects an EIP-6963 provider by
|
||||
* its rdns (or the legacy `injected[-n]` id); omit it to use the first.
|
||||
*/
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
const win = getWindow();
|
||||
if (!win) throw new Error('evm: no window — connectors are browser-only');
|
||||
|
||||
const discovered = await this.#discover(win);
|
||||
if (discovered.length === 0) {
|
||||
throw new Error('evm: no injected EVM wallet found');
|
||||
}
|
||||
const chosen = walletId != null ? discovered.find((d) => d.id === walletId) : discovered[0];
|
||||
if (!chosen) {
|
||||
throw new Error(`evm: wallet '${walletId}' not found`);
|
||||
}
|
||||
|
||||
const provider = chosen.provider;
|
||||
const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[];
|
||||
if (!Array.isArray(accounts) || accounts.length === 0) {
|
||||
throw new Error('evm: wallet returned no accounts');
|
||||
}
|
||||
const address = toChecksum(accounts[0]!);
|
||||
|
||||
let caip2: string | undefined;
|
||||
try {
|
||||
const chainIdHex = (await provider.request({ method: 'eth_chainId' })) as string;
|
||||
const chainId = Number.parseInt(chainIdHex, 16);
|
||||
if (Number.isFinite(chainId)) caip2 = `eip155:${chainId}`;
|
||||
} catch {
|
||||
// chainId is best-effort; signing does not require it.
|
||||
}
|
||||
|
||||
this.#provider = provider;
|
||||
this.#client = createWalletClient({ account: address, transport: custom(provider) });
|
||||
|
||||
return { chain: this.chain, address, walletId: chosen.id, caip2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CAIP-122 message and have the wallet `personal_sign` it.
|
||||
* Produces a `secp256k1-eip191` proof: 65-byte hex signature over the EIP-191
|
||||
* digest, with the signer recoverable from the signature.
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
if (!this.#client || !this.#provider) {
|
||||
throw new Error('evm: not connected — call connect() first');
|
||||
}
|
||||
const chainId = account.caip2 ?? undefined;
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
chainId,
|
||||
});
|
||||
|
||||
// personal_sign returns a 0x-prefixed 65-byte signature (r‖s‖v).
|
||||
const signature = await this.#client.signMessage({
|
||||
account: account.address as `0x${string}`,
|
||||
message,
|
||||
});
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: 'secp256k1-eip191',
|
||||
address: account.address,
|
||||
message,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.#provider = null;
|
||||
this.#client = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* EVM verifier — EIP-191 `personal_sign` over the CAIP-122 message.
|
||||
*
|
||||
* Recovers the secp256k1 public key from the signature, derives the Ethereum
|
||||
* address (keccak256 of the uncompressed pubkey, last 20 bytes), and compares
|
||||
* it case-insensitively to the claimed address. No viem dependency — pure
|
||||
* @noble so this mirrors 1:1 in the Go port.
|
||||
*/
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { keccak_256 } from '@noble/hashes/sha3';
|
||||
import { utf8ToBytes, concatBytes, hexToBytes, bytesToHex } from '../bytes.js';
|
||||
|
||||
/** keccak256(\x19Ethereum Signed Message:\n<len><msg>). */
|
||||
export function eip191Digest(message: string): Uint8Array {
|
||||
const msg = utf8ToBytes(message);
|
||||
const prefix = utf8ToBytes(`\x19Ethereum Signed Message:\n${msg.length}`);
|
||||
return keccak_256(concatBytes(prefix, msg));
|
||||
}
|
||||
|
||||
/** Lowercased 0x-address derived from an uncompressed (65-byte) public key. */
|
||||
export function addressFromPublicKey(pubUncompressed: Uint8Array): string {
|
||||
// Drop the 0x04 prefix → 64 bytes, hash, take last 20.
|
||||
const body = pubUncompressed.length === 65 ? pubUncompressed.slice(1) : pubUncompressed;
|
||||
const hash = keccak_256(body);
|
||||
return '0x' + bytesToHex(hash.slice(-20));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an EIP-191 signature. Returns the recovered lowercased address, or
|
||||
* null if the signature is malformed / unrecoverable.
|
||||
*/
|
||||
export function recoverEvmAddress(message: string, signature: string): string | null {
|
||||
try {
|
||||
const sig = hexToBytes(signature);
|
||||
if (sig.length !== 65) return null;
|
||||
const compact = sig.slice(0, 64);
|
||||
let v = sig[64]!;
|
||||
// Accept 27/28 (Ethereum) and raw 0/1 recovery ids.
|
||||
if (v >= 27) v -= 27;
|
||||
if (v !== 0 && v !== 1) return null;
|
||||
const digest = eip191Digest(message);
|
||||
const recovered = secp256k1.Signature.fromCompact(compact)
|
||||
.addRecoveryBit(v)
|
||||
.recoverPublicKey(digest);
|
||||
return addressFromPublicKey(recovered.toRawBytes(false));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff `signature` over `message` was produced by `address`. */
|
||||
export function verifyEvm(message: string, signature: string, address: string): boolean {
|
||||
const recovered = recoverEvmAddress(message, signature);
|
||||
if (recovered == null) return false;
|
||||
return recovered.toLowerCase() === address.trim().toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @luxwallet/connect — multi-chain wallet connect + Sign-In-With-X.
|
||||
*
|
||||
* Public surface. One vocabulary ({@link Chain}, {@link SignedProof}), one
|
||||
* canonical login message (CAIP-122), one verifier ({@link verifyProof}).
|
||||
*
|
||||
* MIT licensed, zero GPL — clean of the Uniswap-derived bones that stay
|
||||
* quarantined in luxfi/exchange.
|
||||
*/
|
||||
export type {
|
||||
Chain,
|
||||
SignatureScheme,
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
VerifyExpectation,
|
||||
VerifyResult,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from './types.js';
|
||||
export { CHAINS } from './types.js';
|
||||
|
||||
export { buildSiwxMessage, parseSiwxMessage } from './caip122.js';
|
||||
export type { ParsedSiwx, BuildParams } from './caip122.js';
|
||||
|
||||
export { generateNonce, newChallenge } from './nonce.js';
|
||||
|
||||
export { verifyProof } from './verify.js';
|
||||
|
||||
// Per-chain primitives (useful standalone; the connectors build on them).
|
||||
export { verifyEvm, recoverEvmAddress, eip191Digest } from './evm/verify.js';
|
||||
export { verifySolana } from './solana/verify.js';
|
||||
export { verifyTon } from './ton/verify.js';
|
||||
export { verifyBitcoin } from './bitcoin/verify.js';
|
||||
export { verifyXrp } from './xrp/verify.js';
|
||||
|
||||
// Browser wallet connectors + the high-level login flow. These import the
|
||||
// wallet libraries (viem, sats-connect, @tonconnect/sdk, @crossmarkio/sdk);
|
||||
// the server verify path above pulls NONE of them.
|
||||
export {
|
||||
getConnector,
|
||||
allConnectors,
|
||||
EvmConnector,
|
||||
SolanaConnector,
|
||||
BitcoinConnector,
|
||||
TonConnector,
|
||||
XrpConnector,
|
||||
} from './connectors.js';
|
||||
export type { ConnectorOptions, TonConnectorOptions } from './connectors.js';
|
||||
export { loginWithWallet } from './login.js';
|
||||
export type { LoginWithWalletParams, LoginResult } from './login.js';
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* loginWithWallet — the one client-side login flow.
|
||||
*
|
||||
* Ties a connector's connect → signLogin into a single call: pick the chain,
|
||||
* connect the wallet, sign the server-issued {@link LoginChallenge}, and return
|
||||
* the {@link SignedProof}. The caller (or its server) mints the challenge and
|
||||
* later verifies the proof with {@link import('./verify.js').verifyProof}.
|
||||
*
|
||||
* server: newChallenge() ─► client: loginWithWallet({chain, challenge})
|
||||
* ─► SignedProof ─► server: verifyProof(proof, {domain, nonce})
|
||||
*
|
||||
* This module imports connectors, so it carries the wallet libs. Keep it out of
|
||||
* the server verify path.
|
||||
*/
|
||||
import type { Account, Chain, LoginChallenge, SignedProof } from './types.js';
|
||||
import { getConnector, type ConnectorOptions } from './connectors.js';
|
||||
|
||||
export interface LoginWithWalletParams {
|
||||
/** Which chain's wallet to authenticate. */
|
||||
chain: Chain;
|
||||
/** Server-minted challenge to sign (domain, nonce, uri, times). */
|
||||
challenge: LoginChallenge;
|
||||
/** Specific wallet id to target (else the connector's default). */
|
||||
walletId?: string;
|
||||
/** Per-chain connector options (e.g. TON manifest URL). */
|
||||
options?: ConnectorOptions;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
account: Account;
|
||||
proof: SignedProof;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a wallet on `chain` and sign `challenge`, returning the connected
|
||||
* account and the {@link SignedProof}. Throws if no wallet is available or the
|
||||
* user rejects; the connector is disconnected on failure to avoid a dangling
|
||||
* session.
|
||||
*/
|
||||
export async function loginWithWallet(params: LoginWithWalletParams): Promise<LoginResult> {
|
||||
const connector = getConnector(params.chain, params.options);
|
||||
try {
|
||||
const account = await connector.connect(params.walletId);
|
||||
const proof = await connector.signLogin(account, params.challenge);
|
||||
return { account, proof };
|
||||
} catch (err) {
|
||||
await connector.disconnect().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Single-use login nonces. The server mints one per challenge, stores it, and
|
||||
* burns it on verify so a captured proof cannot be replayed.
|
||||
*/
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
/** Cryptographically-random alphanumeric nonce (default 16 chars, ~95 bits). */
|
||||
export function generateNonce(length = 16): string {
|
||||
if (length < 8) {
|
||||
throw new Error('nonce: length must be >= 8 (CAIP-122 minimum)');
|
||||
}
|
||||
const bytes = new Uint8Array(length);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
let out = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += ALPHABET[bytes[i]! % ALPHABET.length];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a {@link import('./types.js').LoginChallenge} with sane defaults. */
|
||||
export function newChallenge(opts: {
|
||||
domain: string;
|
||||
uri: string;
|
||||
statement?: string;
|
||||
nonce?: string;
|
||||
/** TTL in seconds for the expirationTime field. Default 600 (10 min). */
|
||||
ttlSeconds?: number;
|
||||
/** Epoch ms for "now"; injectable for tests. */
|
||||
now?: number;
|
||||
requestId?: string;
|
||||
resources?: string[];
|
||||
}) {
|
||||
const nowMs = opts.now ?? Date.now();
|
||||
const issuedAt = new Date(nowMs).toISOString();
|
||||
const ttl = opts.ttlSeconds ?? 600;
|
||||
const expirationTime = new Date(nowMs + ttl * 1000).toISOString();
|
||||
return {
|
||||
domain: opts.domain,
|
||||
uri: opts.uri,
|
||||
statement: opts.statement,
|
||||
nonce: opts.nonce ?? generateNonce(),
|
||||
issuedAt,
|
||||
expirationTime,
|
||||
version: '1',
|
||||
requestId: opts.requestId,
|
||||
resources: opts.resources,
|
||||
};
|
||||
}
|
||||