Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6813e8a304 | ||
|
|
87ca9544a2 | ||
|
|
05237b0470 | ||
|
|
ac70eb41d1 | ||
|
|
18756a8ce2 | ||
|
|
73346553ce | ||
|
|
23950b9ea4 | ||
|
|
331675c945 | ||
|
|
5ab47aeb5d | ||
|
|
fea1660363 | ||
|
|
5f01fb90f4 | ||
|
|
0da7d5b33a | ||
|
|
787352b0eb | ||
|
|
7c6f148435 | ||
|
|
f2fc1e4539 | ||
|
|
c15300493c | ||
|
|
a39ebf4b4e | ||
|
|
36fb502b13 | ||
|
|
75ea9b6bae | ||
|
|
7183bb69c8 | ||
|
|
2fe32a731e | ||
|
|
1ac8c599b4 | ||
|
|
13413d41a6 | ||
|
|
470aa48408 | ||
|
|
bd04657638 | ||
|
|
793b9b37bc | ||
|
|
255c9622fb | ||
|
|
5d411ff26a | ||
|
|
c78eddd735 | ||
|
|
1e919446b6 | ||
|
|
66c6ebee63 | ||
|
|
ce9de2e9cb | ||
|
|
dddb1bb637 | ||
|
|
06a360e71e | ||
|
|
23c5b47eea | ||
|
|
c2db6cc171 | ||
|
|
9c17d55ebb | ||
|
|
b4b87be598 | ||
|
|
c89793f14d | ||
|
|
446a46a465 | ||
|
|
c9943fb883 | ||
|
|
b774772a92 | ||
|
|
9477777e6b | ||
|
|
e9cca37728 | ||
|
|
e22fef1f2a | ||
|
|
7a72225f58 | ||
|
|
0184f80346 | ||
|
|
3b067475d1 | ||
|
|
cdd9590e2a | ||
|
|
48acbaaf92 | ||
|
|
c8a14ce212 | ||
|
|
6b12efdde5 | ||
|
|
483b5e177c | ||
|
|
818a7d9079 | ||
|
|
d378b6d14b | ||
|
|
ad3e6daf48 | ||
|
|
c6d53f678c | ||
|
|
1c1cd9a803 | ||
|
|
8a556d0094 | ||
|
|
55c7d907de | ||
|
|
41d2409117 | ||
|
|
d05a53d3cb | ||
|
|
93b3524a42 | ||
|
|
8aee66d029 | ||
|
|
2d4e759dfb | ||
|
|
a7e917f348 | ||
|
|
6907c88383 | ||
|
|
bc890fd0d0 | ||
|
|
876be989fd | ||
|
|
8d2ad1891a | ||
|
|
1acb94ac65 | ||
|
|
2cd1979cb0 | ||
|
|
08a19b8dcd | ||
|
|
7bbfb77cd7 | ||
|
|
ffe5d2b9b2 | ||
|
|
4226ef57a7 | ||
|
|
e74c3ffdae | ||
|
|
403ff90b10 | ||
|
|
09ae6190b6 | ||
|
|
33cc55e837 | ||
|
|
dfe7eff66d | ||
|
|
cc7885c680 | ||
|
|
ec17532fb1 | ||
|
|
f2596c4788 | ||
|
|
aa10455c43 | ||
|
|
2b3f9330e7 | ||
|
|
45ffd21a85 | ||
|
|
9bfbb8add2 | ||
|
|
6fb61a4817 | ||
|
|
da19c5f208 | ||
|
|
7f609193ff | ||
|
|
a87b7500e5 | ||
|
|
bf734cf3cf | ||
|
|
23d0c0e7a8 | ||
|
|
5ace3df75c | ||
|
|
436931bb53 | ||
|
|
aee84762b9 | ||
|
|
2ceecf49a5 | ||
|
|
e9d3ae9d07 | ||
|
|
f5d2f13499 | ||
|
|
09239ed16c | ||
|
|
f8640af7bc | ||
|
|
b914f74144 | ||
|
|
f4e7c0d145 | ||
|
|
55d7d3f411 | ||
|
|
0a97192506 | ||
|
|
9cf009a8ac | ||
|
|
d928ecdfe3 | ||
|
|
47e477d599 | ||
|
|
87d2da9c74 | ||
|
|
9517f73345 | ||
|
|
218528569e | ||
|
|
4cc44563b8 | ||
|
|
6df3f160b8 | ||
|
|
373e00516e | ||
|
|
648cbe8e65 | ||
|
|
0c190b08e3 | ||
|
|
db509e068a | ||
|
|
cdb83c111d | ||
|
|
b865303473 | ||
|
|
33b46504b3 | ||
|
|
f483ee6791 | ||
|
|
f807b6a11c | ||
|
|
7757aa1c74 | ||
|
|
7ce73662b2 | ||
|
|
f0b9750325 | ||
|
|
583e406fa6 | ||
|
|
064ea5666b | ||
|
|
87ecfef2a2 | ||
|
|
9d906eda2a | ||
|
|
e9ec7689a8 | ||
|
|
140a000794 | ||
|
|
43f7d74249 | ||
|
|
1c1d6873af | ||
|
|
6c1f661f17 | ||
|
|
5de04a2b46 | ||
|
|
1ea5fe9cdb | ||
|
|
7360334996 | ||
|
|
154d0b3b7d | ||
|
|
b116ea118d | ||
|
|
8745f62bfd | ||
|
|
80ced70133 | ||
|
|
d89222ad27 | ||
|
|
1c060e6886 | ||
|
|
db7acceca8 | ||
|
|
0b144a53e5 | ||
|
|
fe13917088 | ||
|
|
616a1131a6 | ||
|
|
fbb5da8c70 | ||
|
|
a6b37b02d7 | ||
|
|
23cac42399 | ||
|
|
4a8d73b0fb | ||
|
|
0e10986cc6 | ||
|
|
369b0702b9 | ||
|
|
0cf50f1f1b | ||
|
|
52bb873f87 | ||
|
|
ddd5f4c409 | ||
|
|
af8819ba60 | ||
|
|
e303575006 | ||
|
|
04b2a0324d | ||
|
|
106818b9a1 | ||
|
|
7c67359f6f | ||
|
|
5d29cf4a55 | ||
|
|
03217a97be | ||
|
|
b879084af0 | ||
|
|
324c698dd5 | ||
|
|
b425ce9979 | ||
|
|
ba9363cf7a | ||
|
|
be4ea25d2f | ||
|
|
584e6cd679 | ||
|
|
29e85db41d | ||
|
|
fe30228bdc | ||
|
|
d6af481d72 | ||
|
|
d0863532d4 | ||
|
|
a22f3c9648 | ||
|
|
68c33527b9 | ||
|
|
6c7e083100 | ||
|
|
6af816ac9c | ||
|
|
f6b7b9327c | ||
|
|
84a529cd0a | ||
|
|
dc3f888355 | ||
|
|
bfbca725f2 | ||
|
|
6e33166f13 | ||
|
|
6265944602 | ||
|
|
7d91d22904 | ||
|
|
3e31fe7373 | ||
|
|
670f69f604 | ||
|
|
8163c3c2a8 | ||
|
|
647bb88353 | ||
|
|
8cc5428e97 | ||
|
|
2365f83855 | ||
|
|
d402732ebf | ||
|
|
3262d9e47e | ||
|
|
9590dc319d | ||
|
|
2132c13e0c | ||
|
|
475d0291b4 | ||
|
|
818ae385e5 | ||
|
|
ab4f666a2b | ||
|
|
5b9555ada6 | ||
|
|
5372a45fc7 | ||
|
|
704b6989a4 | ||
|
|
bf6acb79d8 | ||
|
|
b0b0c1addf | ||
|
|
60f914894d | ||
|
|
172b4ddf6d | ||
|
|
7503329462 | ||
|
|
c0f11dcf28 | ||
|
|
4186888067 | ||
|
|
f04eb29f28 | ||
|
|
8fce0ef2f2 | ||
|
|
70b1330df2 | ||
|
|
1e43cfe72a | ||
|
|
583e3454dc | ||
|
|
dd9addbb94 | ||
|
|
7a9ac97526 | ||
|
|
d4540a08f8 | ||
|
|
cff508e2f0 | ||
|
|
99b619bcaa | ||
|
|
ef6263d666 | ||
|
|
9cc264b867 | ||
|
|
98c78c7ad1 | ||
|
|
4a7eacdb64 | ||
|
|
4c89defe95 | ||
|
|
7c00afb201 | ||
|
|
fb06c01189 | ||
|
|
9f184bb20a | ||
|
|
5dfbbad5c1 | ||
|
|
c4c2760374 | ||
|
|
d43e700f41 | ||
|
|
f118890485 | ||
|
|
be087c496a | ||
|
|
ca8dd4ab74 | ||
|
|
b7628eee35 | ||
|
|
bdb08d0d98 | ||
|
|
7753b0ce78 | ||
|
|
70a14dc902 | ||
|
|
8d7031cf3d | ||
|
|
1dbc79387b | ||
|
|
30c7d0a8c4 | ||
|
|
74d41880b5 | ||
|
|
9b54655329 | ||
|
|
7853644811 | ||
|
|
17b98ae11c | ||
|
|
b1ed54303c | ||
|
|
feca00917a | ||
|
|
d3dbc1e132 | ||
|
|
43371d76f4 | ||
|
|
f211991862 | ||
|
|
6678b9b9fd | ||
|
|
3e774999da | ||
|
|
6748c62e22 | ||
|
|
8545a85d5d | ||
|
|
7e359f279c | ||
|
|
396b0700f5 | ||
|
|
981cce26e3 | ||
|
|
dbaf3079f5 | ||
|
|
86c961bbaf | ||
|
|
293787a9ff | ||
|
|
fce00d8549 | ||
|
|
0dd2a82d24 | ||
|
|
919d261d0a | ||
|
|
6957e9a07d | ||
|
|
8b85781327 | ||
|
|
aaf43491ab | ||
|
|
3d2001dab7 | ||
|
|
63ddef635d | ||
|
|
3bdccc0095 | ||
|
|
8ac9f1e2a4 | ||
|
|
a8a6ad353d | ||
|
|
2fae3831be | ||
|
|
dddac31dc5 | ||
|
|
b8cba121a9 | ||
|
|
4578efbfc5 | ||
|
|
1de63b9cc3 | ||
|
|
d4eaeabedb | ||
|
|
3e85378bc2 | ||
|
|
64de2b7441 | ||
|
|
cbae8df738 | ||
|
|
b6748bf8f5 | ||
|
|
bc2106db08 | ||
|
|
0e2f42256f | ||
|
|
4d0f06ee34 | ||
|
|
3f2ceaf839 | ||
|
|
c525074995 | ||
|
|
ffdf3eab1f | ||
|
|
1d3b81bd92 | ||
|
|
8462c5a4c8 | ||
|
|
cc64e11712 | ||
|
|
d1810354f6 | ||
|
|
8f0ee95754 | ||
|
|
9655226fa6 | ||
|
|
9b3b5c8238 | ||
|
|
fbc3b1bf42 | ||
|
|
77f8d6e17d | ||
|
|
fff4b8f38f | ||
|
|
22f1eb3dd1 | ||
|
|
171ba8cb18 | ||
|
|
1cd915a4e9 | ||
|
|
7e8c9c4084 | ||
|
|
94d53d7f17 | ||
|
|
5d44b6a246 | ||
|
|
0d506cc23b | ||
|
|
fafbf9fe9d | ||
|
|
cdb960ffa9 | ||
|
|
6f1868e3cf | ||
|
|
b31552bb49 | ||
|
|
b29d16334c | ||
|
|
8a9485b7ba | ||
|
|
5fc7cbe72d | ||
|
|
ed2445da6d | ||
|
|
fa14c7e0ca | ||
|
|
66a21facf8 | ||
|
|
f6fa601c9d |
@@ -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 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- 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
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -7,3 +7,8 @@ out/
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
# Vite + pnpm monorepo
|
||||
apps/*/dist
|
||||
pkgs/*/dist
|
||||
**/tsconfig.tsbuildinfo
|
||||
**/node_modules
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: CI/CD
|
||||
|
||||
# The caller, and deliberately nothing else: triggers plus the import. All real
|
||||
# config is the repo-root hanzo.yml, which platform.hanzo.ai reads too.
|
||||
#
|
||||
# `.hanzo/workflows`, NOT `.github/workflows`, and the difference is whether
|
||||
# anything runs at all:
|
||||
#
|
||||
# * github.com has ZERO self-hosted runners for this org
|
||||
# (/orgs/hanzoai/actions/runners -> total_count 0), so a job asking for
|
||||
# `hanzo-build-linux-amd64` there is never claimed. An unclaimable job does
|
||||
# not fail — it waits out the 24h timeout while the next push queues behind
|
||||
# it. Silence, not an error.
|
||||
# * The `git-runner` StatefulSet registers against the FORGE only
|
||||
# (GIT_INSTANCE_URL=http://hanzo-git.hanzo.svc), and that is the pool which
|
||||
# actually advertises that label.
|
||||
# * Gitea collects workflows from the FIRST of WORKFLOW_DIRS present in the
|
||||
# commit (modules/actions/workflows.go, listWorkflowsInDirs breaks on the
|
||||
# first hit). `.hanzo/workflows` already existed here, so on this forge the
|
||||
# whole of `.github/workflows` was already dark — including the Docker lane
|
||||
# that used to live there.
|
||||
#
|
||||
# The `uses:` path points at .hanzo/workflows for the same reason: reusables
|
||||
# resolve through services/actions.ResolveUses, which enforces the WORKFLOW_DIRS
|
||||
# allowlist on the referenced path too. hanzoai/ci publishes build.yml at both
|
||||
# paths from the same tag, byte-identical apart from the path each names for
|
||||
# itself, so this is the same pipeline at the same @v1.
|
||||
#
|
||||
# NO `paths-ignore`. deploy.yml carried one so that a docs commit would not trip
|
||||
# its "this version already exists" refusal — a guard this lane does not need,
|
||||
# because it derives the next patch instead of going red. Dropping the filter
|
||||
# means the GATES run on every commit to main, including the commits that only
|
||||
# touch a workflow. A change to CI that breaks CI should be caught by CI.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A `v*` tag is a RELEASE and publishes an image named after it, verbatim.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: cicd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Sync from GitHub
|
||||
# git.hanzo.ai is CANONICAL and builds natively; development also lands on
|
||||
# github.com/hanzoai/id. Together with the push-mirror going the other way
|
||||
# (native -> GitHub, sync_on_commit) this is the full bidirectional loop.
|
||||
#
|
||||
# The two compose rather than fight: a native commit reaches GitHub via the
|
||||
# push-mirror, so this job then sees LOCAL == REMOTE and exits "in sync". A
|
||||
# GitHub commit fast-forwards native here, and the resulting push-mirror is a
|
||||
# no-op because GitHub already has it. No echo, no loop.
|
||||
#
|
||||
# ONE deterministic direction per job: an in-cluster PULL. The runner reaches
|
||||
# both ends (GitHub outbound, this forge via the instance URL actions/checkout
|
||||
# already uses), so the sync has no ingress dependency.
|
||||
#
|
||||
# Fast-forward ONLY. A divergence fails LOUDLY here rather than force-pushing
|
||||
# either side and destroying whichever history lost the race.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch: {}
|
||||
concurrency:
|
||||
group: sync-from-github
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
ff-main:
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- name: Checkout main (full history for the ancestry check)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
- name: Fast-forward main from github.com/hanzoai/id
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/id.git" main
|
||||
LOCAL="$(git rev-parse HEAD)"
|
||||
REMOTE="$(git rev-parse FETCH_HEAD)"
|
||||
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||
echo "in sync at $LOCAL"
|
||||
exit 0
|
||||
fi
|
||||
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
|
||||
echo "fast-forwarding $LOCAL -> $REMOTE"
|
||||
git push origin "$REMOTE:refs/heads/main"
|
||||
# A push made with the workflow token does NOT trigger other workflows
|
||||
# (loop prevention), so synced commits would never build. Dispatch it
|
||||
# explicitly — a real fast-forward means real commits arrived.
|
||||
#
|
||||
# This names the workflow BY FILENAME, so it is a hard reference to a
|
||||
# file in this repo and moves when that file does. It pointed at
|
||||
# deploy.yml, which no longer exists; the `|| echo` below makes that a
|
||||
# non-fatal 404, so the symptom would not have been a red sync — it
|
||||
# would have been commits arriving on the forge and NOTHING building,
|
||||
# silently, which is the exact failure this repo already survived once.
|
||||
curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/cicd.yml/dispatches" \
|
||||
-d '{"ref":"main"}' || echo "build dispatch failed (non-fatal)"
|
||||
else
|
||||
echo "DIVERGED: native $LOCAL is not an ancestor of GitHub $REMOTE." >&2
|
||||
echo "Resolve by hand; this job will not force-push either side." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,54 +1,92 @@
|
||||
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
|
||||
# THE LOCKFILE SHIPS, and the install is frozen to it.
|
||||
#
|
||||
# This used to omit pnpm-lock.yaml and run `--frozen-lockfile=false`, so the
|
||||
# image resolved the whole tree FRESH on every build while `pnpm test` on the
|
||||
# runner resolved it from the lockfile. Two different dependency graphs from one
|
||||
# commit: the tested one, and the shipped one. It went green for as long as free
|
||||
# resolution happened to agree, and stopped the moment it did not — adding one
|
||||
# dependency (@hanzo/event) moved vite from the lockfile's
|
||||
# 7.3.5_@types+node@25.9.3_… to 7.3.6_@types+node@22.20.1 and the build died on
|
||||
# `Cannot find module '/build/apps/web/node_modules/vite/bin/vite.js'`. Nothing
|
||||
# was wrong with the source: the same commit builds cleanly when installed from
|
||||
# the lockfile.
|
||||
#
|
||||
# A resolver free to drift ships a bundle no one has run. Frozen, the image gets
|
||||
# the exact tree the tests passed against, and a lockfile that has gone stale
|
||||
# fails HERE — loudly, naming the mismatch — instead of silently building
|
||||
# something else.
|
||||
#
|
||||
# EVERY workspace member's package.json must be present before a frozen install:
|
||||
# pnpm validates the lockfile against all of them and refuses if one is missing.
|
||||
# apps/account is not built into this image, but it IS in the workspace, so its
|
||||
# manifest is required for the check to pass.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/account/package.json apps/account/
|
||||
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
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
COPY apps apps
|
||||
COPY pkgs pkgs
|
||||
|
||||
# --- Build ---
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Publishable event-ingest key (pk-live-…), inlined by Vite into the bundle.
|
||||
#
|
||||
# EVENT_INGEST_KEY is the name in KMS (org `hanzo`, path `deploy`, env `prod`)
|
||||
# and on the --build-arg; the VITE_ prefix is what makes Vite inline it, and it
|
||||
# is a property of THIS build, so it is applied here and the secret store keeps
|
||||
# the ONE plain name.
|
||||
#
|
||||
# Publishable and write-only by design — it authorizes a write into one org and
|
||||
# can read nothing — so shipping it in a bundle is the documented use. It is
|
||||
# still a credential: it comes from KMS via CI. Never commit a value here.
|
||||
#
|
||||
# Deliberately NO default. An absent key is not a degraded mode: cloud takes the
|
||||
# unkeyed beacon down the anonymous lane, files every row under the `$public`
|
||||
# tenant this org cannot read, and answers 200 — so a keyless build looks
|
||||
# healthy from the page and reports nothing to the warehouse. hanzo.id ran that
|
||||
# way with no telemetry at all, which is the failure this build gate exists to
|
||||
# make loud.
|
||||
ARG EVENT_INGEST_KEY
|
||||
ENV VITE_EVENT_INGEST_KEY=$EVENT_INGEST_KEY
|
||||
# Fail closed, and gate HERE because this is the one path every builder passes
|
||||
# through — a guard in a workflow protects that lane only.
|
||||
RUN case "$EVENT_INGEST_KEY" in \
|
||||
pk-*) : ;; \
|
||||
'') echo "EVENT_INGEST_KEY is empty - pass --build-arg EVENT_INGEST_KEY=<pk-...> (KMS deploy/EVENT_INGEST_KEY, env prod)" >&2; exit 1 ;; \
|
||||
*) echo "EVENT_INGEST_KEY is not a publishable key (expected a pk- prefix)" >&2; exit 1 ;; \
|
||||
esac
|
||||
|
||||
# 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
|
||||
# Do NOT re-declare ARG VITE_EVENT_INGEST_KEY below this line. A later ARG of the
|
||||
# same name shadows the ENV set above with an empty default, so the key resolves,
|
||||
# passes the gate, and is then blanked before Vite inlines it — every step green,
|
||||
# the bundle unattributed. That is exactly how hanzo.chat 1.0.58 shipped.
|
||||
#
|
||||
# `&&`, not `;`: with `;` the RUN exits with the status of the LAST command and a
|
||||
# failed build would be masked. Assert on the bytes that actually ship — a key
|
||||
# present in the environment and absent from the bundle is indistinguishable from
|
||||
# success everywhere except the warehouse, where the traffic simply stops being
|
||||
# attributable.
|
||||
RUN pnpm --filter @hanzo/id-web build && \
|
||||
{ grep -rqF "$VITE_EVENT_INGEST_KEY" apps/web/dist || \
|
||||
{ echo "ERROR: the ingest key is not in apps/web/dist - hanzo.id would ship unattributed" >&2; exit 1; }; }
|
||||
|
||||
# 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.4.8
|
||||
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,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,15 @@
|
||||
# @hanzo/id-account
|
||||
|
||||
The account portal: branded account-management pages at `account.hanzo.id`,
|
||||
`account.lux.id`, … served by a Cloudflare Worker, authenticated with IAM access
|
||||
tokens over the OAuth code exchange.
|
||||
|
||||
It lives HERE, in `hanzoai/id`, because it is identity UI — the same brands, the
|
||||
same IAM, the same login redirect as `apps/web`. It used to live in
|
||||
`hanzoai/account`, which is a Go module: one repo held two unrelated codebases
|
||||
under one name (`main` was this Worker, the Go module survived only on a `go`
|
||||
branch and its tags). That collision is what made
|
||||
`github.com/hanzoai/account` unresolvable as a Go module from a clean checkout.
|
||||
|
||||
`hanzoai/account` is now the Go module and nothing else — see its README for the
|
||||
billing-account rule it owns.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@hanzo/id-account",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.65.0"
|
||||
},
|
||||
"description": "Branded account-management pages for account.<brand>.id \u2014 a Cloudflare Worker."
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
/**
|
||||
* Account Portal — Cloudflare Worker
|
||||
*
|
||||
* Serves branded account management pages for:
|
||||
* - account.hanzo.id (Hanzo brand)
|
||||
* - account.lux.id (Lux brand)
|
||||
*
|
||||
* Authentication: Uses IAM access tokens via OAuth code exchange.
|
||||
* Users are redirected to their brand's login page if unauthenticated.
|
||||
*/
|
||||
|
||||
const IAM_ORIGIN = 'https://iam.hanzo.ai';
|
||||
|
||||
// Brand configuration keyed by hostname
|
||||
const BRANDS = {
|
||||
'account.hanzo.id': {
|
||||
name: 'Hanzo',
|
||||
domain: 'hanzo.id',
|
||||
loginUrl: 'https://hanzo.id/login',
|
||||
bg: '#0a0a0a',
|
||||
surface: '#111111',
|
||||
border: '#222222',
|
||||
accent: '#fd4444',
|
||||
clientId: 'hanzo-app-client-id',
|
||||
logo: `<svg viewBox="0 0 100 100" width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M50 5L90 27.5V72.5L50 95L10 72.5V27.5L50 5Z" stroke="#fd4444" stroke-width="4"/>
|
||||
<path d="M30 35V65M70 35V65M30 50H70" stroke="#fd4444" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>`,
|
||||
},
|
||||
'account.lux.id': {
|
||||
name: 'Lux',
|
||||
domain: 'lux.id',
|
||||
loginUrl: 'https://lux.id/login',
|
||||
bg: '#050508',
|
||||
surface: '#0c0c10',
|
||||
border: '#222222',
|
||||
accent: '#ffffff',
|
||||
clientId: 'lux-app-client-id',
|
||||
logo: `<svg viewBox="0 0 100 100" width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="50,10 90,75 10,75" stroke="white" stroke-width="3" fill="none"/>
|
||||
<polygon points="50,30 72,68 28,68" stroke="white" stroke-width="2" fill="none"/>
|
||||
</svg>`,
|
||||
},
|
||||
};
|
||||
|
||||
function getBrand(hostname) {
|
||||
return BRANDS[hostname] || BRANDS['account.hanzo.id'];
|
||||
}
|
||||
|
||||
function htmlResponse(html) {
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
'content-type': 'text/html;charset=UTF-8',
|
||||
'cache-control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Parse access token from cookie
|
||||
function getToken(request) {
|
||||
const cookie = request.headers.get('Cookie') || '';
|
||||
const match = cookie.match(/account_token=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
// Fetch user info from IAM using token
|
||||
async function getUserInfo(token) {
|
||||
const res = await fetch(`${IAM_ORIGIN}/v1/iam/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
// Fetch full user object for editing
|
||||
async function getUser(token, owner, name) {
|
||||
const res = await fetch(`${IAM_ORIGIN}/v1/iam/get-user?id=${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.data || null;
|
||||
}
|
||||
|
||||
// Build the OAuth login URL for the brand. Points at the brand's branded
|
||||
// /login page (front-door worker) with the OAuth params; that page renders
|
||||
// the two-pane login and emits the canonical /v1/iam/* calls itself — no
|
||||
// bare /oauth/authorize, no host leak to iam.hanzo.ai.
|
||||
function buildLoginUrl(brand, callbackUrl) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: brand.clientId,
|
||||
redirect_uri: callbackUrl,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
state: 'account',
|
||||
});
|
||||
return `${brand.loginUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Exchange authorization code for access token
|
||||
async function exchangeCode(code, callbackUrl, brand) {
|
||||
const res = await fetch(`${IAM_ORIGIN}/v1/iam/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: callbackUrl,
|
||||
client_id: brand.clientId,
|
||||
}).toString(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.access_token || null;
|
||||
}
|
||||
|
||||
function renderAccountPage(brand, user, fullUser) {
|
||||
const providers = [];
|
||||
// Extract linked providers from user object
|
||||
const providerFields = ['github', 'google', 'facebook', 'twitter', 'linkedin', 'discord', 'wechat', 'dingtalk'];
|
||||
if (fullUser) {
|
||||
for (const p of providerFields) {
|
||||
if (fullUser[p] && fullUser[p] !== '') {
|
||||
providers.push({ name: p, id: fullUser[p] });
|
||||
}
|
||||
}
|
||||
// Check for MetaMask/Web3 wallet
|
||||
if (fullUser.metamask && fullUser.metamask !== '') {
|
||||
providers.push({ name: 'web3', id: fullUser.metamask });
|
||||
} else if (fullUser.web3onboard && fullUser.web3onboard !== '') {
|
||||
providers.push({ name: 'web3', id: fullUser.web3onboard });
|
||||
}
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Account - ${brand.name}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: ${brand.bg};
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.topbar {
|
||||
border-bottom: 1px solid ${brand.border};
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: ${brand.surface};
|
||||
}
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.topbar-actions { display: flex; gap: 0.75rem; align-items: center; }
|
||||
.topbar-actions a {
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${brand.border};
|
||||
}
|
||||
.topbar-actions a:hover { color: #fff; border-color: #555; }
|
||||
.topbar-actions .logout { color: #ff6b6b; border-color: #ff6b6b33; }
|
||||
.topbar-actions .logout:hover { background: #ff6b6b11; }
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
.section {
|
||||
background: ${brand.surface};
|
||||
border: 1px solid ${brand.border};
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid ${brand.border};
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.field + .field { border-top: 1px solid ${brand.border}22; }
|
||||
.field-label { color: #888; font-size: 0.85rem; min-width: 120px; }
|
||||
.field-value { font-size: 0.92rem; }
|
||||
.field-action {
|
||||
color: ${brand.accent};
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 1px solid ${brand.accent}44;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.field-action:hover { background: ${brand.accent}11; }
|
||||
.provider-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.provider-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid ${brand.border};
|
||||
border-radius: 8px;
|
||||
background: ${brand.bg};
|
||||
}
|
||||
.provider-item .name {
|
||||
text-transform: capitalize;
|
||||
font-weight: 500;
|
||||
}
|
||||
.provider-item .id {
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.provider-item .unlink {
|
||||
color: #ff6b6b;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 1px solid #ff6b6b33;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.add-provider {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px dashed ${brand.border};
|
||||
border-radius: 8px;
|
||||
color: #999;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.add-btn:hover { border-color: #555; color: #fff; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
background: #1a472a;
|
||||
color: #6ee7b7;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
.badge.unverified { background: #472a1a; color: #e7b76e; }
|
||||
.danger-zone {
|
||||
border-color: #ff6b6b33;
|
||||
}
|
||||
.danger-zone h2 { color: #ff6b6b; }
|
||||
.msg {
|
||||
display: none;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.msg.success { background: #1a472a; color: #6ee7b7; display: block; }
|
||||
.msg.error { background: #472a1a; color: #e7b76e; display: block; }
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: ${brand.border};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal {
|
||||
background: ${brand.surface};
|
||||
border: 1px solid ${brand.border};
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
.modal h3 { margin-bottom: 1rem; }
|
||||
.modal input {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${brand.border};
|
||||
background: ${brand.bg};
|
||||
color: #fff;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.modal-actions button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${brand.border};
|
||||
background: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.modal-actions .primary {
|
||||
background: ${brand.accent};
|
||||
color: ${brand.accent === '#ffffff' ? '#111' : '#fff'};
|
||||
border-color: ${brand.accent};
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.container { padding: 0 1rem; margin: 1rem auto; }
|
||||
.section { padding: 1rem; }
|
||||
.field { flex-direction: column; align-items: flex-start; gap: 0.3rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<div class="topbar-brand">
|
||||
${brand.logo}
|
||||
<span>${brand.name} Account</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<a href="https://${brand.domain}">Back to ${brand.name}</a>
|
||||
<a href="https://${brand.domain}/logout" class="logout">Sign Out</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<div id="msg" class="msg"></div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Profile</h2>
|
||||
<div class="avatar-row">
|
||||
<div class="avatar">
|
||||
${user.picture ? `<img src="${user.picture}" alt="Avatar">` : (user.name || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:1.1rem;">${user.preferred_username || user.name || 'User'}</div>
|
||||
<div style="color:#888;font-size:0.85rem;">${user.email || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Display Name</span>
|
||||
<span class="field-value">${user.name || '—'}</span>
|
||||
<button class="field-action" onclick="editField('name','${(user.name || '').replace(/'/g, "\\'")}')">Edit</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Email</span>
|
||||
<span class="field-value">
|
||||
${user.email || '—'}
|
||||
${user.email_verified ? '<span class="badge">Verified</span>' : '<span class="badge unverified">Unverified</span>'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Phone</span>
|
||||
<span class="field-value">${user.phone || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Login Methods</h2>
|
||||
<div class="provider-list">
|
||||
<div class="provider-item">
|
||||
<div>
|
||||
<span class="name">Email & Password</span>
|
||||
<span class="id">${user.email || 'Not set'}</span>
|
||||
</div>
|
||||
<button class="field-action" onclick="openPasswordModal()">Change Password</button>
|
||||
</div>
|
||||
${providers.map(p => `
|
||||
<div class="provider-item">
|
||||
<div>
|
||||
<span class="name">${p.name === 'web3' ? 'Web3 Wallet' : p.name}</span>
|
||||
<span class="id">${p.name === 'web3' ? p.id.slice(0, 6) + '...' + p.id.slice(-4) : p.id}</span>
|
||||
</div>
|
||||
<button class="unlink" onclick="unlinkProvider('${p.name}')">Unlink</button>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="add-provider">
|
||||
<a class="add-btn" href="https://${brand.domain}/v1/iam/oauth/authorize?client_id=${brand.clientId}&redirect_uri=${encodeURIComponent(`https://${brand.domain}/callback`)}&response_type=code&scope=openid+profile+email&provider=provider-google">
|
||||
+ Google
|
||||
</a>
|
||||
<a class="add-btn" href="https://${brand.domain}/v1/iam/oauth/authorize?client_id=${brand.clientId}&redirect_uri=${encodeURIComponent(`https://${brand.domain}/callback`)}&response_type=code&scope=openid+profile+email&provider=provider-github">
|
||||
+ GitHub
|
||||
</a>
|
||||
<button class="add-btn" onclick="linkWallet()">+ Web3 Wallet</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Security</h2>
|
||||
<div class="field">
|
||||
<span class="field-label">Two-Factor Auth</span>
|
||||
<span class="field-value">${fullUser && fullUser.totpSecret ? '<span class="badge">Enabled</span>' : 'Not enabled'}</span>
|
||||
<button class="field-action" onclick="window.location.href='https://iam.hanzo.ai/account#mfa'">${fullUser && fullUser.totpSecret ? 'Manage' : 'Enable'}</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Last Sign-in</span>
|
||||
<span class="field-value">${fullUser && fullUser.lastSigninTime ? new Date(fullUser.lastSigninTime).toLocaleString() : '—'}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Last Sign-in IP</span>
|
||||
<span class="field-value">${fullUser && fullUser.lastSigninIp ? fullUser.lastSigninIp : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section danger-zone">
|
||||
<h2>Danger Zone</h2>
|
||||
<div class="field">
|
||||
<span class="field-label">Delete Account</span>
|
||||
<span class="field-value" style="color:#888;font-size:0.82rem;">Permanently delete your account and all data</span>
|
||||
<button class="field-action" style="color:#ff6b6b;border-color:#ff6b6b44;" onclick="confirmDelete()">Delete Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Change Modal -->
|
||||
<div class="modal-overlay" id="password-modal">
|
||||
<div class="modal">
|
||||
<h3>Change Password</h3>
|
||||
<input type="password" id="old-password" placeholder="Current password" autocomplete="current-password">
|
||||
<input type="password" id="new-password" placeholder="New password (min 8 characters)" autocomplete="new-password">
|
||||
<input type="password" id="confirm-password" placeholder="Confirm new password" autocomplete="new-password">
|
||||
<div id="pw-error" style="color:#ff8f8f;font-size:0.82rem;display:none;margin-bottom:0.5rem;"></div>
|
||||
<div class="modal-actions">
|
||||
<button onclick="closePasswordModal()">Cancel</button>
|
||||
<button class="primary" onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Field Modal -->
|
||||
<div class="modal-overlay" id="edit-modal">
|
||||
<div class="modal">
|
||||
<h3 id="edit-title">Edit Field</h3>
|
||||
<input type="text" id="edit-value">
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeEditModal()">Cancel</button>
|
||||
<button class="primary" onclick="saveField()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TOKEN = document.cookie.match(/account_token=([^;]+)/)?.[1] ? decodeURIComponent(document.cookie.match(/account_token=([^;]+)/)[1]) : null;
|
||||
const IAM = '${IAM_ORIGIN}';
|
||||
const BRAND_DOMAIN = '${brand.domain}';
|
||||
let editingField = null;
|
||||
|
||||
function showMsg(text, type) {
|
||||
const el = document.getElementById('msg');
|
||||
el.textContent = text;
|
||||
el.className = 'msg ' + type;
|
||||
setTimeout(() => { el.className = 'msg'; }, 5000);
|
||||
}
|
||||
|
||||
function openPasswordModal() {
|
||||
document.getElementById('password-modal').classList.add('active');
|
||||
}
|
||||
function closePasswordModal() {
|
||||
document.getElementById('password-modal').classList.remove('active');
|
||||
document.getElementById('old-password').value = '';
|
||||
document.getElementById('new-password').value = '';
|
||||
document.getElementById('confirm-password').value = '';
|
||||
document.getElementById('pw-error').style.display = 'none';
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const oldPw = document.getElementById('old-password').value;
|
||||
const newPw = document.getElementById('new-password').value;
|
||||
const confirm = document.getElementById('confirm-password').value;
|
||||
const errEl = document.getElementById('pw-error');
|
||||
|
||||
if (!oldPw || !newPw) { errEl.textContent = 'All fields required'; errEl.style.display = 'block'; return; }
|
||||
if (newPw.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; errEl.style.display = 'block'; return; }
|
||||
if (newPw !== confirm) { errEl.textContent = 'Passwords do not match'; errEl.style.display = 'block'; return; }
|
||||
|
||||
try {
|
||||
const res = await fetch('/v1/iam/set-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: 'Bearer ' + TOKEN,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
userOwner: '${fullUser ? fullUser.owner : 'hanzo'}',
|
||||
userName: '${fullUser ? fullUser.name : ''}',
|
||||
oldPassword: oldPw,
|
||||
newPassword: newPw,
|
||||
}).toString(),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
closePasswordModal();
|
||||
showMsg('Password updated successfully', 'success');
|
||||
} else {
|
||||
errEl.textContent = data.msg || 'Failed to update password';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Network error';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function editField(field, currentValue) {
|
||||
editingField = field;
|
||||
document.getElementById('edit-title').textContent = 'Edit ' + field.charAt(0).toUpperCase() + field.slice(1);
|
||||
document.getElementById('edit-value').value = currentValue;
|
||||
document.getElementById('edit-modal').classList.add('active');
|
||||
}
|
||||
function closeEditModal() {
|
||||
document.getElementById('edit-modal').classList.remove('active');
|
||||
editingField = null;
|
||||
}
|
||||
async function saveField() {
|
||||
if (!editingField) return;
|
||||
const value = document.getElementById('edit-value').value;
|
||||
try {
|
||||
const userObj = { owner: '${fullUser ? fullUser.owner : 'hanzo'}', name: '${fullUser ? fullUser.name : ''}' };
|
||||
if (editingField === 'name') userObj.displayName = value;
|
||||
const res = await fetch('/v1/iam/update-user?id=${fullUser ? fullUser.owner + '/' + fullUser.name : ''}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + TOKEN,
|
||||
},
|
||||
body: JSON.stringify(userObj),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
closeEditModal();
|
||||
showMsg('Updated successfully. Refreshing...', 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showMsg(data.msg || 'Update failed', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMsg('Network error', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkProvider(provider) {
|
||||
if (!confirm('Unlink ' + provider + ' from your account?')) return;
|
||||
try {
|
||||
var field = provider === 'web3' ? 'metamask' : provider;
|
||||
var userObj = { owner: '${fullUser ? fullUser.owner : 'hanzo'}', name: '${fullUser ? fullUser.name : ''}' };
|
||||
userObj[field] = '';
|
||||
var res = await fetch('/v1/iam/update-user?id=${fullUser ? fullUser.owner + '/' + fullUser.name : ''}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userObj),
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
showMsg(provider + ' unlinked successfully', 'success');
|
||||
setTimeout(function() { location.reload(); }, 1000);
|
||||
} else {
|
||||
showMsg(data.msg || 'Failed to unlink ' + provider, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showMsg('Network error: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function linkWallet() {
|
||||
if (typeof window.ethereum === 'undefined') {
|
||||
showMsg('Please install MetaMask or another Web3 wallet', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
|
||||
var address = accounts[0];
|
||||
if (!address) { showMsg('No wallet account found', 'error'); return; }
|
||||
|
||||
// Sign a message to prove wallet ownership
|
||||
var message = 'Link wallet ' + address + ' to ' + BRAND_DOMAIN + ' account for ${fullUser ? fullUser.name : 'user'}';
|
||||
await window.ethereum.request({ method: 'personal_sign', params: [message, address] });
|
||||
|
||||
// Update user with wallet address
|
||||
var userObj = { owner: '${fullUser ? fullUser.owner : 'hanzo'}', name: '${fullUser ? fullUser.name : ''}', metamask: address };
|
||||
var res = await fetch('/v1/iam/update-user?id=${fullUser ? fullUser.owner + '/' + fullUser.name : ''}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userObj),
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
showMsg('Wallet linked: ' + address.slice(0, 6) + '...' + address.slice(-4), 'success');
|
||||
setTimeout(function() { location.reload(); }, 1500);
|
||||
} else {
|
||||
showMsg(data.msg || 'Failed to link wallet', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code === 4001) return; // User rejected
|
||||
showMsg(err.message || 'Failed to connect wallet', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!confirm('Are you sure? This action is permanent and cannot be undone.')) return;
|
||||
if (!confirm('This will permanently delete your account and all associated data. Type OK to confirm.')) return;
|
||||
showMsg('Account deletion requires verification. Redirecting to IAM...', 'error');
|
||||
window.location.href = 'https://iam.hanzo.ai/account';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderLoginRedirectPage(brand, loginUrl) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign In Required - ${brand.name}</title>
|
||||
<meta http-equiv="refresh" content="2;url=${loginUrl}">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: ${brand.bg};
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.card {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
border: 1px solid ${brand.border};
|
||||
border-radius: 12px;
|
||||
background: ${brand.surface};
|
||||
max-width: 400px;
|
||||
}
|
||||
a { color: ${brand.accent}; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
${brand.logo}
|
||||
<h2 style="margin-top:1rem;">Sign in to continue</h2>
|
||||
<p style="color:#888;margin-top:0.5rem;">Redirecting to ${brand.name} login...</p>
|
||||
<p style="margin-top:1rem;"><a href="${loginUrl}">Click here if not redirected</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
const hostname = url.hostname;
|
||||
const pathname = url.pathname;
|
||||
const brand = getBrand(hostname);
|
||||
const callbackUrl = `https://${hostname}/callback`;
|
||||
|
||||
// Handle OAuth callback — exchange code for token
|
||||
if (pathname === '/callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: buildLoginUrl(brand, callbackUrl) },
|
||||
});
|
||||
}
|
||||
|
||||
const token = await exchangeCode(code, callbackUrl, brand);
|
||||
if (!token) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: buildLoginUrl(brand, callbackUrl) },
|
||||
});
|
||||
}
|
||||
|
||||
// Set token cookie and redirect to account page
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: '/',
|
||||
'Set-Cookie': `account_token=${encodeURIComponent(token)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle logout
|
||||
if (pathname === '/logout') {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `https://${brand.domain}`,
|
||||
'Set-Cookie': 'account_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Proxy IAM API calls (for client-side JS). Same-origin /v1/iam/* from
|
||||
// the account page is forwarded to IAM; the access token is attached
|
||||
// server-side from the HttpOnly cookie so it never rides in page JS.
|
||||
if (pathname.startsWith('/v1/iam/')) {
|
||||
const token = getToken(request);
|
||||
const iamUrl = new URL(pathname + url.search, IAM_ORIGIN);
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('Host', new URL(IAM_ORIGIN).hostname);
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const iamRes = await fetch(iamUrl.toString(), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.method !== 'GET' ? request.body : undefined,
|
||||
});
|
||||
|
||||
return new Response(iamRes.body, {
|
||||
status: iamRes.status,
|
||||
headers: {
|
||||
'content-type': iamRes.headers.get('content-type') || 'application/json',
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check authentication for all other routes
|
||||
const token = getToken(request);
|
||||
if (!token) {
|
||||
const loginUrl = buildLoginUrl(brand, callbackUrl);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: loginUrl },
|
||||
});
|
||||
}
|
||||
|
||||
// Get user info
|
||||
const user = await getUserInfo(token);
|
||||
if (!user || !user.name) {
|
||||
// Token expired or invalid — re-authenticate
|
||||
const loginUrl = buildLoginUrl(brand, callbackUrl);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: loginUrl,
|
||||
'Set-Cookie': 'account_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get full user object for detailed info
|
||||
const fullUser = await getUser(token, user.owner || 'hanzo', user.preferred_username || user.name);
|
||||
|
||||
// Serve account page
|
||||
return htmlResponse(renderAccountPage(brand, user, fullUser));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "account-portal"
|
||||
main = "src/worker.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
workers_dev = false
|
||||
|
||||
# Custom domains are managed via CF Workers Custom Domains API:
|
||||
# account.hanzo.id -> zone hanzo.id
|
||||
# account.lux.id -> zone lux.id
|
||||
# DNS records and SSL certs are automatically provisioned.
|
||||
|
||||
[vars]
|
||||
IAM_ORIGIN = "https://iam.hanzo.ai"
|
||||
@@ -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,41 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"version": "0.1.34",
|
||||
"description": "Hanzo ID \u2014 white-label login / signup / IDV portal. Vite + React 19, styled from @hanzo/design tokens. 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/design": "^0.4.9",
|
||||
"@hanzo/event": "^0.3.11",
|
||||
"@hanzo/iam": "^0.21.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, catalogOf, parseCatalog, resolveOrg, idBrandLabel, type BrandContract, type OrgConfig } 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 org + 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 [org, setOrg] = useState<OrgConfig | 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 org catalog at /config.json — 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 global, then empty, so a
|
||||
// host always resolves to something.
|
||||
//
|
||||
// `catalogOf` owns which key that payload uses — the name is the server's,
|
||||
// not ours (see its doc). Reading the wrong one is a silent total-catalog
|
||||
// outage, so it is pinned by a test next to the resolver it feeds.
|
||||
let catalogRaw: string | undefined
|
||||
try {
|
||||
const res = await fetch('/config.json', { cache: 'no-store' })
|
||||
if (res.ok) catalogRaw = catalogOf(await res.json())
|
||||
} catch {
|
||||
// network/parse error → fall back below
|
||||
}
|
||||
if (!catalogRaw) {
|
||||
catalogRaw = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
|
||||
}
|
||||
const t = resolveOrg(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
|
||||
if (cancelled) return
|
||||
setOrg(t)
|
||||
try {
|
||||
const b = await loadBrand(t.brandPackage)
|
||||
if (cancelled) return
|
||||
setBrand(b)
|
||||
document.title = idBrandLabel(b, t.orgId)
|
||||
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(() => (org ? createAuthClient({ org }) : null), [org])
|
||||
|
||||
if (error) return <div className="hanzo-id-error">{error}</div>
|
||||
if (!org || !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 org={org} brand={brand} />
|
||||
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding org={org} brand={brand} />
|
||||
return <Portal client={client} brand={brand} org={org} />
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* The telemetry gate, tested where it is actually true or false: on the bytes
|
||||
* the client puts on the wire.
|
||||
*
|
||||
* The defect this guards is not hypothetical and is not visible in review. The
|
||||
* obvious way to keep an OAuth code out of telemetry — "send the pathname, never
|
||||
* the href" — DOES NOT WORK against @hanzo/event, because `build()` stamps
|
||||
* `url: window.location.href` onto every event it assembles regardless of the
|
||||
* `path` the caller passed. A pageview from `/callback?code=…&state=…` therefore
|
||||
* ships the authorization code while `path` reads a clean `/callback`, and the
|
||||
* client's scrubber does not catch it: that scrubber redacts secret SHAPES
|
||||
* (JWT, sk-/pk-/hk-, bearer, cloud keys, PAN) and an opaque authorization code
|
||||
* is not one.
|
||||
*
|
||||
* So the gate is "do not emit from a route whose URL carries a credential", and
|
||||
* the test below asserts BOTH halves: that gated routes emit nothing, and that
|
||||
* the same setup ungated really does leak. The second half is what keeps this
|
||||
* from decaying into a decorative assertion — if @hanzo/event ever stops putting
|
||||
* the href on the wire, that case fails and this whole file can be revisited.
|
||||
*/
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createAnalytics } from '@hanzo/event'
|
||||
import { telemetryAllowed, consented } from './analytics'
|
||||
|
||||
// ── the route gate ──────────────────────────────────────────────────────────
|
||||
|
||||
test('auth-artifact routes are refused, funnel routes are not', () => {
|
||||
// Carry a credential in the query string -> must never emit.
|
||||
for (const p of [
|
||||
'/callback',
|
||||
'/callback/',
|
||||
'/callback/anything',
|
||||
'/login/oauth/device',
|
||||
'/login/oauth/device/',
|
||||
'/login/oauth/device/WDJB-MJHT',
|
||||
]) {
|
||||
assert.equal(telemetryAllowed(p), false, `${p} must not emit`)
|
||||
}
|
||||
|
||||
// The funnel this exists to measure: arrival -> sign-in -> session.
|
||||
for (const p of [
|
||||
'/',
|
||||
'/login',
|
||||
'/login/',
|
||||
'/signup',
|
||||
'/forgot',
|
||||
'/forget',
|
||||
'/onboarding',
|
||||
'/callbacks', // near-miss: a real route that merely starts the same way
|
||||
'/login/oauth', // the device path is the specific one, not all of oauth
|
||||
]) {
|
||||
assert.equal(telemetryAllowed(p), true, `${p} must emit`)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The gate is written against literal paths, and App.tsx dispatches against its
|
||||
* own. If someone adds a route that lands on the callback or device page, this
|
||||
* fails rather than silently starting to ship codes — the same reason the token
|
||||
* suite computes from what the bundle serves instead of trusting a list.
|
||||
*/
|
||||
test('every auth-artifact route App.tsx dispatches is covered by the gate', () => {
|
||||
const app = fs.readFileSync(path.join(import.meta.dirname, 'App.tsx'), 'utf8')
|
||||
|
||||
// Route literals compared in App.tsx: path === '…' / path.startsWith('…').
|
||||
const routes = [...app.matchAll(/path\s*(?:===\s*|\.startsWith\(\s*)'([^']+)'/g)].map((m) => m[1]!)
|
||||
assert.ok(routes.length >= 10, `expected App.tsx route literals, found ${routes.length}`)
|
||||
|
||||
for (const r of routes) {
|
||||
const isAuthArtifact = r.startsWith('/callback') || r.startsWith('/login/oauth/device')
|
||||
if (isAuthArtifact) {
|
||||
assert.equal(telemetryAllowed(r), false, `App.tsx routes ${r} to an auth-artifact page; gate it`)
|
||||
}
|
||||
}
|
||||
|
||||
// Both pages are actually reachable — the gate is not guarding dead routes.
|
||||
assert.ok(routes.some((r) => r.startsWith('/callback')), 'App.tsx must route /callback')
|
||||
assert.ok(
|
||||
routes.some((r) => r.startsWith('/login/oauth/device')),
|
||||
'App.tsx must route /login/oauth/device',
|
||||
)
|
||||
})
|
||||
|
||||
// ── consent ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('an explicit browser opt-out turns everything off', () => {
|
||||
assert.equal(consented({ globalPrivacyControl: true }), false)
|
||||
assert.equal(consented({ doNotTrack: '1' }), false)
|
||||
assert.equal(consented({ doNotTrack: 'yes' }), false)
|
||||
|
||||
assert.equal(consented(), true)
|
||||
assert.equal(consented({}), true)
|
||||
assert.equal(consented({ globalPrivacyControl: false, doNotTrack: '0' }), true)
|
||||
assert.equal(consented({ doNotTrack: null }), true)
|
||||
})
|
||||
|
||||
// ── the wire ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CODE = 'AUTHCODE_abc123XYZ'
|
||||
const STATE = 'STATE_deadbeef'
|
||||
const USER_CODE = 'WDJB-MJHT'
|
||||
|
||||
/** Installs the browser globals @hanzo/event reads, at a given location. */
|
||||
function atLocation(href: string, pathname: string, search: string) {
|
||||
const store: Record<string, string> = {}
|
||||
const localStorage = {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => void (store[k] = String(v)),
|
||||
removeItem: (k: string) => void delete store[k],
|
||||
}
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.window = {
|
||||
location: { href, pathname, search, hostname: 'hanzo.id', origin: 'https://hanzo.id' },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
localStorage,
|
||||
screen: { width: 1440, height: 900 },
|
||||
}
|
||||
g.document = {
|
||||
referrer: '',
|
||||
title: 'Sign in',
|
||||
visibilityState: 'visible',
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}
|
||||
g.localStorage = localStorage
|
||||
g.screen = { width: 1440, height: 900 }
|
||||
g.location = (g.window as { location: unknown }).location
|
||||
}
|
||||
|
||||
function clearLocation() {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
delete g.window
|
||||
delete g.document
|
||||
delete g.localStorage
|
||||
delete g.screen
|
||||
delete g.location
|
||||
}
|
||||
|
||||
/** Runs the client exactly as mounted and returns everything it tried to send. */
|
||||
function wireFrom(href: string, pathname: string, search: string, enabled: boolean): string {
|
||||
atLocation(href, pathname, search)
|
||||
try {
|
||||
const sent: string[] = []
|
||||
const client = createAnalytics({
|
||||
product: 'id',
|
||||
host: 'https://api.hanzo.ai',
|
||||
ingestKey: 'pk-live-TESTKEY',
|
||||
enabled,
|
||||
transport: { send: (_url: string, body: string) => void sent.push(body) },
|
||||
})
|
||||
client.init()
|
||||
client.pageview(pathname) // pathname only — the mitigation that is NOT enough
|
||||
client.captureError(new Error('boom'))
|
||||
client.flush()
|
||||
return sent.join('')
|
||||
} finally {
|
||||
clearLocation()
|
||||
}
|
||||
}
|
||||
|
||||
test('a gated auth-artifact route puts nothing on the wire', () => {
|
||||
const cb = wireFrom(
|
||||
`https://hanzo.id/callback?code=${CODE}&state=${STATE}`,
|
||||
'/callback',
|
||||
`?code=${CODE}&state=${STATE}`,
|
||||
telemetryAllowed('/callback'),
|
||||
)
|
||||
assert.equal(cb, '', 'the callback route must emit nothing at all')
|
||||
assert.ok(!cb.includes(CODE), 'authorization code must never reach the wire')
|
||||
assert.ok(!cb.includes(STATE), 'state must never reach the wire')
|
||||
|
||||
const dev = wireFrom(
|
||||
`https://hanzo.id/login/oauth/device?user_code=${USER_CODE}`,
|
||||
'/login/oauth/device',
|
||||
`?user_code=${USER_CODE}`,
|
||||
telemetryAllowed('/login/oauth/device'),
|
||||
)
|
||||
assert.equal(dev, '', 'the device route must emit nothing at all')
|
||||
assert.ok(!dev.includes(USER_CODE), 'device user_code must never reach the wire')
|
||||
})
|
||||
|
||||
/**
|
||||
* The reason the gate exists. Passing a clean pathname is NOT what protects the
|
||||
* code — if this ever stops leaking, @hanzo/event changed and the gate's
|
||||
* justification should be re-read.
|
||||
*/
|
||||
test('without the gate, a clean pathname still leaks the code (why the gate exists)', () => {
|
||||
const leaked = wireFrom(
|
||||
`https://hanzo.id/callback?code=${CODE}&state=${STATE}`,
|
||||
'/callback',
|
||||
`?code=${CODE}&state=${STATE}`,
|
||||
true, // ungated
|
||||
)
|
||||
assert.ok(leaked.includes(CODE), 'expected the ungated client to leak the code via `url`')
|
||||
assert.ok(leaked.includes(STATE), 'expected the ungated client to leak the state via `url`')
|
||||
assert.ok(leaked.includes('"path":"/callback"'), 'and to report a clean path while doing it')
|
||||
})
|
||||
|
||||
test('funnel routes do report, and carry no credential', () => {
|
||||
for (const p of ['/', '/login', '/signup', '/onboarding']) {
|
||||
const wire = wireFrom(`https://hanzo.id${p}`, p, '', telemetryAllowed(p))
|
||||
assert.ok(wire.includes('"$pageview"'), `${p} must report a pageview`)
|
||||
assert.ok(wire.includes('"product":"id"'), `${p} must attribute to the id product`)
|
||||
for (const secret of [CODE, STATE, USER_CODE]) {
|
||||
assert.ok(!wire.includes(secret), `${p} must not carry ${secret}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('an opted-out visitor emits nothing even on a funnel route', () => {
|
||||
const wire = wireFrom(
|
||||
'https://hanzo.id/login',
|
||||
'/login',
|
||||
'',
|
||||
consented({ globalPrivacyControl: true }) && telemetryAllowed('/login'),
|
||||
)
|
||||
assert.equal(wire, '', 'GPC must suppress the whole client')
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
// Telemetry for the sign-in portal — pageviews and errors, anonymous, via the
|
||||
// ONE @hanzo/event client (POST /v1/event, the front door cloud fans out into
|
||||
// the web / product / error lenses). No page tag, no second SDK.
|
||||
//
|
||||
// This surface reported NOTHING before this file existed, which is why the
|
||||
// arrival->session funnel had no denominator: hanzo.id is where every property's
|
||||
// visitor lands, and none of it was attributable.
|
||||
//
|
||||
// It is also an AUTH surface, so what is NOT here is deliberate:
|
||||
//
|
||||
// - no identify(). Attribution here is anonymous; @hanzo/event stamps a
|
||||
// per-browser `anonymousId` that survives sign-up, so the visitor's
|
||||
// pre-signup pageviews still join to whoever they become once a
|
||||
// post-auth surface (chat/console) identifies them. Reading the IAM
|
||||
// subject would mean wiring this into the auth context for a join that
|
||||
// already happens downstream.
|
||||
// - no interaction autocapture (@hanzo/observe). Heat maps answer "where do
|
||||
// they click"; the question this funnel exists to answer is "did they get a
|
||||
// session", which pageviews answer completely. Autocapture on the login and
|
||||
// signup forms is capture surface bought for no funnel signal.
|
||||
// - no session replay, no input capture, no email/name.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AnalyticsProvider as EventProvider, usePageview } from '@hanzo/event/react'
|
||||
|
||||
const HOST = 'https://api.hanzo.ai'
|
||||
|
||||
/**
|
||||
* Publishable ingest key (pk-…), inlined by Vite from the build env. Write-only:
|
||||
* it attributes a write to ONE org and mints no reading principal, which is what
|
||||
* makes it safe in a bundle — and it is the ONLY thing that attributes a
|
||||
* LOGGED-OUT visitor, which on a sign-in portal is nearly all of them.
|
||||
*
|
||||
* Absent is not a degraded mode: cloud takes an unkeyed beacon down the anonymous
|
||||
* lane and files it under `$public`, a tenant this org cannot read, and answers
|
||||
* 200 either way. The loss is silent on both ends, so the Dockerfile fails the
|
||||
* build rather than letting an empty value ship. Never hardcode a value here.
|
||||
*/
|
||||
const INGEST_KEY = import.meta.env.VITE_EVENT_INGEST_KEY?.trim() || undefined
|
||||
|
||||
/**
|
||||
* Routes whose URL carries an authentication artifact.
|
||||
*
|
||||
* `/callback` holds the OAuth authorization `code` and `state`; the device
|
||||
* verification URI holds a `user_code`. Both sit in the QUERY STRING, and
|
||||
* @hanzo/event stamps `url: window.location.href` onto every event it builds —
|
||||
* independently of the `path` a caller passes. So passing a clean pathname does
|
||||
* NOT keep the code out of the payload; only not emitting does. Measured against
|
||||
* the real client, a pageview from `/callback?code=…&state=…` put both values on
|
||||
* the wire in cleartext while `path` read a tidy `/callback`.
|
||||
*
|
||||
* The client's scrubber does not save this either — it redacts secret SHAPES
|
||||
* (JWTs, sk-/pk-/hk-, bearer, cloud keys, PANs) and an opaque authorization code
|
||||
* matches none of them.
|
||||
*
|
||||
* Neither route is a funnel step: both are transient machine hops that redirect
|
||||
* onward within a tick. The funnel is `/` -> `/login` -> `/onboarding`, and every
|
||||
* one of those still reports. Dropping these two costs no signal and removes the
|
||||
* entire class of credential leak. See analytics.test.ts.
|
||||
*/
|
||||
const AUTH_ARTIFACT = /^\/(callback|login\/oauth\/device)(\/|$)/
|
||||
|
||||
/** telemetryAllowed reports whether a path may emit at all. Pure. */
|
||||
export function telemetryAllowed(pathname: string): boolean {
|
||||
return !AUTH_ARTIFACT.test(pathname)
|
||||
}
|
||||
|
||||
/**
|
||||
* consented honours an explicit browser opt-out — Global Privacy Control, then
|
||||
* legacy Do-Not-Track. This is the whole consent surface, and it suppresses
|
||||
* pageviews AND errors together: a visitor who opted out is not "mostly" off.
|
||||
* Pure with respect to its argument so the policy is testable without a DOM.
|
||||
*/
|
||||
export function consented(nav?: {
|
||||
globalPrivacyControl?: boolean
|
||||
doNotTrack?: string | null
|
||||
}): boolean {
|
||||
if (!nav) return true
|
||||
if (nav.globalPrivacyControl === true) return false
|
||||
const dnt = nav.doNotTrack
|
||||
return dnt !== '1' && dnt !== 'yes'
|
||||
}
|
||||
|
||||
/** Reads the live opt-out signals off `navigator`, or none outside a browser. */
|
||||
function browserConsent(): boolean {
|
||||
if (typeof navigator === 'undefined') return true
|
||||
return consented(navigator as Navigator & { globalPrivacyControl?: boolean })
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a pageview on SPA route changes.
|
||||
*
|
||||
* Inert today, and deliberately kept: this app navigates with
|
||||
* `window.location.assign/replace`, so every route change is a fresh document
|
||||
* and the provider's own initial pageview counts each page exactly once
|
||||
* (`usePageview` skips its first mount for precisely that reason — it would
|
||||
* otherwise double-count). `@tanstack/react-router` is a declared dependency
|
||||
* that nothing imports; the day someone mounts it, navigation stops reloading
|
||||
* the document and this is what keeps pageviews from silently going to zero.
|
||||
*
|
||||
* It is fed the PATHNAME, never `location.href`, and only when the path is
|
||||
* allowed to emit — `usePageview` no-ops on a null path.
|
||||
*/
|
||||
function RouteViews() {
|
||||
const [pathname, setPathname] = useState(() =>
|
||||
typeof window === 'undefined' ? '/' : window.location.pathname,
|
||||
)
|
||||
useEffect(() => {
|
||||
const sync = () => setPathname(window.location.pathname)
|
||||
window.addEventListener('popstate', sync)
|
||||
return () => window.removeEventListener('popstate', sync)
|
||||
}, [])
|
||||
usePageview(telemetryAllowed(pathname) ? pathname : null)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the client. `enabled` is the single gate every plane reads — it stops
|
||||
* init, enqueue, flush and the error handlers alike, so an off state emits
|
||||
* nothing at all rather than emitting less.
|
||||
*
|
||||
* The gate is evaluated once per document, which is exact here BECAUSE
|
||||
* navigation is full-page: the path a document is loaded at is the path it dies
|
||||
* at, so there is no window in which a `/callback` load is measured under an
|
||||
* earlier route's decision.
|
||||
*/
|
||||
export function Analytics({ children }: { children: ReactNode }) {
|
||||
const pathname = typeof window === 'undefined' ? '/' : window.location.pathname
|
||||
const enabled = browserConsent() && telemetryAllowed(pathname)
|
||||
|
||||
return (
|
||||
<EventProvider config={{ product: 'id', host: HOST, ingestKey: INGEST_KEY, enabled }}>
|
||||
<RouteViews />
|
||||
{children}
|
||||
</EventProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
/* Hanzo ID — the styling layer for hanzo.id / lux.id / zoo.id / pars.id.
|
||||
*
|
||||
* TOKENS COME FROM @hanzo/design. Not one colour, radius or type size is
|
||||
* invented here; every value below resolves to a design token, so a token change
|
||||
* lands on all four brand portals AND on pay.hanzo.ai — the other half of the
|
||||
* same sign-in-then-pay flow — at once.
|
||||
*
|
||||
* ARCHITECTURE RULE, learned the hard way. A component's surface must never
|
||||
* depend on WHERE it is mounted. This file used to paint controls with the
|
||||
* descendant selectors `form input {…}` and `.hanzo-id-btn, form button {…}`,
|
||||
* so any control that escaped a <form> ancestor fell out of the stylesheet and
|
||||
* rendered as raw UA chrome: the device-approval screen (2 inputs, 0 forms)
|
||||
* showed a 31px beveled browser input next to correctly-styled 44px siblings.
|
||||
* That is the same class of defect as a distributed component shipping utility
|
||||
* class names with no CSS behind them. Every rule below is keyed to a CLASS the
|
||||
* component itself carries — `.hanzo-id-input`, `.hanzo-id-btn`,
|
||||
* `.hanzo-id-field`, `.hanzo-id-form` — and there are no element-descendant
|
||||
* selectors for surface anywhere in this file.
|
||||
*/
|
||||
|
||||
/* ONE import, the whole token layer. This file used to cherry-pick four of the
|
||||
* nine token groups, which meant z, elevation, spacing, fonts and the element
|
||||
* defaults simply did not exist here — and an absent group is invisible: an
|
||||
* unresolved var() paints nothing and reports no error. @hanzo/iam's account
|
||||
* menu alone reaches for --z-popover, --shadow-floating and --space-1..3, none
|
||||
* of which the four-group subset carried.
|
||||
*
|
||||
* The reason for cherry-picking is gone: as of @hanzo/design 0.3.0 Geist is
|
||||
* SELF-HOSTED inside the package (two variable woff2, SIL OFL-1.1), so
|
||||
* tokens/fonts.css no longer makes a request to fonts.googleapis.com and the
|
||||
* sign-in path can take the typeface along with the colours. */
|
||||
@import '@hanzo/design/styles.css';
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
/* 100dvh, with 100vh left underneath it as the fallback for anything that does
|
||||
not know the unit. On a phone `vh` resolves against the LARGEST viewport —
|
||||
the one with the URL bar retracted — so a bar that is actually on screen
|
||||
makes a "full height" page taller than the space it has, and a login page
|
||||
with one card on it acquires a scrollbar and sits low in the window. `dvh`
|
||||
is the height that is really there. */
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* NO focus rule here any more — tokens/base.css ships
|
||||
`:focus-visible{outline:2px solid var(--ring);outline-offset:2px}` to every
|
||||
consumer, and as of 0.3.0 --ring is var(--neutral-500), which measures 4.43:1
|
||||
on --background (WCAG 2.4.13 wants 3:1). The local override that painted the
|
||||
ring --primary existed only because --ring was #333333 at 1.66:1; that was a
|
||||
finding against @hanzo/design, it has been fixed there, so the workaround
|
||||
goes. One focus indicator, defined once, in the design system. */
|
||||
|
||||
/* ── Page shell ────────────────────────────────────────────────────── */
|
||||
|
||||
.hanzo-id-page {
|
||||
flex: 1;
|
||||
/* #root is not a flex container, so `flex: 1` alone never stretches this to
|
||||
the viewport — pin a min height so `main`'s justify-content:center has room
|
||||
to vertically center the auth card (box-sizing:border-box folds in padding).
|
||||
dvh for the reason given on `body`; the vh line is the fallback. */
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
/* index.html declares `viewport-fit=cover`, which puts the page UNDER the
|
||||
notch, the rounded corners and the home indicator. Declaring cover without
|
||||
consuming the insets is strictly worse than not declaring it, and nothing
|
||||
in this stylesheet consumed them: `max()` keeps the 24px gutter everywhere
|
||||
it is already enough and only grows it where the hardware intrudes, so
|
||||
nothing moves on a device with no insets (env() is 0px there). */
|
||||
padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right))
|
||||
max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.hanzo-id-brand-header { padding: 16px 0 32px; }
|
||||
|
||||
/* The home link wraps a 32px mark but measured 32x18 — an inline <a> takes its
|
||||
box from the LINE BOX of its own font, not from a replaced child, so the tap
|
||||
target was SMALLER than the logo inside it. inline-flex gives the anchor the
|
||||
mark's real box; the padding/negative-margin pair then grows the hit area to
|
||||
the 44px floor while leaving the mark exactly where it was — the padding
|
||||
offsets the margin, so the logo's painted position and the header's height
|
||||
are both unchanged, and only the invisible target bleeds into the gutter. */
|
||||
.hanzo-id-brand-header a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: content-box;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 6px;
|
||||
margin: -6px;
|
||||
}
|
||||
|
||||
/* The lockup is 32px tall for EVERY brand, and it has to be said here.
|
||||
BrandHeader writes `height={32}`, but that is a presentational attribute and
|
||||
@hanzo/design's `:where(img,video){height:auto}` overrides it — so the logo
|
||||
was sized by whatever the brand package happened to ship, not by this page.
|
||||
The two assets differ in exactly the way that hides it: @hanzo/brand's SVG
|
||||
carries a viewBox and no width/height, so it has no intrinsic size and landed
|
||||
near the intended 32; @luxfi/brand's declares 1024x1024, so it took the full
|
||||
column and rendered a 342px mark over lux.id's sign-in form. Same markup,
|
||||
same CSS, opposite results, and only the brand nobody was looking at broke.
|
||||
Pinning the height and letting width follow makes the header's own
|
||||
declaration true again whatever a brand ships. */
|
||||
.hanzo-id-brand-header img { height: 32px; width: auto; }
|
||||
|
||||
/* Text fallback when a brand ships no logo asset (see BrandHeader). */
|
||||
.hanzo-id-wordmark {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hanzo-id-page main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* The type scale is the design scale, five rungs: xs / sm / base / xl / 2xl.
|
||||
`h1` at --text-2xl is the SAME 24px as pay.hanzo.ai's `text-2xl` headings, so
|
||||
a heading does not change size when the flow crosses between the two. */
|
||||
.hanzo-id-page h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--leading-2xl);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.hanzo-id-page h2 { margin: 0; font-size: var(--text-xl); line-height: var(--leading-xl); }
|
||||
.hanzo-id-page .lede { color: var(--muted-foreground); margin: 0; font-size: var(--text-base); }
|
||||
|
||||
/* ── Field ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.hanzo-id-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.hanzo-id-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.hanzo-id-input {
|
||||
/* `font: inherit` first: a bare <input>/<button> otherwise renders in the UA
|
||||
face (Arial), which put two typefaces inside one 432px card — including on
|
||||
the primary CTA. */
|
||||
font: inherit;
|
||||
font-size: var(--text-base);
|
||||
background: var(--white-05);
|
||||
color: var(--foreground);
|
||||
/* A control's resting edge is --border-control. 0.4.2 cut it back to the alpha
|
||||
ladder (.15) because a boundary that clears 3:1 on a near-black page IS a
|
||||
mid-grey box, and a form of them reads as a wireframe. The contrast budget
|
||||
went to --ring instead — the focus indicator is what a keyboard user
|
||||
navigates by, it is the only boundary still held at 3:1, and unlike a
|
||||
resting edge it is worth the loudness because it is transient. */
|
||||
border: 1px solid var(--border-control);
|
||||
/* 8px. 0.4.2 moved every control in the system to --radius-md, and tokens/
|
||||
base.css draws a bare field at it; --radius-sm (6px) keeps its job on
|
||||
genuinely small parts — badges, chips, menu rows — where 8px looks bubbly. */
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 14px;
|
||||
min-height: 44px; /* the touch-target floor */
|
||||
width: 100%;
|
||||
}
|
||||
.hanzo-id-input::placeholder { color: var(--text-disabled); }
|
||||
.hanzo-id-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* iOS Safari ZOOMS the viewport when a focused control's text is under 16px,
|
||||
and it does not zoom back out — the user is left on a magnified, sideways-
|
||||
scrolling sign-in page mid-credential. This rule used to be a COMMENT on
|
||||
font-size above claiming `--text-base` prevented that; --text-base is
|
||||
0.875rem = 14px, so the protection was never in effect on either credential
|
||||
field. The type scale has no 16px rung and should not grow one — 16 is not a
|
||||
design value here, it is the threshold in Safari's own zoom heuristic — so it
|
||||
is written as the literal it is, and scoped to touch-primary pointers so the
|
||||
desktop type ramp is untouched. */
|
||||
@media (pointer: coarse) {
|
||||
.hanzo-id-input { font-size: 16px; }
|
||||
}
|
||||
|
||||
/* Checkbox: the UA control, sized up from its 13px intrinsic box and tinted with
|
||||
the brand. Its touch target is the whole <label> row, which is why it is the
|
||||
one control here that is not 44px itself. */
|
||||
.hanzo-id-check {
|
||||
font: inherit;
|
||||
accent-color: var(--primary);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ── Button — ONE primitive, two modifiers ─────────────────────────── */
|
||||
/* `.hanzo-id-btn` is filled (the primary action). `.ghost` is the secondary
|
||||
surface — social sign-in, org rows, Skip/Back. `.row` spreads content for a
|
||||
list row. There is no `.primary`: the base IS primary, so there is one and
|
||||
only one way to write the default button. */
|
||||
|
||||
.hanzo-id-btn {
|
||||
font: inherit;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border: 1px solid transparent;
|
||||
/* Same 8px as the field it sits under. Two stacked controls in one 432px card
|
||||
cannot round differently, and the system draws buttons at 8-10px too. */
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 16px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: background var(--duration-fast) var(--ease-out),
|
||||
border-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
.hanzo-id-btn:disabled,
|
||||
.hanzo-id-btn[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
|
||||
.hanzo-id-btn svg { flex: none; }
|
||||
|
||||
/* The filled button had no hover at all. It has declared `transition:
|
||||
background` since it was written and the system has shipped --primary-hover
|
||||
the whole time, so the most-clicked control on the portal — Sign in, Continue,
|
||||
Create account — was animating a property nothing ever changed. The ghost
|
||||
variant below got its hover and this one was simply missed.
|
||||
|
||||
`:not(.ghost)` rather than relying on order: this selector and the ghost
|
||||
hover both compute to (0,4,0), so with a bare `.hanzo-id-btn:hover` the two
|
||||
would tie and source order would decide which surface a ghost button lifts
|
||||
to. Saying which buttons are meant makes them unable to collide. */
|
||||
.hanzo-id-btn:not(.ghost):hover:not(:disabled):not([aria-disabled='true']) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.hanzo-id-btn.ghost {
|
||||
background: var(--white-05);
|
||||
color: var(--foreground);
|
||||
border-color: var(--border-strong);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
/* Hover is a SURFACE lift, not a brighter edge. This used to also set
|
||||
`border-color: var(--foreground)` — #ededed, 17.9:1 on --background — which
|
||||
painted a near-solid-white 1px wireframe around the two most-hovered controls
|
||||
on the page (Continue with GitHub / Continue with Google). Two problems with
|
||||
moving the edge on hover, and the second is the one that matters:
|
||||
|
||||
1. It read as a wireframe, not as a refined surface. The house hairline is a
|
||||
low-alpha step (--white-10/-15), ~4x quieter than #ededed.
|
||||
2. Brightening an edge is THEME-HOSTILE. Every rung brighter than
|
||||
--neutral-500 is worse on white: --neutral-400 measures 8.3:1 on black but
|
||||
only 2.52:1 on white, which would fail the same WCAG 1.4.11 floor the
|
||||
resting border is documented to hold in BOTH themes. There is no token
|
||||
that gets brighter on dark and darker on light, so the edge must not
|
||||
encode state at all.
|
||||
|
||||
So the border stays the constant control boundary (--border-strong, 4.43:1,
|
||||
both themes) and the background carries the state — the lift the `transition`
|
||||
on .hanzo-id-btn was already animating. */
|
||||
.hanzo-id-btn.ghost:hover:not(:disabled) { background: var(--white-10); }
|
||||
|
||||
.hanzo-id-btn.row { justify-content: space-between; text-align: left; width: 100%; }
|
||||
|
||||
.hanzo-id-cta-row { display: flex; gap: 12px; }
|
||||
.hanzo-id-cta-row .hanzo-id-btn { flex: 1; }
|
||||
|
||||
.hanzo-id-linkbtn {
|
||||
font: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.hanzo-id-footer-links { color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
/* "Forgot password?" / "Create account" / "Sign in" / "Back to sign in" are the
|
||||
only route changes on these pages that are not buttons, and they measured 17px
|
||||
tall — the line box of their own text. Vertical padding on an INLINE box is
|
||||
hit-tested but does not enter line-box height, so this buys the 44px target
|
||||
with zero layout movement and no change to the sentences they sit inside.
|
||||
(17 + 14 + 14 = 45.) The 14px bleed stays inside `main`'s 24px gap, so no two
|
||||
targets overlap. Horizontal size already clears 44px on every one of them. */
|
||||
.hanzo-id-footer-links a {
|
||||
color: var(--text-primary);
|
||||
padding: 14px 0;
|
||||
margin: -14px 0;
|
||||
}
|
||||
|
||||
/* A2P SMS consent disclosure (shown on phone/SMS surfaces). */
|
||||
.hanzo-id-sms-consent { color: var(--muted-foreground); font-size: var(--text-xs); line-height: var(--leading-relaxed); }
|
||||
.hanzo-id-sms-consent p { margin: 0 0 6px; }
|
||||
.hanzo-id-sms-consent-links { margin: 0; }
|
||||
.hanzo-id-sms-consent a { color: var(--text-primary); }
|
||||
|
||||
/* ── Inline message ────────────────────────────────────────────────── */
|
||||
/* Red is one of the two hues @hanzo/design permits, and these are the system's
|
||||
own state tokens — the same #fca5a5 pay.hanzo.ai renders errors in. The
|
||||
informational variant used to be a blue (#78b8ff) that exists nowhere in the
|
||||
system; on a monochrome surface "information" is a neutral card. */
|
||||
|
||||
.hanzo-id-error,
|
||||
.hanzo-id-info {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.hanzo-id-error {
|
||||
background: var(--state-error-bg);
|
||||
color: var(--state-error-text);
|
||||
border: 1px solid var(--state-error);
|
||||
}
|
||||
.hanzo-id-info {
|
||||
background: var(--card);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--white-10);
|
||||
}
|
||||
|
||||
/* ── Loading ───────────────────────────────────────────────────────── */
|
||||
/* `.hanzo-id-spinner` used to be a class with NO rule behind it: the loading
|
||||
state measured 0px tall and was invisible on every portal. */
|
||||
|
||||
.hanzo-id-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 0 auto;
|
||||
border: 2px solid var(--white-15);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
animation: hanzo-id-spin 700ms linear infinite;
|
||||
}
|
||||
@keyframes hanzo-id-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Device-authorization approval ─────────────────────────────────── */
|
||||
|
||||
.hanzo-id-device main { gap: 18px; }
|
||||
.hanzo-id-device-prompt {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin: 0;
|
||||
}
|
||||
.hanzo-id-device-prompt strong { color: var(--text-primary); }
|
||||
.hanzo-id-device-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xl);
|
||||
letter-spacing: var(--tracking-widest);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hanzo-id-device-confirm {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* ── Forced TOTP enrollment ────────────────────────────────────────── */
|
||||
|
||||
.hanzo-id-mfa-enroll { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-mfa-enroll h2 { margin: 0; font-size: var(--text-xl); }
|
||||
.hanzo-id-mfa-qr {
|
||||
align-self: center;
|
||||
background: var(--pure-white);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
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: var(--text-sm); color: var(--muted-foreground); }
|
||||
.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: var(--white-05);
|
||||
border: 1px solid var(--white-10);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: var(--tracking-widest);
|
||||
word-break: break-all;
|
||||
}
|
||||
.hanzo-id-mfa-recovery { font-size: var(--text-sm); color: var(--muted-foreground); line-height: var(--leading-relaxed); }
|
||||
.hanzo-id-mfa-recovery code { letter-spacing: var(--tracking-normal); }
|
||||
|
||||
/* ── Social / Web3 sign-in ─────────────────────────────────────────── */
|
||||
/* These are `.hanzo-id-btn.ghost` — there is no separate social-button surface. */
|
||||
|
||||
.hanzo-id-social { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
/* Wallet chain chooser — revealed under the single "Connect Wallet" button when
|
||||
the injected chain is ambiguous. Indented so the EVM/Solana options read as
|
||||
children of the wallet entry. */
|
||||
.hanzo-id-wallet-chains {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-left: 4px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--white-10);
|
||||
}
|
||||
|
||||
/* Labeled divider between social row and email form. */
|
||||
.hanzo-id-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.hanzo-id-divider::before,
|
||||
.hanzo-id-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--white-10);
|
||||
}
|
||||
.hanzo-id-divider span { padding: 0 12px; }
|
||||
|
||||
/* ── Signed-in portal: the apps launcher ───────────────────────────── */
|
||||
/* These rules used to be an inline `style={{…}}` object on Portal.tsx carrying
|
||||
rgba(255,255,255,0.14), borderRadius 12, fontSize 13, fontWeight 600 and two
|
||||
bare opacities — six invented values for facts the token layer already
|
||||
states. They are classes now for the same reason as everything else in this
|
||||
file: a surface must not depend on where it is mounted. */
|
||||
|
||||
.hanzo-id-portal main { width: 100%; max-width: 760px; }
|
||||
|
||||
.hanzo-id-apps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.hanzo-id-applink {
|
||||
display: block;
|
||||
padding: var(--space-4);
|
||||
/* A tile whose affordance is carried by its label, not its edge — so this is
|
||||
the decorative hairline rung, not the 3:1 control boundary. --white-15 is
|
||||
the ladder rung the old rgba(255,255,255,0.14) was approximating. */
|
||||
border: 1px solid var(--white-15);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
.hanzo-id-applink:hover { border-color: var(--border-strong); text-decoration: none; }
|
||||
.hanzo-id-applink-name { display: flex; justify-content: space-between; font-weight: var(--weight-semibold); }
|
||||
.hanzo-id-applink-name span[aria-hidden] { color: var(--text-tertiary); }
|
||||
.hanzo-id-applink-desc { color: var(--muted-foreground); font-size: var(--text-sm); margin-top: var(--space-1); }
|
||||
|
||||
/* The account control is a control, not a bar: cap it so the trigger (and the
|
||||
menu, which matches the trigger's width) reads at the size it does on every
|
||||
other Hanzo surface instead of spanning the whole 760px column. */
|
||||
.hanzo-id-portal-account { margin-top: var(--space-6); max-width: 260px; }
|
||||
|
||||
/* ── 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,
|
||||
.hanzo-id-onboarding-done h1 { margin: 0; font-size: var(--text-2xl); letter-spacing: var(--tracking-tight); }
|
||||
.hanzo-id-onboarding-body { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-onboarding-done { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.hanzo-id-stepdots { display: flex; gap: 8px; }
|
||||
.hanzo-id-stepdots span {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--white-10);
|
||||
}
|
||||
.hanzo-id-stepdots span.on { background: var(--primary); }
|
||||
|
||||
.hanzo-id-slug-preview {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
margin: -8px 0 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.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-summary { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; }
|
||||
.hanzo-id-summary dt { color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
.hanzo-id-summary dd { margin: 0; font-size: var(--text-sm); font-family: var(--font-mono); }
|
||||
|
||||
/* Consent step */
|
||||
.hanzo-id-consent { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-consent p { margin: 0; color: var(--muted-foreground); font-size: var(--text-sm); line-height: var(--leading-normal); }
|
||||
.hanzo-id-consent-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.hanzo-id-consent-check input { margin-top: 2px; accent-color: var(--primary); }
|
||||
|
||||
/* Plan step — one card per catalog plan + the pay-as-you-go card */
|
||||
.hanzo-id-plans { display: flex; flex-direction: column; gap: 10px; }
|
||||
.hanzo-id-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
min-height: 44px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--white-05);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.hanzo-id-plan:hover { background: var(--white-10); }
|
||||
.hanzo-id-plan:disabled { opacity: 0.6; cursor: default; }
|
||||
.hanzo-id-plan.popular { border-color: var(--border-selected); }
|
||||
.hanzo-id-plan-badge {
|
||||
align-self: flex-end;
|
||||
margin: -4px 0 -18px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
.hanzo-id-plans-empty { margin: 0; color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
.hanzo-id-plan-name { font-weight: 600; }
|
||||
.hanzo-id-plan-price { font-size: var(--text-sm); }
|
||||
.hanzo-id-plan-price em { font-style: normal; color: var(--muted-foreground); }
|
||||
.hanzo-id-plan-desc { color: var(--muted-foreground); font-size: var(--text-sm); }
|
||||
@@ -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,26 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import { Analytics } from './analytics'
|
||||
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>
|
||||
{/*
|
||||
Telemetry wraps App rather than living inside it: the pageview must be
|
||||
recorded for the arrival itself, including the loads where `/config.json`
|
||||
or the brand package fails and App renders nothing but an error. Those are
|
||||
exactly the visits worth counting.
|
||||
*/}
|
||||
<Analytics>
|
||||
<App />
|
||||
</Analytics>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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 `org.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' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// The launcher lists PRODUCTS a person opens, not every host we run.
|
||||
//
|
||||
// Hanzo is three: App (build), Chat (talk), Cloud (the platform + its API).
|
||||
// "Console" is NOT a fourth — it is Cloud's former name, and console.hanzo.ai
|
||||
// now redirects to cloud.hanzo.ai, so listing both showed one product twice
|
||||
// under two names and sent half the traffic through a redirect. It is gone;
|
||||
// nothing here links to console.hanzo.ai.
|
||||
//
|
||||
// Analytics, Platform and Storage came out with it: s3.hanzo.ai answers a bare
|
||||
// XML AccessDenied to a browser (it is an S3 API endpoint, not a page), and the
|
||||
// other two are surfaces inside Cloud rather than products of their own. A
|
||||
// launcher that lands you on an error page teaches people the tiles are broken.
|
||||
const APPS: Record<string, readonly AppLink[]> = {
|
||||
hanzo: [
|
||||
{ name: 'App', href: 'https://hanzo.app', description: 'Build with AI' },
|
||||
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
|
||||
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'Models, compute & API' },
|
||||
],
|
||||
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,61 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, OrgConfig } from '@hanzo/id-shared'
|
||||
import { createIam } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* OAuth/OIDC callback — the portal's OWN PKCE return, and only that.
|
||||
*
|
||||
* One kind of return lands here: an IAM authorization code for a flow this
|
||||
* portal started (password, wallet, or a federated provider begun through
|
||||
* `signinRedirect`). The `@hanzo/iam` SDK's `handleCallback` completes it,
|
||||
* reading back the exact PKCE verifier and state it stored.
|
||||
*
|
||||
* There is no second, social-specific case. A federated sign-in returns from the
|
||||
* IdP to IAM's OWN callback (`/v1/iam/oauth/callback`), which does the code
|
||||
* exchange server-side and sends the browser back here with an ordinary IAM code
|
||||
* — indistinguishable from any other. The page used to carry a branch that
|
||||
* decoded a base64 provider `state` and posted the raw IdP code back to IAM; no
|
||||
* endpoint ever accepted that, and nothing can produce that state any more.
|
||||
*
|
||||
* Routing after the exchange:
|
||||
* - A non-OIDC "come back here" target left in `post_login_redirect` (device
|
||||
* approval) → forward tokens there.
|
||||
* - A bare portal sign-in → `/onboarding`.
|
||||
*
|
||||
* An app that sent the user here for a code never reaches this page at all: that
|
||||
* flow re-enters IAM's authorize endpoint and IAM redirects straight to the app.
|
||||
*/
|
||||
export function Callback({ org, brand }: { org: OrgConfig; brand: BrandContract }) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
const iam = createIam(org)
|
||||
iam
|
||||
.handleCallback(window.location.href)
|
||||
.then((tok) => {
|
||||
const target = sessionStorage.getItem('post_login_redirect')
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
if (target) {
|
||||
// Forward tokens to the page that sent the user to sign in.
|
||||
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)))
|
||||
}, [org])
|
||||
|
||||
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,293 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { LoginForm, SocialButtons, type AuthClient, type DeviceInfoResult } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* RFC 8628 device-authorization approval (`/login/oauth/device`).
|
||||
*
|
||||
* The terminal leg of `hanzo login`: the CLI shows a short `user_code` and sends
|
||||
* the human here (the IAM `verification_uri`; `verification_uri_complete`
|
||||
* appends the code as a PATH segment, `/login/oauth/device/<code>` — IAM builds
|
||||
* it that way because that is the route this page is registered on, and
|
||||
* `readUserCode` accepts the `?user_code=` query form too). The human signs in
|
||||
* to the SAME issuer, confirms the code matches what their device shows, and
|
||||
* approves — which binds their identity onto the pending row (`Token.User`,
|
||||
* owner/name) so the CLI's token poll stops answering `authorization_pending`
|
||||
* and mints. There is no `UserSignIn` flag; an empty `User` IS "not yet
|
||||
* approved".
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* The screen exists to answer ONE question — which application am I authorizing?
|
||||
* — so the application it names is read from the code (`client.deviceInfo`) and
|
||||
* from nowhere else. Until IAM has named one there is no name on screen and no
|
||||
* button to press.
|
||||
*/
|
||||
|
||||
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())
|
||||
// The code is prefilled from `?user_code=` and stays EDITABLE, which is the
|
||||
// anti-phishing property that matters: approving is an explicit click on a
|
||||
// code the human can read and correct against what their own device shows.
|
||||
// There was also a "I started this sign-in" checkbox in front of that click.
|
||||
// No device page anyone actually uses has one — Google, GitHub and AWS all
|
||||
// show the code and an Approve button — and a tickbox is not evidence: a
|
||||
// victim being walked through a crafted link ticks it as readily as they
|
||||
// click Approve. It bought nothing and cost every honest user a step.
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// WHICH application is asking — the whole point of this screen, and the one
|
||||
// thing the page cannot know on its own. It used to render `org.appName`: this
|
||||
// PORTAL's own branding, a static per-org string, so a sign-in started by
|
||||
// hanzo-cli was approved under a screen reading "hanzo-console". The client is
|
||||
// a property of the CODE (it lives on the pending row and is what the backend
|
||||
// actually approves), so it is read from the code — `client.deviceInfo`,
|
||||
// IAM `POST /v1/iam/oauth/device/info`.
|
||||
//
|
||||
// That read is session-gated and answers with one opaque refusal for unknown /
|
||||
// expired / already-approved, so it is no oracle for hunting live codes: it
|
||||
// tells a caller strictly less than the approval that same caller could already
|
||||
// attempt.
|
||||
//
|
||||
// null = not resolved yet. NOTHING is rendered in its place — no fallback name,
|
||||
// no portal name, no guess. Naming the wrong party is the defect being fixed
|
||||
// here, and a screen that names none is strictly better than one that lies.
|
||||
const [app, setApp] = useState<DeviceInfoResult | null>(null)
|
||||
const named = app?.ok ? app : null
|
||||
|
||||
// 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.org.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.org.iamUrl])
|
||||
|
||||
// Ask WHICH application the code belongs to. Needs both halves of what the
|
||||
// endpoint is gated on: the issuer session (every phase past the check has one
|
||||
// except `signin`, and the boolean keeps consent/approving from re-asking) and
|
||||
// a code to ask about.
|
||||
//
|
||||
// The debounce is what makes a hand-typed code work: each keystroke is a
|
||||
// different code, and a partial one is not a real code — without it the human
|
||||
// watches IAM's refusal flash at them while they are still typing.
|
||||
const signedIn = phase.s !== 'checking' && phase.s !== 'signin'
|
||||
const blank = userCode.trim().length === 0
|
||||
useEffect(() => {
|
||||
if (!signedIn || blank) return
|
||||
let alive = true
|
||||
const t = setTimeout(() => {
|
||||
client.deviceInfo(userCode).then((r) => {
|
||||
if (!alive) return
|
||||
// The session lapsed between the get-account check and this read. The
|
||||
// signin phase preserves the code in `returnTo`, so the human lands back
|
||||
// here with it intact.
|
||||
if (!r.ok && r.loginRequired) setPhase({ s: 'signin' })
|
||||
else setApp(r)
|
||||
})
|
||||
}, 250)
|
||||
return () => {
|
||||
alive = false
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [client, userCode, signedIn, blank])
|
||||
|
||||
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
|
||||
// ONE place shows a failure, whichever leg produced it: the approval itself, or
|
||||
// the lookup that has to name an application before an approval is offered.
|
||||
const failure = error ?? (app && !app.ok ? app.error : null)
|
||||
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>Approve this device</h1>
|
||||
{email ? <p className="lede">Signed in as {email}</p> : null}
|
||||
|
||||
{/* The application is named ONLY once IAM has confirmed it — the clientId
|
||||
alongside the display name, so a technical human can check it reads
|
||||
`hanzo-cli` exactly and not something that merely looks like it. Until
|
||||
then the sentence says a device, because that is all the page knows. */}
|
||||
<p className="hanzo-id-device-prompt">
|
||||
{named ? (
|
||||
<>
|
||||
<strong>{named.displayName}</strong> (<code>{named.clientId}</code>) is asking to
|
||||
sign in as you.
|
||||
</>
|
||||
) : (
|
||||
'A device is asking to sign in as you.'
|
||||
)}{' '}
|
||||
Approve ONLY if the code below matches the one shown on that device, and only if you
|
||||
started this sign-in yourself.
|
||||
</p>
|
||||
|
||||
<label className="hanzo-id-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-input hanzo-id-device-code"
|
||||
value={userCode}
|
||||
// A name — and a failure — belongs to a CODE. Edit the code and both are
|
||||
// dropped in the same commit, so no name is ever left on screen for a
|
||||
// frame beside a code it was not confirmed for.
|
||||
onChange={(e) => {
|
||||
setUserCode(e.target.value)
|
||||
setApp(null)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder="e.g. K7M4P2QH"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{consent && named ? (
|
||||
<p className="hanzo-id-info">
|
||||
<strong>{named.displayName}</strong> needs your consent to continue. By approving
|
||||
you grant the device showing this code access to your profile.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{failure ? <p role="alert" className="hanzo-id-error">{failure}</p> : null}
|
||||
|
||||
<div className="hanzo-id-cta-row">
|
||||
<button
|
||||
type="button"
|
||||
// Nothing is approved until IAM has named what is being approved. An
|
||||
// unresolved or refused lookup leaves no button to press, rather than a
|
||||
// button that authorizes an unnamed party.
|
||||
className="hanzo-id-btn"
|
||||
disabled={busy || !named}
|
||||
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,212 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { idBrandLabel, 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.org.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.org.appName,
|
||||
organization: client.org.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 {idBrandLabel(brand, client.org.orgId)}</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">
|
||||
{/* Carry the OIDC request across. These are full page loads, so a bare
|
||||
href drops the client_id, redirect_uri, state and PKCE challenge the
|
||||
app sent — and registration then has nothing to return the new user
|
||||
to. `Signup` reads exactly these params. */}
|
||||
<a href={`/forget${window.location.search}`}>Forgot password?</a> ·{' '}
|
||||
<a href={`/signup${window.location.search}`}>Create account</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { BrandContract, OrgConfig } 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'
|
||||
|
||||
/** Fleet default pay origin; a white-label brand overrides via catalog `payUrl`. */
|
||||
const DEFAULT_PAY_URL = 'https://pay.hanzo.ai'
|
||||
|
||||
/**
|
||||
* Post-login onboarding page.
|
||||
*
|
||||
* Reached after a bare portal sign-in (no downstream `redirect_uri`). Mounts
|
||||
* the `@hanzo/id-onboarding` flow (org → project → wallet → consent → plan)
|
||||
* 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.
|
||||
*
|
||||
* NEVER REPEATS: completion is recorded on the USER (Properties, via
|
||||
* saveOnboarding) — so before mounting the flow this page reads it back and,
|
||||
* if the user already finished onboarding on ANY browser, goes straight to
|
||||
* the portal. The read failing open (network blip → run the flow again) is
|
||||
* deliberate: repeating is annoying, silently skipping a required step is
|
||||
* worse.
|
||||
*
|
||||
* On completion it routes by the plan choice — the platform is prepay-only,
|
||||
* so a plan goes to the pay cart and pay-as-you-go goes to the top-up flow.
|
||||
* A downstream app that wanted a token would have carried `redirect_uri` and
|
||||
* never reached here.
|
||||
*/
|
||||
export function Onboarding({ org, brand }: { org: OrgConfig; brand: BrandContract }) {
|
||||
const iam = useMemo(() => createIam(org), [org])
|
||||
const payUrl = org.payUrl || DEFAULT_PAY_URL
|
||||
|
||||
const service = useMemo(
|
||||
() =>
|
||||
createOnboardingService({
|
||||
iamUrl: org.iamUrl,
|
||||
orgId: org.orgId,
|
||||
getAccessToken: () => iam.getValidAccessToken(),
|
||||
}),
|
||||
[org, iam],
|
||||
)
|
||||
|
||||
// null = still checking; false = run the flow; true = already done, leaving.
|
||||
const [alreadyDone, setAlreadyDone] = useState<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
// TOKEN BOOTSTRAP. A password/form sign-in mints a SESSION COOKIE and
|
||||
// lands here directly — the PKCE SDK holds no token, so every onboarding
|
||||
// WRITE (update-user needs a bearer; the cookie alone is refused, and
|
||||
// rightly — a cookie-authed write is a CSRF surface) answered 401 and
|
||||
// the funnel dead-ended at consent. With a live session, authorize is
|
||||
// the silent-SSO branch: signinRedirect bounces through IAM with no UI,
|
||||
// /callback stores the token and returns to /onboarding. One bounce per
|
||||
// session, guarded, so a broken mint degrades to the read-only 401
|
||||
// instead of a redirect loop.
|
||||
const token = await iam.getValidAccessToken().catch(() => null)
|
||||
if (!alive) return
|
||||
if (!token) {
|
||||
const guard = 'onboarding.token_bounce'
|
||||
if (!sessionStorage.getItem(guard)) {
|
||||
sessionStorage.setItem(guard, '1')
|
||||
void iam.signinRedirect()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
sessionStorage.removeItem('onboarding.token_bounce')
|
||||
}
|
||||
try {
|
||||
const { completedAt } = await service.readOnboarding()
|
||||
if (!alive) return
|
||||
if (completedAt) {
|
||||
setAlreadyDone(true)
|
||||
window.location.replace('/?signed_in=1')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Read failing open (network blip → run the flow) is deliberate.
|
||||
}
|
||||
if (alive) setAlreadyDone(false)
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [service, iam])
|
||||
|
||||
function onComplete(state: OnboardingState) {
|
||||
// Prepay-only funnel: the last step recorded a choice, now act on it.
|
||||
// - a plan slug → the pay cart, seats + payment there (price is the
|
||||
// catalog's — commerce recomputes server-side, the slug is enough)
|
||||
// - pay as you go → the top-up flow ($5 minimum, all methods)
|
||||
// The plan choice is already persisted on the user, so bouncing off the
|
||||
// payment page never re-enters onboarding.
|
||||
const choice = state.planChoice
|
||||
if (choice === 'payg') {
|
||||
window.location.replace(`${payUrl}/onboard`)
|
||||
} else if (choice) {
|
||||
window.location.replace(`${payUrl}/cart?plan=${encodeURIComponent(choice)}`)
|
||||
} else {
|
||||
// No recorded choice (should not happen — the plan step requires one):
|
||||
// land on the authenticated portal rather than a dead end.
|
||||
window.location.replace('/?signed_in=1')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-onboarding-page">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
{alreadyDone === false ? (
|
||||
<OnboardingFlow
|
||||
service={service}
|
||||
brandName={brand.name}
|
||||
connectWallet={connectInjectedWallet}
|
||||
onComplete={onComplete}
|
||||
payUrl={payUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="hanzo-id-spinner" aria-label="Loading" />
|
||||
)}
|
||||
</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 a full-row read-merge-write in the
|
||||
* service), so we connect and return account.address — no signature round-trip.
|
||||
*/
|
||||
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,120 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { IamIdentity } from '@hanzo/iam/react'
|
||||
import { UserMenu, resolveIdentity } from '@hanzo/iam/react'
|
||||
import type { BrandContract, OrgConfig } 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'; identity: IamIdentity | null }
|
||||
|
||||
/**
|
||||
* 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;
|
||||
* `org.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,
|
||||
org,
|
||||
}: {
|
||||
client: AuthClient
|
||||
brand: BrandContract
|
||||
org: OrgConfig
|
||||
}) {
|
||||
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', org.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') {
|
||||
// `resolveIdentity` is the SAME name/avatar/initials resolution every
|
||||
// Hanzo surface shows, so the portal cannot disagree with the console
|
||||
// about who you are — and it never falls back to a raw uuid.
|
||||
setAuth({ s: 'authed', identity: resolveIdentity(d, {}) })
|
||||
} else {
|
||||
setAuth(justSignedIn ? { s: 'authed', identity: null } : { s: 'anon' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setAuth(justSignedIn ? { s: 'authed', identity: null } : { s: 'anon' })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [org.iamUrl])
|
||||
|
||||
if (auth.s === 'loading') {
|
||||
return (
|
||||
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? 'var(--primary)' }} />
|
||||
</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(org.orgId)
|
||||
const billingUrl = billingFor(org.orgId)
|
||||
const logoutUrl = client.logout(undefined, `${org.publicOrigin}/login`)
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-portal">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Your {brand.name} apps</h1>
|
||||
<div className="hanzo-id-apps">
|
||||
{apps.map((a) => (
|
||||
<a key={a.name} className="hanzo-id-applink" href={a.href}>
|
||||
<div className="hanzo-id-applink-name">
|
||||
<span>{a.name}</span>
|
||||
<span aria-hidden>↗</span>
|
||||
</div>
|
||||
<div className="hanzo-id-applink-desc">{a.description}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
{/* The ONE account control. This was a hand-rolled "Billing / Sign out"
|
||||
link row; every Hanzo surface mounts @hanzo/iam's UserMenu instead,
|
||||
so identity, billing and sign-out read and behave identically here,
|
||||
on hanzo.chat and in the console. The portal's session is its own
|
||||
cookie read rather than an IamProvider, which is exactly what the
|
||||
`identity` / `isAuthenticated` / `onSignOut` overrides are for.
|
||||
No `brand` prop: omitting `markSvg` would put the HANZO mark on
|
||||
lux.id and zoo.id, and this one image serves all four portals. */}
|
||||
<div className="hanzo-id-portal-account">
|
||||
<UserMenu
|
||||
identity={auth.identity}
|
||||
isAuthenticated
|
||||
usageUrl={billingUrl}
|
||||
usageLabel="Billing"
|
||||
onSignOut={() => { window.location.href = logoutUrl }}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
// The same downstream OIDC request `Login` reads. Registration ends in a
|
||||
// sign-in, so it needs the whole request — not just the client and its
|
||||
// callback — or the minted code carries no PKCE binding and no state.
|
||||
const state = sp.get('state') ?? 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
|
||||
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}
|
||||
clientIdOverride={clientIdOverride}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
nonce={nonce}
|
||||
/>
|
||||
<p className="hanzo-id-footer-links">
|
||||
Already have an account? <a href={`/login${window.location.search}`}>Sign in</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Every token this surface references must RESOLVE.
|
||||
*
|
||||
* An undefined CSS custom property paints nothing and reports no error, so this
|
||||
* whole class of defect survives review: `var(--surface-1)` and
|
||||
* `var(--shadow-lg)` shipped in @hanzo/iam's account menu against a token layer
|
||||
* that defines neither, and the menu rendered transparent. "It is declared" was
|
||||
* never evidence — nor was "it type-checks", because the reference is built at
|
||||
* runtime from a string and is invisible to both the compiler and grep.
|
||||
*
|
||||
* So the gate is resolution, and it is computed from what the bundle ACTUALLY
|
||||
* serves: it walks app.css's @import graph into the installed @hanzo/design,
|
||||
* collects the tokens those files declare, then asserts that every var(--x)
|
||||
* anywhere under src/ — plus every token @hanzo/iam paints its menu with — is
|
||||
* in that set.
|
||||
*
|
||||
* This fails if someone cherry-picks token groups again (the four-of-nine
|
||||
* subset this file used to import left --z-*, --shadow-* and --space-* out),
|
||||
* if @hanzo/design renames or drops a token, or if a component starts asking
|
||||
* for a token in @hanzo/brand's vocabulary instead of @hanzo/design's.
|
||||
*/
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const SRC = path.join(import.meta.dirname, '.')
|
||||
|
||||
/**
|
||||
* Both packages restrict `exports`, so resolve them by walking up for the
|
||||
* node_modules directory rather than by asking Node for a subpath it refuses.
|
||||
*/
|
||||
function pkgRoot(name: string): string {
|
||||
for (let d = SRC; d !== path.dirname(d); d = path.dirname(d)) {
|
||||
const p = path.join(d, 'node_modules', name)
|
||||
if (fs.existsSync(path.join(p, 'package.json'))) return fs.realpathSync(p)
|
||||
}
|
||||
throw new Error(`${name} is not installed`)
|
||||
}
|
||||
const DESIGN = pkgRoot('@hanzo/design')
|
||||
|
||||
const read = (p: string) => fs.readFileSync(p, 'utf8')
|
||||
const declaredIn = (css: string) => [...css.matchAll(/(--[a-zA-Z0-9-]+)\s*:/g)].map((m) => m[1])
|
||||
const referencedIn = (css: string) => [...css.matchAll(/var\(\s*(--[a-zA-Z0-9-]+)/g)].map((m) => m[1])
|
||||
|
||||
/** Follow @import from an entry stylesheet into the @hanzo/design package. */
|
||||
function tokenFiles(entry: string, seen = new Set<string>()): string[] {
|
||||
for (const m of read(entry).matchAll(/@import\s+(?:url\()?['"]([^'"]+)['"]/g)) {
|
||||
const spec = m[1]
|
||||
const abs = spec.startsWith('@hanzo/design/')
|
||||
? path.join(DESIGN, spec.slice('@hanzo/design/'.length))
|
||||
: path.resolve(path.dirname(entry), spec)
|
||||
if (seen.has(abs) || !fs.existsSync(abs)) continue
|
||||
seen.add(abs)
|
||||
tokenFiles(abs, seen)
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
/** Every .css/.ts/.tsx under src/, so inline `var(--x)` in a component counts. */
|
||||
function sources(dir: string, out: string[] = []): string[] {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name)
|
||||
if (e.isDirectory()) sources(p, out)
|
||||
// Skip this file: its own doc comment quotes `var(--x)`.
|
||||
else if (/\.(css|tsx?)$/.test(e.name) && p !== import.meta.filename) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const served = tokenFiles(path.join(SRC, 'app.css'))
|
||||
const available = new Set(served.flatMap((f) => declaredIn(read(f))))
|
||||
const files = sources(SRC)
|
||||
const local = new Set(files.flatMap((f) => declaredIn(read(f))))
|
||||
|
||||
/**
|
||||
* Coverage is judged by TOKENS, not by files. This used to assert that each
|
||||
* `tokens/<group>.css` was itself reached through @import, which stopped being
|
||||
* true at @hanzo/design 0.4.x: gen-tokens.mjs now FLATTENS all ten groups into
|
||||
* styles.css so a bundler never has to resolve those subpaths. Nothing was
|
||||
* dropped — the files still ship, they are just inlined — so asking "is every
|
||||
* token this group declares actually served?" catches the cherry-picking this
|
||||
* gate exists for, and survives however the package chooses to assemble itself.
|
||||
*/
|
||||
test('app.css serves the whole @hanzo/design token layer, not a subset', () => {
|
||||
const groups = fs.readdirSync(path.join(DESIGN, 'tokens')).filter((f) => f.endsWith('.css'))
|
||||
const missing = groups.filter((g) => {
|
||||
const declared = [...new Set(declaredIn(read(path.join(DESIGN, 'tokens', g))))]
|
||||
return declared.length > 0 && !declared.every((t) => available.has(t))
|
||||
})
|
||||
assert.deepEqual(missing, [], `token groups authored by @hanzo/design but never served here: ${missing.join(', ')}`)
|
||||
/* base.css is the odd group and token coverage cannot see it: it ships the
|
||||
ELEMENT DEFAULTS (the control, the focused control, the scrollbar) as 23
|
||||
:where() rules, and the single token it declares — --border — is declared by
|
||||
colors.css too. It is also the only group that opens `@layer base`, so that
|
||||
is the exact marker for "the defaults are actually being served". */
|
||||
assert.ok(
|
||||
served.some((f) => /@layer\s+base/.test(read(f))),
|
||||
'the element defaults from tokens/base.css are not served'
|
||||
)
|
||||
})
|
||||
|
||||
test('every token this surface references is defined', () => {
|
||||
const unresolved = new Map<string, string[]>()
|
||||
for (const f of files) {
|
||||
for (const name of referencedIn(read(f))) {
|
||||
if (available.has(name) || local.has(name)) continue
|
||||
const at = unresolved.get(name) ?? []
|
||||
at.push(path.relative(SRC, f))
|
||||
unresolved.set(name, at)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
[...unresolved].map(([n, at]) => `${n} (${[...new Set(at)].join(', ')})`),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('every token @hanzo/iam paints the account menu with is defined', () => {
|
||||
// The menu is a distributed component: it emits its own stylesheet at
|
||||
// runtime, so its token references never appear in this repo's source and no
|
||||
// amount of grepping here would find them. Read them out of the shipped
|
||||
// bundle instead — literal `var(--x)` plus the names its tok() helper builds.
|
||||
const iam = read(path.join(pkgRoot('@hanzo/iam'), 'dist/react.js'))
|
||||
const names = new Set([
|
||||
...referencedIn(iam),
|
||||
...[...iam.matchAll(/\btok\(\s*["']([a-zA-Z0-9-]+)["']/g)].map((m) => `--${m[1]}`),
|
||||
])
|
||||
const unresolved = [...names].filter((n) => !available.has(n)).sort()
|
||||
assert.deepEqual(unresolved, [])
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/**
|
||||
* Publishable event-ingest key (pk-…), inlined at build time from the
|
||||
* EVENT_INGEST_KEY build-arg (KMS `deploy/EVENT_INGEST_KEY`, env `prod`).
|
||||
* Declared so a typo reads as a type error rather than as `any` — Vite's
|
||||
* ImportMetaEnv carries a string index signature, so an undeclared
|
||||
* `import.meta.env.VITE_EVENT_INGEST_KEZ` would type-check and ship empty.
|
||||
*/
|
||||
readonly VITE_EVENT_INGEST_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# Hanzo ID — the ONE file that says how this repo builds, gates and ships.
|
||||
#
|
||||
# The only workflow is `.hanzo/workflows/cicd.yml`, ~10 lines that import
|
||||
# hanzoai/ci; everything real is here, and platform.hanzo.ai reads this same
|
||||
# file. It replaces TWO image lanes that had drifted apart:
|
||||
#
|
||||
# .hanzo/workflows/deploy.yml the one that worked — buildx, two registry
|
||||
# logins, a version read out of package.json and
|
||||
# a post-push manifest check. Every line of it is
|
||||
# something the reusable already does.
|
||||
# .github/workflows/docker.yml a second lane on a plane with no runners for
|
||||
# our labels. Already reduced to an echo, but it
|
||||
# kept the shape of a build lane alive in a
|
||||
# directory this forge cannot even read.
|
||||
#
|
||||
# Neither gated anything. This repo has 13 test files and 160 assertions and CI
|
||||
# ran none of them, which is how `pkgs/shared/src/org.test.ts` sat RED on main
|
||||
# while the code it tested was correct.
|
||||
|
||||
# Gates run DIRECTLY on the runner, not inside the image build. hanzoai/ci
|
||||
# provisions Node 22 and enables corepack; corepack then reads `packageManager`
|
||||
# from package.json, so the pnpm this uses is the SAME pnpm the Dockerfile
|
||||
# activates — stated once, in package.json, rather than pinned again here.
|
||||
#
|
||||
# Three gates, because there are three different ways this repo breaks and one
|
||||
# combined gate would report all of them as the same failure.
|
||||
test:
|
||||
- name: install
|
||||
# --frozen-lockfile, deliberately stricter than the Dockerfile's
|
||||
# `--frozen-lockfile=false`. The image build must not be blocked by a
|
||||
# lockfile that drifted; CI is exactly where that drift should be caught.
|
||||
run: |
|
||||
set -e
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
- name: typecheck
|
||||
# Vite does NOT typecheck — `vite build` transpiles and strips types, so a
|
||||
# type error ships a green image. This is the only thing in the pipeline
|
||||
# that reads the types across all 7 workspace packages.
|
||||
run: |
|
||||
set -e
|
||||
pnpm -r tc
|
||||
- name: unit
|
||||
# vitest over pkgs/**/src and apps/**/src (vitest.config.ts). 160 tests,
|
||||
# including the org resolver that decides which brand a host authenticates
|
||||
# as and which redirect_uri every social hop sends — the surface that
|
||||
# produced days of "login is broken" and cannot be verified by looking at it.
|
||||
run: |
|
||||
set -e
|
||||
pnpm test
|
||||
|
||||
images:
|
||||
- name: id
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
repo: ghcr.io/hanzoai/id
|
||||
platforms: [linux/amd64]
|
||||
# Fetched from KMS (`deploy/EVENT_INGEST_KEY`, env `prod`) and passed as
|
||||
# --build-arg EVENT_INGEST_KEY. The reusable fails closed on an empty value,
|
||||
# and the Dockerfile gates the shape and then asserts the key actually landed
|
||||
# in the bundle — because a build that is green with an unattributed bundle
|
||||
# is the failure mode here, not a build that goes red.
|
||||
#
|
||||
# This is the ONE spelling the fleet carries: KMS holds the plain name, each
|
||||
# Dockerfile re-exports it with the prefix its bundler inlines.
|
||||
build_secrets: [EVENT_INGEST_KEY]
|
||||
# The version comes from package.json — hanzoai/ci's bin/imgver reads it as
|
||||
# the declared floor, exactly as deploy.yml's `node -p require('./package.
|
||||
# json').version` did, so a release is still a version bump and nothing about
|
||||
# how this image is named changes.
|
||||
#
|
||||
# One difference worth stating: imgver takes max(declared, published)+1 patch
|
||||
# when the declared version is ALREADY at the registry, where deploy.yml
|
||||
# failed the build instead ("bump the version to cut a release"). Both refuse
|
||||
# to put a second digest under a tag someone may already be pinning; this one
|
||||
# publishes the next patch rather than going red on a docs commit.
|
||||
#
|
||||
# oci.hanzo.ai is still written to — the reusable crane-copies the exact tag
|
||||
# set after the ghcr push. The PATH changes: deploy.yml pushed
|
||||
# `oci.hanzo.ai/id`, the reusable writes the org-qualified
|
||||
# `oci.hanzo.ai/hanzoai/id`, which is the fleet convention. Nothing live
|
||||
# pulls either one; charts/app/values/hanzo/id.yaml pins
|
||||
# `ghcr.io/hanzoai/id` by tag AND digest.
|
||||
|
||||
# No `deploy:` ON PURPOSE, and this is deploy.yml's own rule, not a new one.
|
||||
#
|
||||
# id is governed by Hanzo CD. The tag it runs is declared in
|
||||
# hanzoai/universe (charts/app/values/hanzo/id.yaml, tag + digest together), and
|
||||
# the in-cluster reconcile restores that within ~60-90s. A CI-side `kubectl
|
||||
# patch` therefore CANNOT stick — and the reason to refuse it is not that it
|
||||
# fails, it is that it LOOKS like it works: the patch applies, the pod rolls, and
|
||||
# CD quietly puts the old tag back a minute later.
|
||||
#
|
||||
# It would also be actively wrong here. The reusable's deploy step guards a
|
||||
# semver pin against a transient branch build by reading the current tag out of
|
||||
# `infra/k8s/operator/crs/<svc>.yaml` — a path this service does not use — so the
|
||||
# guard would not fire and a `sha-<short>-amd64` tag would be written over a
|
||||
# reviewed semver+digest pin on the live sign-in surface for every property in
|
||||
# the fleet.
|
||||
#
|
||||
# Build and deploy are separate concerns: this emits an immutable image, git
|
||||
# declares desired state, CD applies it. To ship a build, set image.tag AND
|
||||
# image.digest in universe and push.
|
||||
@@ -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,19 @@
|
||||
{
|
||||
"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.28",
|
||||
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"packageManager": "pnpm@10.15.0",
|
||||
"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",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"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",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@hanzo/id-auth",
|
||||
"version": "0.1.7",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-connect": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.21.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,156 @@
|
||||
/**
|
||||
* 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"` + the challenge list — named `mfa` first, legacy
|
||||
* `data2`; both decode until the legacy slot is deleted. 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 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
import { createAuthClient, mfaChannelOf, MFA_TOTP } from './client.ts'
|
||||
|
||||
const TENANT: OrgConfig = {
|
||||
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({ org: 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')
|
||||
})
|
||||
|
||||
const CHALLENGE = [{ mfaType: 'app', enabled: true }, { mfaType: 'sms', enabled: true }]
|
||||
|
||||
// Both spellings decode until IAM's envelope rename lands everywhere and the
|
||||
// legacy slot is deleted: named `mfa` (new), untyped `data2` (legacy), and
|
||||
// named-first precedence when a transitional server sends both.
|
||||
test.each([
|
||||
['named mfa', { status: 'ok', data: 'NextMfa', mfa: CHALLENGE }],
|
||||
['legacy data2', { status: 'ok', data: 'NextMfa', data2: CHALLENGE }],
|
||||
['mfa wins over data2', { status: 'ok', data: 'NextMfa', mfa: CHALLENGE, data2: [{ mfaType: 'email', enabled: true }] }],
|
||||
])('login → NextMfa maps to a challenge signal and carries the allowed types (%s)', async (_spelling, body) => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ org: 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({ org: 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({ org: 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({ org: 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({ org: 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({ org: 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,685 @@
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import type { OrgConfig } 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 org(overrides: Partial<OrgConfig> = {}): OrgConfig {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// `client.login` is a PURE PASSTHROUGH for `organization`: it sends what the
|
||||
// caller gave it and omits the key when there is nothing to send. That is the
|
||||
// contract these two tests pin, and it is unchanged.
|
||||
//
|
||||
// What DID change is whose job it is to supply one. This used to be deliberate
|
||||
// omission — IAM resolved the user cross-org so a colliding identity
|
||||
// (z@hanzo.ai exists in both `admin` and `hanzo`) landed on admin/* with a full
|
||||
// multi-org session. iam2 removed that on purpose, treating the collision as a
|
||||
// defect ("the F-2 bug where z@hanzo.ai collided across admin and hanzo": it
|
||||
// coupled lockout counters across rows and gave a brute-force oracle on the
|
||||
// superadmin), and now REFUSES an org-less login. So LoginForm resolves the
|
||||
// app's own org via get-app-login and always passes one. Do not re-add an
|
||||
// omit-the-org path here expecting the server to figure it out — it will not,
|
||||
// and it fails with an HTTP 200 that reads like a wrong password.
|
||||
test('login omits organization when the caller supplies none', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
// organization intentionally not provided — LoginForm now always resolves one
|
||||
})
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(
|
||||
'organization' in calls[0]!.body,
|
||||
false,
|
||||
'organization must be absent when the caller supplies none — the client never invents one',
|
||||
)
|
||||
// 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({ org: org(), 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({ org: org(), 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({ org: org(), 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({ org: org(), 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 (every new customer was stranded on the portal): IAM's signup is
|
||||
// CREATE-ONLY — it sets no session and mints no code. Signup must therefore end
|
||||
// in a real sign-in, or the app that sent the user waits forever for a code.
|
||||
test('signup COMPLETES the OIDC request — create, then sign in, then redirect back with the code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-app',
|
||||
application: 'hanzo-app',
|
||||
organization: 'hanzo',
|
||||
redirectUri: 'https://hanzo.app/auth/callback',
|
||||
state: 'xyz',
|
||||
codeChallenge: 'CHALLENGE',
|
||||
codeChallengeMethod: 'S256',
|
||||
})
|
||||
|
||||
// Two legs, in order: create the row, then authenticate it.
|
||||
assert.equal(calls.length, 2)
|
||||
assert.match(calls[0]!.url, /\/v1\/iam\/signup/)
|
||||
assert.match(calls[1]!.url, /\/v1\/iam\/login/)
|
||||
|
||||
// The sign-in leg carries the downstream request, so the code is PKCE-bound.
|
||||
assert.match(calls[1]!.url, /code_challenge=CHALLENGE/)
|
||||
assert.match(calls[1]!.url, /code_challenge_method=S256/)
|
||||
assert.match(calls[1]!.url, /type=code/)
|
||||
assert.equal(calls[1]!.body.username, 'new@hanzo.ai')
|
||||
|
||||
// And the caller is handed a destination BACK AT THE APP — never the portal's
|
||||
// own /onboarding, which is where the create-only response used to land.
|
||||
assert.equal(res.redirectUrl, 'https://hanzo.app/auth/callback?code=AUTHCODE&state=xyz')
|
||||
})
|
||||
|
||||
// `autoSignin` was posted for its name and dropped on the floor: the Go
|
||||
// signupForm has no such field, so it never signed anyone in. Do not post a flag
|
||||
// the server does not read — it is what made this look like it worked.
|
||||
test('signup does NOT post autoSignin (IAM has no such field)', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-app',
|
||||
application: 'hanzo-app',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal('autoSignin' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// IAM refuses with HTTP 200 + status:"error", so the status code proves nothing.
|
||||
// A refused create must surface the reason and must NOT go on to try a login.
|
||||
test('a refused signup surfaces the reason and never attempts a sign-in', async () => {
|
||||
const seen: string[] = []
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
seen.push(typeof input === 'string' ? input : input.toString())
|
||||
return new Response(JSON.stringify({ status: 'error', msg: 'email already exists', data: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await client.signup({
|
||||
email: 'taken@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-app',
|
||||
application: 'hanzo-app',
|
||||
organization: 'hanzo',
|
||||
redirectUri: 'https://hanzo.app/auth/callback',
|
||||
})
|
||||
assert.equal(res.error, 'email already exists')
|
||||
assert.equal(res.redirectUrl, undefined)
|
||||
assert.equal(seen.length, 1)
|
||||
assert.match(seen[0]!, /\/v1\/iam\/signup/)
|
||||
})
|
||||
|
||||
// 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({ org: org(), 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')
|
||||
})
|
||||
|
||||
// Federation is entered by NAMING the provider on IAM's own authorize endpoint.
|
||||
// The `provider` field has always been on OAuthAuthorizeRequest; `authorize`
|
||||
// never emitted it, which is why social sign-in had no server side at all.
|
||||
test('authorize emits the provider record name, so IAM federates instead of showing its login', () => {
|
||||
const client = createAuthClient({ org: org({ iamUrl: 'https://hanzo.id' }) })
|
||||
const url = new URL(
|
||||
client.authorize({
|
||||
clientId: 'hanzo-console',
|
||||
redirectUri: 'https://hanzo.id/callback',
|
||||
state: 'rp1',
|
||||
codeChallenge: 'C1',
|
||||
codeChallengeMethod: 'S256',
|
||||
provider: 'provider-github',
|
||||
}),
|
||||
)
|
||||
assert.equal(url.pathname, '/v1/iam/oauth/authorize')
|
||||
// The RECORD name, never the bare key: federationProvider matches
|
||||
// ProviderItem.Name exactly (live, `provider=github` is refused).
|
||||
assert.equal(url.searchParams.get('provider'), 'provider-github')
|
||||
// The app's own request is what IAM binds the minted code to.
|
||||
assert.equal(url.searchParams.get('client_id'), 'hanzo-console')
|
||||
assert.equal(url.searchParams.get('redirect_uri'), 'https://hanzo.id/callback')
|
||||
assert.equal(url.searchParams.get('code_challenge'), 'C1')
|
||||
assert.equal(url.searchParams.get('code_challenge_method'), 'S256')
|
||||
})
|
||||
|
||||
test('authorize without a provider stays the ordinary hosted-login request', () => {
|
||||
const client = createAuthClient({ org: org({ iamUrl: 'https://hanzo.id' }) })
|
||||
const url = new URL(
|
||||
client.authorize({ clientId: 'hanzo-console', redirectUri: 'https://hanzo.id/callback', state: 'rp1' }),
|
||||
)
|
||||
assert.equal(url.searchParams.get('provider'), null)
|
||||
})
|
||||
|
||||
// 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({ org: org(), 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({ org: org(), 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({ org: org(), 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({ org: org(), 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({ org: org(), 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
|
||||
// org 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 + org app/org, NO credentials', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ org: org(), 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({ org: org(), 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({ org: org(), 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({ org: org(), 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({ org: org(), fetchImpl })
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.required, true)
|
||||
assert.equal(r.error, undefined)
|
||||
})
|
||||
|
||||
// ── Which application is this code for? (deviceInfo) ─────────────────────────
|
||||
// A one-call double for `POST /v1/iam/oauth/device/info`: records what the
|
||||
// request actually was (URL, method, credentials, body) and answers with
|
||||
// `payload`.
|
||||
function deviceInfoFetch(payload: unknown) {
|
||||
const calls: {
|
||||
url: string
|
||||
method?: string
|
||||
credentials?: RequestCredentials
|
||||
body?: string
|
||||
}[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
calls.push({
|
||||
url: typeof input === 'string' ? input : input.toString(),
|
||||
method: init?.method,
|
||||
credentials: init?.credentials,
|
||||
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||
})
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
// THE REGRESSION THIS FILE EXISTS FOR. The approval page used to render
|
||||
// `org.appName` — the PORTAL's own branding, the static `hanzo-console` this
|
||||
// test's org() is configured with — so a device sign-in started by `hanzo-cli`
|
||||
// was approved under a screen naming a different application. The name must come
|
||||
// off the RESPONSE, which is the code's own application, and never off the org
|
||||
// config; asserting both is what keeps the two from being confused again.
|
||||
test('deviceInfo names the RESPONSE client, never the portal org appName', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({
|
||||
status: 'ok',
|
||||
data: { clientId: 'hanzo-cli', displayName: 'Hanzo CLI' },
|
||||
})
|
||||
const cfg = org()
|
||||
const client = createAuthClient({ org: cfg, fetchImpl })
|
||||
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.ok && r.clientId, 'hanzo-cli')
|
||||
assert.equal(r.ok && r.displayName, 'Hanzo CLI')
|
||||
// The portal is hanzo-console. Nothing about it may reach the result.
|
||||
assert.equal(cfg.appName, 'hanzo-console')
|
||||
assert.notEqual(r.ok && r.clientId, cfg.appName)
|
||||
assert.notEqual(r.ok && r.displayName, cfg.appName)
|
||||
|
||||
// A session-cookie POST at the /v1/ device-info path. The user_code is the one
|
||||
// secret in this flow, so it rides the BODY: a request line is copied into
|
||||
// ingress and proxy access logs where a body is not, and this page ships
|
||||
// scrubUrl() precisely to keep the code out of URLs.
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/oauth/device/info')
|
||||
assert.equal(calls[0]!.method, 'POST')
|
||||
assert.equal(calls[0]!.credentials, 'include')
|
||||
assert.equal(calls[0]!.body, JSON.stringify({ userCode: 'K7M4P2QH' }))
|
||||
assert.equal(calls[0]!.url.includes('K7M4P2QH'), false)
|
||||
})
|
||||
|
||||
// Same normalization as the approval: a code transcribed lower-cased or with
|
||||
// dashes must resolve to the same row IAM minted, or the page would refuse to
|
||||
// name an application that is perfectly live.
|
||||
test('deviceInfo uppercases and strips spaces/dashes into the body', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({
|
||||
status: 'ok',
|
||||
data: { clientId: 'hanzo-cli', displayName: 'Hanzo CLI' },
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.deviceInfo(' k7m4-p2qh ')
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/oauth/device/info')
|
||||
assert.equal(calls[0]!.body, JSON.stringify({ userCode: 'K7M4P2QH' }))
|
||||
})
|
||||
|
||||
// An empty code names nothing and never hits the network.
|
||||
test('deviceInfo rejects an empty code without calling fetch', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({ status: 'ok', data: {} })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo(' ')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.loginRequired, undefined)
|
||||
})
|
||||
|
||||
// IAM `CodeLoginRequired`: the session lapsed. Flagged separately from a refusal
|
||||
// because the page's answer is to sign the human in and come back, not to give up.
|
||||
test('deviceInfo flags login_required distinctly from a refusal', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'please sign in first',
|
||||
code: 'login_required',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.loginRequired, true)
|
||||
assert.equal(r.ok === false && r.error, 'please sign in first')
|
||||
})
|
||||
|
||||
// The ONE opaque refusal IAM answers for unknown / expired / already-approved —
|
||||
// surfaced verbatim, carrying no loginRequired, so the page shows it and offers
|
||||
// no approval. Distinguishing those three would be an oracle for hunting the
|
||||
// 40-bit user_code; the client must not invent a distinction either.
|
||||
test('deviceInfo surfaces the opaque refusal verbatim and does not name an app', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'the user code is invalid or expired',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.error, 'the user code is invalid or expired')
|
||||
assert.equal(r.ok === false && r.loginRequired, undefined)
|
||||
})
|
||||
|
||||
// The org-boundary refusal is a plain refusal too: surfaced, not special-cased.
|
||||
test('deviceInfo surfaces the wrong-org refusal', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'your organization may not approve this device sign-in',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.error, 'your organization may not approve this device sign-in')
|
||||
})
|
||||
|
||||
// An HTML error page from a proxy is not an application name. It must fail,
|
||||
// never resolve to a blank or guessed one.
|
||||
test('deviceInfo fails on a non-JSON response', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response('<html>502 Bad Gateway</html>', {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.match(String(r.ok === false && r.error), /non-JSON/)
|
||||
})
|
||||
|
||||
// A network failure resolves — never rejects — so the page renders the failure
|
||||
// instead of tearing down on an unhandled rejection.
|
||||
test('deviceInfo resolves an error when fetch throws', async () => {
|
||||
const fetchImpl: typeof fetch = async () => {
|
||||
throw new Error('offline')
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.match(String(r.ok === false && r.error), /offline/)
|
||||
})
|
||||
|
||||
// A 200 that names no client is not a name. Falling back to ANY local string here
|
||||
// is what produced the original defect, so an absent clientId is a failure.
|
||||
test('deviceInfo refuses an ok response with no clientId', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({ status: 'ok', data: { displayName: 'Hanzo CLI' } })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
})
|
||||
|
||||
// IAM already falls back to the app's name when DisplayName is empty; if one ever
|
||||
// arrives blank anyway, the label is the server-confirmed clientId — never the portal's.
|
||||
test('deviceInfo falls back to the confirmed clientId when displayName is empty', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({ status: 'ok', data: { clientId: 'hanzo-cli', displayName: '' } })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.ok && r.displayName, 'hanzo-cli')
|
||||
})
|
||||
|
||||
// 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({ org: org(), 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,789 @@
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
import type {
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
DeviceInfoResult,
|
||||
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 org. 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 org: OrgConfig
|
||||
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 org'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>
|
||||
/**
|
||||
* Name the application a pending device code belongs to, so the approval page
|
||||
* can say WHICH app it is authorizing — `GET
|
||||
* /v1/iam/oauth/device/<user_code>`, riding the same `iam_session_id` cookie
|
||||
* as {@link approveDevice}.
|
||||
*
|
||||
* Read this and render it; never `org.appName`, which is this portal's own
|
||||
* branding and names a different application than the one that minted the
|
||||
* code. IAM answers from the code's own application row.
|
||||
*
|
||||
* Session-gated and deliberately terse: an expired session comes back as
|
||||
* `loginRequired`, and unknown / expired / already-approved all come back as
|
||||
* ONE indistinguishable refusal, because a user_code is 40 bits and an
|
||||
* endpoint that told them apart would be an oracle for hunting live codes.
|
||||
*/
|
||||
deviceInfo(userCode: string): Promise<DeviceInfoResult>
|
||||
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 org'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>
|
||||
/**
|
||||
* 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>
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
readonly org: OrgConfig
|
||||
/** Override fetch impl (testing). Defaults to global fetch. */
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
const org = opts.org
|
||||
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', org.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 (IAM 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', org.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', org.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', org.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: org.appName,
|
||||
}
|
||||
// `organization` scopes the application lookup (FindApplicationByName); it
|
||||
// does NOT resolve the user (that comes from the session), so pinning the
|
||||
// org org here is safe — unlike password login, which omits it.
|
||||
if (org.orgId) body.organization = org.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 deviceInfo(userCode: string): Promise<DeviceInfoResult> {
|
||||
const code = normalizeUserCode(userCode)
|
||||
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
|
||||
// POST, and the code rides the BODY — like `approveDevice` beside it, and for
|
||||
// the reason IAM's own introspection endpoint is POST: the user_code is the one
|
||||
// secret in this flow, and a request line is copied into ingress and proxy
|
||||
// access logs where a body is not. This page ships `scrubUrl()` to keep the
|
||||
// code out of the address bar; putting it into every request line would undo
|
||||
// that server-side. Same session cookie as the approval: whatever you may look
|
||||
// at is exactly what you may approve.
|
||||
const url = new URL('/v1/iam/oauth/device/info', org.iamUrl)
|
||||
let res: Response
|
||||
try {
|
||||
res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ userCode: code }),
|
||||
})
|
||||
} 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') {
|
||||
const error = typeof parsed.msg === 'string' && parsed.msg ? parsed.msg : `HTTP ${res.status}`
|
||||
// IAM `CodeLoginRequired` (internal/oidc/oidc.go): the session lapsed between
|
||||
// the page's get-account check and this read. Not a dead end — sign in again.
|
||||
if (parsed.code === 'login_required') return { ok: false, error, loginRequired: true }
|
||||
return { ok: false, error }
|
||||
}
|
||||
// A name is only worth rendering if the server sent it. An answer with no
|
||||
// clientId names nothing, so it fails rather than letting the page fall back
|
||||
// to a guess — showing the WRONG application is the defect this endpoint exists
|
||||
// to fix. `displayName` falls back to the clientId, which IAM did confirm.
|
||||
const data = parsed.data as Record<string, unknown> | undefined
|
||||
const clientId = typeof data?.clientId === 'string' ? data.clientId : ''
|
||||
const displayName = typeof data?.displayName === 'string' ? data.displayName : ''
|
||||
if (!clientId) return { ok: false, error: 'IAM did not name the application for this code.' }
|
||||
return { ok: true, clientId, displayName: displayName || clientId }
|
||||
}
|
||||
|
||||
async function signup(req: SignupRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/signup', org.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,
|
||||
...(req.inviteCode ? { invitationCode: req.inviteCode } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
// Registration is CREATE-ONLY at IAM. `/v1/iam/signup` persists the user and
|
||||
// answers with the created row — it sets no session cookie and mints no
|
||||
// authorization code, and its form (`internal/oidc/signup.go`) has no
|
||||
// `autoSignin`, `redirectUri` or `code_challenge` field to make it do so.
|
||||
// The `autoSignin: true` this used to post was silently dropped by the Go
|
||||
// decoder, so "signed up" and "signed in" were never the same event.
|
||||
//
|
||||
// Left there, the response fell through `parseLoginResponse`'s no-redirect
|
||||
// arm to `{ redirectUrl: '/onboarding' }` — every new customer was sent to
|
||||
// the portal's own onboarding, unauthenticated, while the app that sent them
|
||||
// waited on a code that was never minted. So finish the job here: a signup
|
||||
// that leaves you logged out is not a signup.
|
||||
const created = await parseCreated(res)
|
||||
if (created.error) return created
|
||||
|
||||
return login({
|
||||
identifier: req.email,
|
||||
password: req.password,
|
||||
clientId: req.clientId,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
redirectUri: req.redirectUri,
|
||||
state: req.state,
|
||||
codeChallenge: req.codeChallenge,
|
||||
codeChallengeMethod: req.codeChallengeMethod,
|
||||
nonce: req.nonce,
|
||||
})
|
||||
}
|
||||
|
||||
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
|
||||
const url = new URL('/v1/iam/send-verification-code', org.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/${org.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', org.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')
|
||||
}
|
||||
// Naming a provider federates the request to that external IdP instead of
|
||||
// the hosted credential login. The type has always declared this field;
|
||||
// never emitting it is why social sign-in had no server side at all.
|
||||
if (req.provider) url.searchParams.set('provider', req.provider)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function exchange(code: string, codeVerifier?: string): Promise<TokenResponse> {
|
||||
const url = new URL('/v1/iam/oauth/token', org.iamUrl)
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: org.clientId,
|
||||
redirect_uri: `${org.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', org.iamUrl)
|
||||
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint)
|
||||
url.searchParams.set(
|
||||
'post_logout_redirect_uri',
|
||||
postLogoutRedirectUri ?? `${org.publicOrigin}/login`,
|
||||
)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null> {
|
||||
const id = clientId ?? org.clientId
|
||||
const url = new URL('/v1/iam/get-app-login', org.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 || `${org.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>, org.appName, org.orgId)
|
||||
}
|
||||
|
||||
async function getAccount(): Promise<MfaIdentity | null> {
|
||||
const url = new URL('/v1/iam/get-account', org.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}`, org.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', org.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 {
|
||||
org,
|
||||
login,
|
||||
silentLogin,
|
||||
approveDevice,
|
||||
deviceInfo,
|
||||
signup,
|
||||
forgot,
|
||||
authorize,
|
||||
exchange,
|
||||
logout,
|
||||
getAppLogin,
|
||||
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, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's DISPLAY key — `provider-github` → `github` — used to pick an
|
||||
* icon and a label (`PROVIDER_META`) and to match a `provider_hint`.
|
||||
*
|
||||
* It is NOT what the authorize endpoint wants. `federationProvider` matches the
|
||||
* record name exactly, so `?provider=` must carry the full `provider-github`;
|
||||
* live, `?provider=github` is refused "unknown or unavailable provider". This
|
||||
* comment used to assert the opposite — a bare key — which was never true of the
|
||||
* federation broker.
|
||||
*/
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a create-only IAM response: `{status, msg, data}` where `data` is the
|
||||
* created row. Success carries nothing the caller can navigate to, so this
|
||||
* reports only whether it worked — never a redirect. Kept separate from
|
||||
* `parseLoginResponse` precisely because that one INVENTS a destination when no
|
||||
* `redirectUri` was requested, which is wrong for a row that is not a session.
|
||||
*/
|
||||
async function parseCreated(res: Response): Promise<{ error?: string }> {
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
// IAM answers a REFUSAL with HTTP 200 + status:"error" (see the org-less login
|
||||
// note in this repo's LLM.md), so the status code alone proves nothing.
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
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') {
|
||||
// Challenge allow-list: IAM's named `mfa` field first, falling back to
|
||||
// the legacy untyped `data2` slot until IAM stops emitting it.
|
||||
const allow = Array.isArray(body.mfa) ? body.mfa : 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 { OrgConfig } from '@hanzo/id-shared'
|
||||
import { IAM } from '@hanzo/iam/browser'
|
||||
|
||||
/**
|
||||
* One IAM browser-SDK instance per org, 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(org: OrgConfig, clientId?: string): IAM {
|
||||
return new IAM({
|
||||
serverUrl: org.iamUrl,
|
||||
clientId: clientId ?? org.clientId,
|
||||
redirectUri: `${org.publicOrigin}/callback`,
|
||||
scope: 'openid profile email',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export {
|
||||
createAuthClient,
|
||||
mfaChannelOf,
|
||||
MFA_TOTP,
|
||||
type AuthClient,
|
||||
type AuthClientOptions,
|
||||
} from './client'
|
||||
export { createIam } from './iam'
|
||||
export { authorizeRequest, matchProviderHint } from './social'
|
||||
export {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletLoginContext,
|
||||
type WalletWindow,
|
||||
} from './web3'
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChannel,
|
||||
MfaChallengeRequest,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
SignupRequest,
|
||||
ForgotRequest,
|
||||
OAuthAuthorizeRequest,
|
||||
TokenResponse,
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
DeviceInfoResult,
|
||||
} from './types'
|
||||
export * from './ui'
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Federated sign-in — pure unit tests, no network. Run with:
|
||||
* pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* The browser's whole job in a federated sign-in is to name the provider on
|
||||
* IAM's authorize endpoint and, when an app sent the user here, to hand that
|
||||
* app's own request back unchanged so IAM mints the code against it. Those two
|
||||
* are what these tests pin; the IdP leg belongs to IAM and is not modelled here.
|
||||
*/
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { authorizeRequest, matchProviderHint } from './social.ts'
|
||||
|
||||
const PORTAL = 'hanzo-console'
|
||||
|
||||
test('an app-initiated request is recovered whole, so IAM binds the code to that app', () => {
|
||||
// What IAM forwards to the hosted login (authorizeForwardQuery) when an app
|
||||
// sends a user here for a code.
|
||||
const req = authorizeRequest(
|
||||
'?client_id=hanzo-app&redirect_uri=https%3A%2F%2Fhanzo.app%2Fcallback&response_type=code' +
|
||||
'&scope=openid+profile&state=rp123&nonce=n1&code_challenge=C1&code_challenge_method=S256',
|
||||
PORTAL,
|
||||
)!
|
||||
assert.equal(req.clientId, 'hanzo-app')
|
||||
assert.equal(req.redirectUri, 'https://hanzo.app/callback')
|
||||
assert.equal(req.state, 'rp123')
|
||||
assert.equal(req.scope, 'openid profile')
|
||||
assert.equal(req.nonce, 'n1')
|
||||
// Load-bearing: the code IAM mints is bound to the APP's challenge, so the
|
||||
// app's own callback completes the exchange with the verifier it kept.
|
||||
assert.equal(req.codeChallenge, 'C1')
|
||||
assert.equal(req.codeChallengeMethod, 'S256')
|
||||
})
|
||||
|
||||
test('a bare portal sign-in has no app to return to', () => {
|
||||
// No redirect_uri → nothing to return a code to, so the portal starts its own
|
||||
// PKCE flow instead (the SDK owns the verifier; Callback reads it back).
|
||||
assert.equal(authorizeRequest('', PORTAL), null)
|
||||
assert.equal(authorizeRequest('?provider_hint=provider-github', PORTAL), null)
|
||||
})
|
||||
|
||||
test('the portal client id is the fallback, never an override', () => {
|
||||
const own = authorizeRequest('?redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
|
||||
assert.equal(own.clientId, PORTAL, 'no client_id on the query → the portal is the client')
|
||||
|
||||
const app = authorizeRequest('?client_id=hanzo-app&redirect_uri=https%3A%2F%2Fhanzo.app%2Fcallback', PORTAL)!
|
||||
assert.equal(app.clientId, 'hanzo-app', "the app's own client_id wins — the code is minted for IT")
|
||||
})
|
||||
|
||||
test('a leading ? is optional and absent params stay absent', () => {
|
||||
const withMark = authorizeRequest('?redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
|
||||
const without = authorizeRequest('redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback', PORTAL)!
|
||||
assert.deepEqual(withMark, without)
|
||||
// Undefined, not '' — client.authorize omits a param it was not given, and an
|
||||
// empty code_challenge is not the same request as no code_challenge.
|
||||
assert.equal(withMark.codeChallenge, undefined)
|
||||
assert.equal(withMark.nonce, undefined)
|
||||
assert.equal(withMark.scope, undefined)
|
||||
assert.equal(withMark.state, '', 'state is always sent, empty when the app sent none')
|
||||
})
|
||||
|
||||
test('only the two PKCE methods RFC 7636 defines are carried through', () => {
|
||||
const base = 'redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback&code_challenge=C1&code_challenge_method='
|
||||
assert.equal(authorizeRequest(base + 'S256', PORTAL)!.codeChallengeMethod, 'S256')
|
||||
assert.equal(authorizeRequest(base + 'plain', PORTAL)!.codeChallengeMethod, 'plain')
|
||||
// Anything else is dropped rather than forwarded, so client.authorize applies
|
||||
// its S256 default instead of asking IAM to honor a method it does not define.
|
||||
assert.equal(authorizeRequest(base + 'md5', PORTAL)!.codeChallengeMethod, undefined)
|
||||
})
|
||||
|
||||
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,89 @@
|
||||
import type { OAuthAuthorizeRequest } from './types'
|
||||
|
||||
/**
|
||||
* Federated sign-in — the portal's half of IAM identity federation.
|
||||
*
|
||||
* IAM is the relying party; this SPA is not. `/v1/iam/oauth/authorize?provider=…`
|
||||
* IS the entry point: having already validated the client_id, the EXACT
|
||||
* redirect_uri and the PKCE policy, IAM stashes the app-leg request server-side,
|
||||
* sets a single-use browser-binding cookie and sends the browser to the IdP
|
||||
* (`internal/oidc/federation.go::beginFederation`). The IdP returns to IAM's own
|
||||
* fixed callback — `/v1/iam/oauth/callback`, never a route in this SPA — where
|
||||
* IAM, which holds the client SECRET a browser cannot, exchanges the code, links
|
||||
* or provisions the user, and mints an IAM authorization code bound to the
|
||||
* original PKCE challenge, redirect_uri and nonce. The ordinary code→token
|
||||
* exchange then completes unchanged.
|
||||
*
|
||||
* This file used to build the IdP URL here in the browser, replicating a contract
|
||||
* from an IAM fork whose front end no longer exists. Nothing could ever finish it:
|
||||
* the SPA has no client secret and IAM has no endpoint that exchanges a raw
|
||||
* provider code, so GitHub returned to `/callback` with a code nobody could spend
|
||||
* and the flow died there. The browser's only job is to NAME the provider.
|
||||
*
|
||||
* The name is the IAM provider RECORD name (`provider-github`), never the bare
|
||||
* key: `federationProvider` matches `ProviderItem.Name` exactly, and
|
||||
* `EnrichProviders` resolves that same name to the record, so the two are one
|
||||
* string by construction. Verified live — `provider=github` is refused with
|
||||
* "unknown or unavailable provider"; `provider=provider-github` redirects to
|
||||
* GitHub.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The authorize request this portal is standing in for, read from its own URL.
|
||||
*
|
||||
* When an app sends a user here for a code, IAM forwards that app's validated
|
||||
* request on the query (`authorizeForwardQuery`). Re-entering authorize with it —
|
||||
* plus `provider` — is what makes IAM mint the code against THAT app: its
|
||||
* client_id, its redirect_uri, its PKCE challenge. The browser is returned
|
||||
* straight to the app, so this portal's own `/callback` never runs and no token
|
||||
* is ever handed across on a URL.
|
||||
*
|
||||
* Null when there is no app to return to (a bare portal sign-in), which is the
|
||||
* signal to start the portal's own PKCE flow instead. Keyed on `redirect_uri`
|
||||
* because that is the one parameter that makes a request returnable — the same
|
||||
* condition the password path branches on (`Login.completeAfterAuth`).
|
||||
*
|
||||
* The result is an {@link OAuthAuthorizeRequest} because that is exactly what
|
||||
* `client.authorize` consumes: one type, read and written in one shape.
|
||||
*/
|
||||
export function authorizeRequest(search: string, clientId: string): OAuthAuthorizeRequest | null {
|
||||
const q = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
const redirectUri = q.get('redirect_uri')
|
||||
if (!redirectUri) return null
|
||||
const method = q.get('code_challenge_method')
|
||||
return {
|
||||
clientId: q.get('client_id') || clientId,
|
||||
redirectUri,
|
||||
state: q.get('state') ?? '',
|
||||
scope: q.get('scope') ?? undefined,
|
||||
nonce: q.get('nonce') ?? undefined,
|
||||
codeChallenge: q.get('code_challenge') ?? undefined,
|
||||
codeChallengeMethod: method === 'plain' || method === 'S256' ? method : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
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 org (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
|
||||
* named `mfa` field, legacy `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
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH application a pending device code belongs to
|
||||
* ({@link AuthClient.deviceInfo}) — the one thing the approval page exists to
|
||||
* tell a human, and the one thing it cannot know on its own.
|
||||
*
|
||||
* Both fields come off the device code's own application row, so a page that
|
||||
* renders them names the party it is actually authorizing. They are the ONLY
|
||||
* honest source: `org.appName` is this portal's static branding and names the
|
||||
* wrong app for every code minted by anything else.
|
||||
*
|
||||
* Discriminated on `ok` so a caller cannot read `displayName` without having
|
||||
* proved the server confirmed one. `loginRequired` singles out the expired
|
||||
* session (IAM `code:"login_required"`) — the page's cue to sign the human in
|
||||
* and come back, not an error to show. Every other failure is IAM's single
|
||||
* opaque refusal, surfaced verbatim.
|
||||
*/
|
||||
export type DeviceInfoResult =
|
||||
| { readonly ok: true; readonly clientId: string; readonly displayName: string }
|
||||
| { readonly ok: false; readonly error: string; readonly loginRequired?: boolean }
|
||||
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* The org to create the user in. REQUIRED — unlike login's optional
|
||||
* lookup hint, you cannot create a user in "no org", and IAM gates this
|
||||
* against the application's own org.
|
||||
*/
|
||||
readonly organization: string
|
||||
readonly inviteCode?: string
|
||||
/**
|
||||
* The downstream OIDC request, when an app sent the user here to register.
|
||||
* Registration completes by signing the new user in, so these are forwarded
|
||||
* to that sign-in: without them the minted code carries no PKCE binding and
|
||||
* there is nowhere to return the user to.
|
||||
*/
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
readonly nonce?: 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.org.clientId,
|
||||
organization: client.org.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-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Email</span>
|
||||
<input className="hanzo-id-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" className="hanzo-id-btn" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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.
|
||||
//
|
||||
// BOTH entry points resolve the same way — the downstream-app login
|
||||
// (clientIdOverride) and the brand portal's own bare sign-in. They used to
|
||||
// differ: the bare portal deliberately posted NO `organization` so IAM's
|
||||
// cross-org fallback landed a colliding identity (z@hanzo.ai exists in both
|
||||
// `admin` and `hanzo`) on admin/* and returned the full multi-org session.
|
||||
//
|
||||
// That is gone, on purpose, at the server. iam2 scopes every credential
|
||||
// lookup to one org and treats the collision it relied on as a defect —
|
||||
// "the F-2 bug where z@hanzo.ai collided across admin and hanzo" — because
|
||||
// cross-org resolution coupled lockout counters across rows and gave a
|
||||
// brute-force oracle on the superadmin. So it now REFUSES an org-less login
|
||||
// with "organization, username and password are required". It answers HTTP
|
||||
// **200**, which the form then renders as if the user's own password were
|
||||
// wrong, and which every status-code monitor reads as green — the apex form
|
||||
// was dead on hanzo.id, lux.id, iam.hanzo.ai and pars.id simultaneously.
|
||||
//
|
||||
// Posting the app's own org is the established answer (it is what the
|
||||
// override path already does, and what reaches admin/* for admin-org apps).
|
||||
// A global admin is no longer resolved by omission; they reach the admin
|
||||
// identity by signing into an admin-org app, which is the explicit path.
|
||||
const app = await client.getAppLogin(props.clientIdOverride ?? client.org.clientId)
|
||||
const application = app?.application ?? client.org.appName
|
||||
const organization = app?.organization ?? client.org.loginOrg
|
||||
const res = await client.login({
|
||||
identifier,
|
||||
password,
|
||||
clientId: props.clientIdOverride ?? client.org.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-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Email or username</span>
|
||||
<input
|
||||
className="hanzo-id-input"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
className="hanzo-id-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" className="hanzo-id-btn" 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,48 @@
|
||||
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-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
className="hanzo-id-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" className="hanzo-id-btn" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
|
||||
export interface SignupFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly inviteCode?: string
|
||||
/**
|
||||
* The downstream OIDC request the user arrived with, when an app sent them
|
||||
* here to register. Forwarded to the sign-in that follows account creation so
|
||||
* the flow ends where it started — back at the app, holding a code.
|
||||
*/
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly clientIdOverride?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
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 {
|
||||
// Register against the app the user CAME FROM, not this portal. IAM's
|
||||
// signup resolves the application by clientId and then gates the org
|
||||
// against that app's own org, so a downstream `client_id` must reach
|
||||
// it or the account is created under the portal's app instead.
|
||||
const clientId = props.clientIdOverride ?? client.org.clientId
|
||||
const app = await client.getAppLogin(clientId, props.redirectUri)
|
||||
const application = app?.application ?? client.org.appName
|
||||
const organization = app?.organization ?? client.org.orgId
|
||||
|
||||
const session = await client.signup({
|
||||
email,
|
||||
password,
|
||||
clientId,
|
||||
application,
|
||||
organization,
|
||||
inviteCode: props.inviteCode,
|
||||
redirectUri: props.redirectUri,
|
||||
state: props.state,
|
||||
codeChallenge: props.codeChallenge,
|
||||
codeChallengeMethod: props.codeChallengeMethod,
|
||||
nonce: props.nonce,
|
||||
})
|
||||
if (session.error) {
|
||||
setError(session.error)
|
||||
return
|
||||
}
|
||||
if (session.redirectUrl) {
|
||||
window.location.href = session.redirectUrl
|
||||
return
|
||||
}
|
||||
// The account exists but the session did not complete here — an org that
|
||||
// forces MFA answers the login with an enrollment step. Hand the user to
|
||||
// the sign-in page, carrying the same OIDC request, rather than leaving
|
||||
// them on a form that has nothing left to do.
|
||||
if (session.mfaRequired) {
|
||||
window.location.href = `/login${window.location.search}`
|
||||
return
|
||||
}
|
||||
setError('Your account was created, but sign-in did not complete. Please sign in.')
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Email</span>
|
||||
<input className="hanzo-id-input" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label className="hanzo-id-field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
className="hanzo-id-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" className="hanzo-id-btn" 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,331 @@
|
||||
import { Fragment, 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 { authorizeRequest, matchProviderHint } from '../social'
|
||||
import { createIam } from '../iam'
|
||||
import {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
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/gitlab) → FEDERATION: name the provider on IAM's own
|
||||
* authorize endpoint (`?provider=provider-github`) and let IAM run the entire
|
||||
* IdP leg server-side, where the client secret lives. See `social.ts`. This
|
||||
* browser never builds an IdP URL and never sees a provider code.
|
||||
* - 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 renders ONE
|
||||
* chain-agnostic "Connect Wallet" button: it auto-detects the injected
|
||||
* chain (`detectWalletChains`) and connects straight when exactly one is
|
||||
* present, else reveals a chooser so either EVM or Solana stays reachable.
|
||||
*/
|
||||
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 {
|
||||
/** 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)
|
||||
// The chain-agnostic wallet entry reveals a chooser only when it can't decide
|
||||
// for the user (zero or multiple injected wallets); a single injected wallet
|
||||
// connects straight without ever showing it.
|
||||
const [walletMenu, setWalletMenu] = useState(false)
|
||||
const autoStarted = useRef(false)
|
||||
|
||||
// Start federation: hand the provider's NAME to IAM's authorize endpoint and
|
||||
// let IAM run the whole IdP leg. Shared by the button click and the `autoStart`
|
||||
// auto-launch so both take the identical path.
|
||||
//
|
||||
// Two arms, and they are the same two the password path already branches on
|
||||
// (`Login.completeAfterAuth`) — the question is only who owns the PKCE verifier:
|
||||
//
|
||||
// an app sent the user here → re-enter authorize with THAT app's request, so
|
||||
// IAM mints the code against its client_id, redirect_uri and challenge and
|
||||
// returns the browser straight to it. The app holds the verifier; this portal
|
||||
// is never in the return path and never touches a token.
|
||||
//
|
||||
// a bare portal sign-in → the portal is its own client, so the IAM SDK mints
|
||||
// and stores the verifier that `Callback` reads back. `post_login_redirect`
|
||||
// carries a non-OIDC "come back here" target (device approval), which is why
|
||||
// it belongs to this arm alone: it is only ever read by the portal's own
|
||||
// callback, and only this arm runs it.
|
||||
function hop(provider: AppProvider) {
|
||||
const app = authorizeRequest(window.location.search, clientIdOverride ?? client.org.clientId)
|
||||
if (app) {
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
window.location.assign(client.authorize({ ...app, provider: provider.name }))
|
||||
return
|
||||
}
|
||||
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
|
||||
else sessionStorage.removeItem('post_login_redirect')
|
||||
createIam(client.org, clientIdOverride)
|
||||
.signinRedirect({ additionalParams: { provider: provider.name } })
|
||||
.catch((e) => setError(String(e)))
|
||||
}
|
||||
|
||||
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({ 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({ 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) {
|
||||
onAutoStartResolved?.(true)
|
||||
hop(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({ 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(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)
|
||||
}
|
||||
}
|
||||
|
||||
// The chain-agnostic entry: auto-detect the injected wallet and connect
|
||||
// straight when exactly one chain is available; otherwise reveal the chooser
|
||||
// so the user picks EVM or Solana. Both underlying flows stay reachable.
|
||||
function onConnectWallet() {
|
||||
setError(null)
|
||||
const detected = detectWalletChains()
|
||||
if (detected.length === 1) startWallet(detected[0]!)
|
||||
else setWalletMenu(true)
|
||||
}
|
||||
|
||||
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') {
|
||||
// ONE chain-agnostic entry. It connects straight when a single
|
||||
// wallet is detected, else expands into the chooser below — so the
|
||||
// page always shows exactly one "Connect Wallet" button, with both
|
||||
// EVM and Solana reachable from it.
|
||||
return (
|
||||
<Fragment key="web3">
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider="web3"
|
||||
data-wallet-connect="true"
|
||||
aria-expanded={walletMenu}
|
||||
disabled={busyChain !== null}
|
||||
onClick={onConnectWallet}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span>{busyChain !== null && !walletMenu ? 'Connecting…' : 'Connect Wallet'}</span>
|
||||
</button>
|
||||
{walletMenu ? (
|
||||
<div
|
||||
className="hanzo-id-wallet-chains"
|
||||
role="group"
|
||||
aria-label="Choose a wallet network"
|
||||
>
|
||||
{ENABLED_WALLET_CHAINS.map((chain) => (
|
||||
<button
|
||||
key={`web3-${chain}`}
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider="web3"
|
||||
data-chain={chain}
|
||||
disabled={busyChain !== null}
|
||||
onClick={() => startWallet(chain)}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span>{busyChain === chain ? 'Connecting…' : WALLET_CHAIN_LABELS[chain]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
const meta = PROVIDER_META[k]!
|
||||
const { Icon } = meta
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
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,234 @@
|
||||
/**
|
||||
* 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 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletSigner,
|
||||
} from './web3.ts'
|
||||
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
|
||||
import type { OrgConfig } from '@hanzo/id-shared'
|
||||
|
||||
function org(overrides: Partial<OrgConfig> = {}): OrgConfig {
|
||||
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({ org: org(), 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({ org: org(), 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({ org: org(), 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({ org: org(), 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('detectWalletChains: a single injected wallet resolves to exactly its chain', () => {
|
||||
// EVM only → [evm]; the UI connects straight, no chooser.
|
||||
assert.deepEqual(detectWalletChains({ ethereum: {} }), ['evm'])
|
||||
// Solana only, via any of the recognized injected providers → [solana].
|
||||
assert.deepEqual(detectWalletChains({ solana: {} }), ['solana'])
|
||||
assert.deepEqual(detectWalletChains({ solflare: {} }), ['solana'])
|
||||
assert.deepEqual(detectWalletChains({ backpack: {} }), ['solana'])
|
||||
})
|
||||
|
||||
test('detectWalletChains: both injected → both, in enabled order (chooser)', () => {
|
||||
assert.deepEqual(detectWalletChains({ ethereum: {}, solana: {} }), ['evm', 'solana'])
|
||||
})
|
||||
|
||||
test('detectWalletChains: nothing injected → [] (chooser, both still reachable)', () => {
|
||||
// No window (server / node) and an empty window both resolve to none — the UI
|
||||
// then reveals the chooser so EVM and Solana stay selectable regardless.
|
||||
assert.deepEqual(detectWalletChains({}), [])
|
||||
assert.deepEqual(detectWalletChains(undefined), [])
|
||||
assert.deepEqual(detectWalletChains(), []) // node has no global window
|
||||
// Every detectable chain is one the wallet flow actually enables.
|
||||
for (const c of detectWalletChains({ ethereum: {}, solana: {} })) {
|
||||
assert.ok(ENABLED_WALLET_CHAINS.includes(c))
|
||||
}
|
||||
})
|
||||
|
||||
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({ org: org(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.error, 'web3: bad signature')
|
||||
assert.equal(res.redirectUrl, undefined)
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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 { OrgConfig } 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',
|
||||
}
|
||||
|
||||
/** The `window` fields the injected-wallet sniff reads — kept local so the
|
||||
* wallet libs stay out of this module (detection is a pure property read). */
|
||||
export interface WalletWindow {
|
||||
readonly ethereum?: unknown
|
||||
readonly solana?: unknown
|
||||
readonly solflare?: unknown
|
||||
readonly backpack?: unknown
|
||||
}
|
||||
|
||||
/** Is an injected wallet for `chain` present on `w`? Mirrors the connectors'
|
||||
* own discovery: EVM = `window.ethereum` (EIP-1193 / EIP-6963 legacy handle),
|
||||
* Solana = Phantom/Solflare/Backpack injected providers. */
|
||||
function chainInjected(chain: Chain, w: WalletWindow): boolean {
|
||||
switch (chain) {
|
||||
case 'evm':
|
||||
return Boolean(w.ethereum)
|
||||
case 'solana':
|
||||
return Boolean(w.solana || w.solflare || w.backpack)
|
||||
default:
|
||||
// A chain with no sniff is never auto-detected; the chooser still offers
|
||||
// it. Only the ENABLED set is ever consulted, so this stays unreachable.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ENABLED wallet chains that currently have an injected provider. A pure
|
||||
* `window` sniff — no connect, no I/O — that powers the chain-agnostic "Connect
|
||||
* Wallet" entry: exactly one match → connect straight; zero or many → let the
|
||||
* user pick. Derived from {@link ENABLED_WALLET_CHAINS} so there is ONE source
|
||||
* of truth for what wallet login offers.
|
||||
*/
|
||||
export function detectWalletChains(
|
||||
w: WalletWindow | undefined = typeof window === 'undefined' ? undefined : (window as WalletWindow),
|
||||
): Chain[] {
|
||||
if (!w) return []
|
||||
return ENABLED_WALLET_CHAINS.filter((c) => chainInjected(c, w))
|
||||
}
|
||||
|
||||
/** Routing context for the verify POST — exactly what the password flow carries. */
|
||||
export interface WalletLoginContext {
|
||||
/** Override the OAuth client_id (downstream app); defaults to org.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.org.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 org = client.org
|
||||
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(org, 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', org.iamUrl)
|
||||
const res = await fetchImpl(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
// routing
|
||||
organization: org.loginOrg ?? '',
|
||||
application: org.appName,
|
||||
method: 'login',
|
||||
clientId: ctx.clientId ?? org.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(
|
||||
org: OrgConfig,
|
||||
chain: Chain,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<LoginChallenge> {
|
||||
const url = new URL('/v1/iam/web3/nonce', org.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;
|
||||
}
|
||||
}
|
||||
}
|
||||