Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48acbaaf92 | ||
|
|
6b12efdde5 | ||
|
|
818a7d9079 | ||
|
|
ad3e6daf48 | ||
|
|
1c1cd9a803 | ||
|
|
55c7d907de | ||
|
|
d05a53d3cb | ||
|
|
8aee66d029 | ||
|
|
a7e917f348 | ||
|
|
bc890fd0d0 | ||
|
|
8d2ad1891a | ||
|
|
2cd1979cb0 | ||
|
|
7bbfb77cd7 | ||
|
|
4226ef57a7 | ||
|
|
403ff90b10 | ||
|
|
33cc55e837 | ||
|
|
cc7885c680 | ||
|
|
f2596c4788 | ||
|
|
2b3f9330e7 | ||
|
|
9bfbb8add2 | ||
|
|
da19c5f208 | ||
|
|
a87b7500e5 | ||
|
|
23d0c0e7a8 | ||
|
|
436931bb53 | ||
|
|
2ceecf49a5 | ||
|
|
f5d2f13499 | ||
|
|
f8640af7bc | ||
|
|
f4e7c0d145 | ||
|
|
0a97192506 | ||
|
|
d928ecdfe3 | ||
|
|
87d2da9c74 | ||
|
|
218528569e | ||
|
|
6df3f160b8 | ||
|
|
648cbe8e65 | ||
|
|
db509e068a | ||
|
|
b865303473 | ||
|
|
f483ee6791 | ||
|
|
7757aa1c74 | ||
|
|
f0b9750325 | ||
|
|
064ea5666b | ||
|
|
9d906eda2a | ||
|
|
140a000794 | ||
|
|
1c1d6873af | ||
|
|
5de04a2b46 | ||
|
|
7360334996 | ||
|
|
b116ea118d | ||
|
|
80ced70133 | ||
|
|
1c060e6886 | ||
|
|
0b144a53e5 | ||
|
|
616a1131a6 | ||
|
|
a6b37b02d7 | ||
|
|
4a8d73b0fb | ||
|
|
369b0702b9 | ||
|
|
52bb873f87 | ||
|
|
af8819ba60 | ||
|
|
04b2a0324d | ||
|
|
7c67359f6f | ||
|
|
03217a97be | ||
|
|
324c698dd5 | ||
|
|
ba9363cf7a | ||
|
|
584e6cd679 | ||
|
|
fe30228bdc | ||
|
|
d0863532d4 | ||
|
|
68c33527b9 | ||
|
|
6af816ac9c | ||
|
|
84a529cd0a | ||
|
|
bfbca725f2 | ||
|
|
6265944602 | ||
|
|
3e31fe7373 | ||
|
|
8163c3c2a8 | ||
|
|
8cc5428e97 | ||
|
|
d402732ebf | ||
|
|
9590dc319d | ||
|
|
475d0291b4 | ||
|
|
ab4f666a2b | ||
|
|
5372a45fc7 | ||
|
|
bf6acb79d8 | ||
|
|
60f914894d | ||
|
|
7503329462 | ||
|
|
4186888067 | ||
|
|
8fce0ef2f2 | ||
|
|
1e43cfe72a | ||
|
|
dd9addbb94 | ||
|
|
d4540a08f8 | ||
|
|
99b619bcaa | ||
|
|
9cc264b867 | ||
|
|
4a7eacdb64 | ||
|
|
7c00afb201 | ||
|
|
9f184bb20a | ||
|
|
c4c2760374 | ||
|
|
f118890485 | ||
|
|
ca8dd4ab74 | ||
|
|
bdb08d0d98 | ||
|
|
70a14dc902 | ||
|
|
1dbc79387b | ||
|
|
74d41880b5 | ||
|
|
7853644811 | ||
|
|
b1ed54303c | ||
|
|
d3dbc1e132 | ||
|
|
f211991862 | ||
|
|
3e774999da | ||
|
|
8545a85d5d | ||
|
|
396b0700f5 | ||
|
|
dbaf3079f5 | ||
|
|
293787a9ff | ||
|
|
0dd2a82d24 | ||
|
|
6957e9a07d | ||
|
|
aaf43491ab | ||
|
|
63ddef635d | ||
|
|
8ac9f1e2a4 | ||
|
|
2fae3831be | ||
|
|
b8cba121a9 | ||
|
|
1de63b9cc3 | ||
|
|
3e85378bc2 | ||
|
|
cbae8df738 |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="id">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">id</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Hosted login pages for Hanzo IAM - configurable per organization</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,53 +1,12 @@
|
||||
name: Docker
|
||||
# Self-contained build on Hanzo self-hosted runners. The shared reusable
|
||||
# workflow (hanzoai/.github docker-build.yml@main) is currently failing graph
|
||||
# validation for every caller (org-wide startup_failure), so this repo builds
|
||||
# its own image directly. Cluster nodes are linux/amd64 → build that arch.
|
||||
# The native deploy pipeline is .hanzo/workflows/deploy.yml (Hanzo Git push →
|
||||
# act_runner in-cluster → BuildKit builds Dockerfile → ghcr.io/hanzoai/id:<sha>
|
||||
# → operator reconcile → hanzocd). GitHub is a mirror; this workflow no longer
|
||||
# builds or pushes the app image.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, dev, test]
|
||||
tags: ['v*']
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
jobs:
|
||||
docker:
|
||||
# Route to the org-level ARC scale set (always-on, autoscales 0→100).
|
||||
# The bare [self-hosted, linux, amd64] labels target the native dbc/evo/
|
||||
# spark runners, which queue indefinitely when offline. ARC v0.14 routes
|
||||
# by scale-set NAME, so name the pool directly (matches universe + the
|
||||
# shared hanzoai/.github docker-build.yml default).
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
notice:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
SHA="sha-${GITHUB_SHA:0:7}"
|
||||
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -1)"
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "ver=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Tags: $SHA, $VER"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push (linux/amd64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/hanzoai/id:${{ steps.tags.outputs.sha }}
|
||||
ghcr.io/hanzoai/id:${{ steps.tags.outputs.ver }}
|
||||
cache-from: type=gha,scope=hanzoai-id
|
||||
cache-to: type=gha,scope=hanzoai-id,mode=max
|
||||
- run: echo "native pipeline is .hanzo/workflows/deploy.yml; GitHub is a mirror"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A docs or workflow edit is not a new image. Without this, every such commit
|
||||
# trips the "version already exists" guard below and reports a RED build for
|
||||
# a change that could not possibly produce a release — training everyone to
|
||||
# ignore red. Real source changes still build; use workflow_dispatch to force.
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'LLM.md'
|
||||
- 'CLAUDE.md'
|
||||
- '.github/**'
|
||||
- '.hanzo/workflows/**'
|
||||
- 'docs/**'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
|
||||
# This pipeline BUILDS. It does not deploy, and it must not try.
|
||||
#
|
||||
# App/id is governed by Hanzo CD: `id.yaml` is listed in
|
||||
# universe/infra/k8s/operator/crs/kustomization.yaml and the live object carries
|
||||
# `apps.hanzo.ai/tracking-id`. A `kubectl patch` of that CR is therefore reverted
|
||||
# on the next sync (~60-90s) back to whatever git says — which is exactly what
|
||||
# this workflow used to do, so its "deploy" step could never stick. Worse, it
|
||||
# LOOKED like it worked: the patch applies, the pod rolls, and CD quietly puts
|
||||
# the old tag back a minute later.
|
||||
#
|
||||
# Build and deploy are separate concerns. This emits an immutable image; git
|
||||
# declares desired state; CD applies it. To ship a build, set `spec.image.tag`
|
||||
# in universe/infra/k8s/operator/crs/id.yaml and push — that is the ONE way a
|
||||
# tag reaches this cluster.
|
||||
jobs:
|
||||
build:
|
||||
# The pool the runners ACTUALLY advertise. They register
|
||||
# `hanzo-build-linux-amd64` (plus the ubuntu-* aliases); there is no
|
||||
# `hanzo-linux-amd64` label anywhere in the fleet, which this targeted before
|
||||
# — so no runner could ever match and Gitea created NO RUN AT ALL. Four
|
||||
# commits landed on main between 2026-07-23 and 2026-07-25 and produced zero
|
||||
# images, silently. Same label as hanzoai/app, which has 299 green runs.
|
||||
runs-on: [hanzo-build-linux-amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker-container
|
||||
driver-opts: network=host
|
||||
|
||||
# ghcr auth, the piece the previous raw `buildctl` invocation lacked: it
|
||||
# pushed with no registry login at all, so every run died on
|
||||
# `https://ghcr.io/v2/: denied: denied`. GH_PAT (write:packages, user
|
||||
# hanzo-dev) is an ORG-level secret on hanzoai and is inherited here — same
|
||||
# as hanzoai/app and hanzoai/cloud.
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: hanzo-dev
|
||||
password: ${{ secrets.GH_PAT }}
|
||||
|
||||
# oci.hanzo.ai is OUR registry and the destination for every image. It is
|
||||
# the same registry.hanzo.ai backend under a second hostname, gated by IAM
|
||||
# token auth (realm https://iam.hanzo.ai/v1/iam/registry/token). Creds are
|
||||
# ORG-level secrets sourced from the KMS-synced registry credential.
|
||||
- name: Log in to oci.hanzo.ai
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: oci.hanzo.ai
|
||||
username: ${{ secrets.OCI_USER }}
|
||||
password: ${{ secrets.OCI_TOKEN }}
|
||||
|
||||
# SEMVER, never a sha and never a floating tag. package.json is the single
|
||||
# source of the version, so a release IS a version bump: edit the version,
|
||||
# push, and the image that appears is named after it. Building an existing
|
||||
# version again is refused rather than silently overwriting a tag other
|
||||
# environments may already be pinning.
|
||||
- name: Resolve version from package.json
|
||||
id: ver
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="$(node -p "require('./package.json').version")"
|
||||
case "$v" in
|
||||
''|*[!0-9.]*) echo "package.json version is not plain semver: '$v'" >&2; exit 1 ;;
|
||||
esac
|
||||
if docker manifest inspect "ghcr.io/hanzoai/id:${v}" >/dev/null 2>&1; then
|
||||
echo "ghcr.io/hanzoai/id:${v} already exists — bump the version to cut a release" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=${v}" >> "$GITHUB_OUTPUT"
|
||||
echo "building ghcr.io/hanzoai/id:${v}"
|
||||
|
||||
- name: Build + push ghcr.io/hanzoai/id
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
# BOTH registries, one build. oci.hanzo.ai is where images are moving;
|
||||
# ghcr stays until a pull credential naming oci.hanzo.ai exists in the
|
||||
# cluster (k8s matches imagePullSecrets by HOSTNAME, and the KMS-synced
|
||||
# registry-credentials secret currently only carries registry.hanzo.ai
|
||||
# — so a CR pointed at oci.hanzo.ai today would ImagePullBackOff).
|
||||
# Publishing to both means the CR flip is one line whenever that lands.
|
||||
tags: |
|
||||
oci.hanzo.ai/id:${{ steps.ver.outputs.version }}
|
||||
ghcr.io/hanzoai/id:${{ steps.ver.outputs.version }}
|
||||
provenance: false
|
||||
|
||||
# build-push-action can exit 0 while the manifest is not yet resolvable at
|
||||
# the registry. Prove the image actually pulls before anyone bumps a CR to
|
||||
# it, so a partial push fails HERE instead of becoming an ImagePullBackOff.
|
||||
- name: Verify the pushed image resolves
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${{ steps.ver.outputs.version }}"
|
||||
for ref in "oci.hanzo.ai/id:${tag}" "ghcr.io/hanzoai/id:${tag}"; do
|
||||
ok=""
|
||||
for i in 1 2 3 4 5 6; do
|
||||
if docker manifest inspect "$ref" >/dev/null 2>&1; then ok=1; break; fi
|
||||
sleep 5
|
||||
done
|
||||
if [ -z "$ok" ]; then
|
||||
echo "image $ref did not resolve after push" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$ref resolves"
|
||||
done
|
||||
echo "to ship it: set spec.image.tag to ${tag} in universe crs/id.yaml and push"
|
||||
@@ -0,0 +1,60 @@
|
||||
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.
|
||||
curl -fsS --max-time 20 -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/deploy.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
|
||||
+2
-1
@@ -9,6 +9,7 @@ COPY pnpm-workspace.yaml package.json tsconfig.base.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY pkgs/shared/package.json pkgs/shared/
|
||||
COPY pkgs/auth/package.json pkgs/auth/
|
||||
COPY pkgs/connect/package.json pkgs/connect/
|
||||
COPY pkgs/idv/package.json pkgs/idv/
|
||||
COPY pkgs/onboarding/package.json pkgs/onboarding/
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
@@ -23,6 +24,6 @@ RUN pnpm --filter @hanzo/id-web build
|
||||
# (built for static assets, not an app that loads its own bundle), which
|
||||
# blocks the SPA's own scripts and leaves a blank page. hanzoai/spa serves
|
||||
# index.html for all routes with a sane CSP. Defaults: PORT=3000, ROOT=/public.
|
||||
FROM ghcr.io/hanzoai/spa:1.2.0
|
||||
FROM ghcr.io/hanzoai/spa:1.4.8
|
||||
COPY --from=build /build/apps/web/dist /public
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,5 +1,303 @@
|
||||
# LLM.md — Hanzo ID
|
||||
|
||||
## One chain-agnostic "Connect Wallet" button — merge EVM + Solana entries (0.2.9)
|
||||
|
||||
The login page rendered ONE wallet button PER enabled chain (`SocialButtons`
|
||||
mapped `ENABLED_WALLET_CHAINS` → "Continue with Ethereum / EVM" +
|
||||
"Continue with Solana"), so a two-chain build showed two near-identical buttons
|
||||
above the divider. Merged into a SINGLE chain-agnostic "Connect Wallet" entry —
|
||||
the ENTRY is merged, both underlying flows are kept intact. (Rides on 0.2.8:
|
||||
GitLab provider + vitest-unified test runner.)
|
||||
|
||||
- **Detection is a pure function, in the ONE web3 module.** `detectWalletChains()`
|
||||
(`pkgs/auth/src/web3.ts`) is a pure `window` sniff — EVM = `window.ethereum`,
|
||||
Solana = `window.solana`/`solflare`/`backpack` — that returns the
|
||||
`ENABLED_WALLET_CHAINS` with an injected provider. Derived from the enabled
|
||||
set (one source of truth); testable via an injectable window (no DOM, no
|
||||
connect, no I/O). Exported alongside `loginWithWalletChain`.
|
||||
- **`SocialButtons` renders one entry.** The web3 provider now renders a single
|
||||
"Connect Wallet" button (`data-wallet-connect`). On click, `onConnectWallet`
|
||||
calls `detectWalletChains()`: exactly one injected chain → connect straight
|
||||
(`startWallet(chain)`, no chooser); zero or many → reveal an inline chooser
|
||||
(`.hanzo-id-wallet-chains`) of one button per enabled chain
|
||||
(`data-chain=evm|solana`) so EITHER chain stays reachable. Same monochrome
|
||||
`hanzo-id-social-btn` style as GitHub/GitLab/Google; the chooser is indented
|
||||
under the entry. The per-chain path (`startWallet` → `loginWithWalletChain`)
|
||||
is UNCHANGED — only the button that reaches it is merged.
|
||||
- **Verified.** Auth unit tests green (3 new `detectWalletChains` cases:
|
||||
single→[chain], both→[evm,solana], none→[]). Playwright against the dev SPA
|
||||
(get-app-login intercepted so web3 resolves): one "Connect Wallet" renders;
|
||||
click with no injected wallet → chooser shows Ethereum/EVM + Solana; click
|
||||
with an injected `window.ethereum` → NO chooser, straight into the EVM flow.
|
||||
- **Backend untouched.** Pure `id` SPA change (frontend), no IAM/casdoor edit.
|
||||
Ships as `ghcr.io/hanzoai/id:0.2.9`; deploy = bump the operator CR image
|
||||
(`universe/infra/k8s/operator/crs/id.yaml`) by hand (id not in the
|
||||
gitops-reconcile allowlist). NEVER restart ingress (TLS-outage hazard).
|
||||
|
||||
## Provider-hint auto-federation — click GitHub/Google downstream, land straight in the provider (0.2.6)
|
||||
|
||||
Clicking "Continue with GitHub/Google" on a downstream app (console.hanzo.ai)
|
||||
used to bounce the user to the hanzo.id login FORM — the portal ignored the
|
||||
provider the user already chose. Now it launches that provider immediately.
|
||||
Three fixes, all reusing the EXISTING `social.ts` hop (no duplicated IdP config);
|
||||
verified live in-browser (`?provider_hint=provider-github` → github.com,
|
||||
`provider-google` → accounts.google.com, both `redirect_uri=iam.hanzo.ai/callback`,
|
||||
`method=signup`, single `provider=`).
|
||||
|
||||
1. **`Login.tsx` honors `provider_hint`.** The console SDK already appends
|
||||
`&provider_hint=provider-github` (the IAM record `name`) to the authorize
|
||||
redirect. A new `federate` phase: after a silent-SSO miss, if the hint is
|
||||
present it renders a HEADLESS `<SocialButtons autoStart={hint}>` that
|
||||
auto-runs the same hop the button runs — no form flash — and drops to the
|
||||
form only if the hint matches no configured provider. Honor ONLY
|
||||
`provider_hint`, never bare `provider=` (that carries the SSO SDK's
|
||||
`<org>-iam` IDP hint — see the single-provider-state note below).
|
||||
`matchProviderHint` (`social.ts`, pure + tested) maps the hint to a provider
|
||||
by record name / key (`provider-github` or `github`, case-insensitive).
|
||||
|
||||
2. **`getAppLogin` validates against the DOWNSTREAM app's own redirect_uri.**
|
||||
`getAppLogin(clientId, redirectUri?)` now sends the incoming OIDC
|
||||
`redirect_uri` (read from the query), not the hardcoded
|
||||
`${publicOrigin}/callback`. IAM validates it against the app's registered
|
||||
list: `hanzo-cloud` (the console's client_id) registers
|
||||
`console.hanzo.ai/auth/callback` but NOT `hanzo.id/callback`, so the old
|
||||
hardcode made `CheckOAuthLogin` answer `status:error` ("Redirect URI …
|
||||
doesn't exist in the allowed list") and the SPA dropped the whole response
|
||||
(`status!=='ok'`→null) → NO providers resolved. This blocked the social
|
||||
buttons for EVERY cross-app SSO read, not just auto-federation. `SocialButtons`
|
||||
reads `redirect_uri` from `window.location.search` (same pattern as the wallet
|
||||
path); absent (bare portal / device flow) it defaults to the portal callback.
|
||||
|
||||
3. **The interactive hop uses `method=signup` (find-or-create-login).** IAM runs
|
||||
find-or-create only under `signup` (`controllers/auth.go:1041`: existing
|
||||
3rd-party identity → sign in, else create). `signin` is the account-LINK
|
||||
branch (`auth.go:1257`: `GetSessionUsername()==''` → `ResponseError("user
|
||||
doesn't exist")`) — it needs a live session and errors on a fresh "Continue
|
||||
with GitHub". The old `intent==='signup'?'signup':'signin'` sent `signin` from
|
||||
the sign-in page; both intents now use `signup` (intent only changes button
|
||||
copy). Latent bug — never exercised end-to-end before this.
|
||||
|
||||
Contracts locked in `pkgs/auth/src/{social,client}.test.ts`. Deploy: image
|
||||
`ghcr.io/hanzoai/id:0.2.6`, operator CR `universe/infra/k8s/operator/crs/id.yaml`
|
||||
(the `id` CR did not exist in-cluster before — id ran off the raw
|
||||
`deployment.yaml`; applying the CR handed the Deployment to the operator, safe
|
||||
because id carries no env, only `envFrom: id-tenant-catalog`). `id` is NOT in the
|
||||
gitops-reconcile allowlist → apply the CR by hand. Rides on 0.2.5 forced-MFA.
|
||||
|
||||
## Silent SSO must be org-scoped — admin-guard god-mode fix (fixed 0.2.2 → 0.2.3)
|
||||
|
||||
`admin.hanzo.ai` (global-admin console) sits behind admin-guard, a Traefik
|
||||
ForwardAuth that allows ONLY `owner == admin` tokens. Login rides
|
||||
`client_id=hanzo-admin-guard` (org=admin). The 0.2.2 credential-form fix
|
||||
(`LoginForm` posts `app.organization` from `get-app-login` → org=admin →
|
||||
IAM resolves `admin/z`, owner=admin) is CORRECT and verified: posting
|
||||
`organization=admin` to `/v1/iam/login` resolves the admin/* row and
|
||||
`get-account` returns owner=admin.
|
||||
|
||||
But it "didn't take effect end-to-end" because `Login.tsx` (0.2.2) added a
|
||||
`silentLogin` SSO fast-path (`canSilent = client_id && redirect_uri`) that,
|
||||
on mount, minted an auth code from the AMBIENT `iam_session_id` session
|
||||
REGARDLESS of that session's org. Real operators carry a hanzo/* session
|
||||
(from hanzo.chat/console), so silentLogin minted an owner=hanzo code and
|
||||
the guard bounced them to console.hanzo.ai — the org-scoped form was never
|
||||
shown. Silent SSO shadowed the form fix.
|
||||
|
||||
Fix (`pkgs/auth/src/client.ts`, `silentLogin`): silent SSO may reuse the
|
||||
ambient session ONLY when its user org == the app's org. `silentLogin` now
|
||||
resolves the app org (`get-app-login`) + session owner (`get-account`,
|
||||
new internal `sessionOwner()` helper) before minting; on no session or an
|
||||
org mismatch it returns `{}` so `Login.tsx` falls back to the interactive
|
||||
form (which authenticates in the app's own org). Same-org SSO (the common
|
||||
case: hanzo session → hanzo app) still mints silently — no UX change.
|
||||
Cross-org (hanzo session → admin-guard) → form → org=admin → owner=admin →
|
||||
god-mode. Non-admins (e.g. Dave) never reach god-mode: the guard validates
|
||||
`owner==admin` server-side, so this is availability (admins get IN), not a
|
||||
privilege boundary — the fix is client-side and cannot admit a non-admin.
|
||||
IAM (`iam:v1.31.14`) is unchanged; the SSO-ATO exact-match redirect fix
|
||||
(d7648965) is untouched.
|
||||
|
||||
## Social login (GitHub/Google) — single-provider state + matched redirect_uri (fixed 0.1.24 → 0.1.25)
|
||||
|
||||
The social hop used to fail at the IAM `/callback` exchange — GitHub with
|
||||
**"The provider: hanzo-iam does not exist"**, Google with "password or code is
|
||||
incorrect". TWO independent bugs, both in the SPA's provider-name handling; the
|
||||
IAM backend, the OAuth creds, and the registered redirect_uri were all fine.
|
||||
|
||||
**Bug A — wrong provider IDENTITY (fixed 0.1.24).** `parseAppLogin`
|
||||
(`pkgs/auth/src/client.ts`) read the **outer** IAM app-provider LINK `name`
|
||||
as the provider identity. `get-app-login` returns each provider as a link object
|
||||
`{name, canSignIn, …, provider:{name, type, clientId, …}}`; the REAL identity is
|
||||
the nested `provider.name` (e.g. `provider-github`), the name the backend
|
||||
resolves with `GetProvider(admin/<name>)`. The outer link `name` can be a
|
||||
per-app label. Fix: derive from the **nested** `rec.provider.name`, falling back
|
||||
to the outer `rec.name` only when there is no nested record.
|
||||
|
||||
**Bug B — TWO `provider=` in the state (fixed 0.1.25).** The console→hanzo.id
|
||||
SSO SDK appends `provider=hanzo-iam` (its per-org IDP hint) to the upstream
|
||||
`/login/oauth/authorize` query. `social.ts::buildProviderAuthUrl` then appends
|
||||
the REAL social `provider=provider-google`, so the base64 `state` carried BOTH.
|
||||
`Callback.tsx` recovers the provider with `URLSearchParams.get('provider')` —
|
||||
which returns the **FIRST** match (`hanzo-iam`), NOT the last — so the exchange
|
||||
POSTed `provider=hanzo-iam` and the backend rejected it. (The earlier "backend
|
||||
reads the LAST param" note was WRONG: the SPA reads the first.) Fix:
|
||||
`buildProviderAuthUrl` strips any pre-existing `provider=` from the upstream
|
||||
query (`baseQ.delete('provider')`) before appending the real one, so the state
|
||||
carries **exactly ONE** `provider=`. One provider, one source of truth — no
|
||||
reliance on parameter ordering. Locked in `pkgs/auth/src/social.test.ts`
|
||||
("a pre-existing provider= … is stripped — state carries exactly ONE provider").
|
||||
|
||||
**redirect_uri consistency (hardened 0.1.25).** The hop builds the provider
|
||||
`redirect_uri` from `tenant.oauthCallbackOrigin` (the provider's REGISTERED
|
||||
callback host, `https://iam.hanzo.ai`, shared across brand portals).
|
||||
`client.providerLogin` POSTs that redirect_uri to IAM, which forwards it
|
||||
**verbatim** to the provider's token endpoint (`auth.go` →
|
||||
`GetIdProvider(idpInfo, authForm.RedirectUri)` → `Config.Exchange`); a mismatch
|
||||
→ `invalid_grant`. It now derives from the SAME `oauthCallbackOrigin` (was
|
||||
`publicOrigin`, the brand host — equal on the callback host today, but they
|
||||
diverge whenever a brand shares the iam.hanzo.ai client). Locked in
|
||||
`client.test.ts` ("providerLogin posts redirectUri from oauthCallbackOrigin").
|
||||
|
||||
**RC#2 verified live (no Google account needed).** A junk-code probe of the live
|
||||
social path — `POST iam.hanzo.ai/v1/iam/login` with `provider=provider-google` +
|
||||
a fake code — returns `oauth2: "invalid_grant"`, NOT `invalid_client`. That
|
||||
proves Google **accepted** the client_id + client_secret and the
|
||||
`https://iam.hanzo.ai/callback` redirect_uri (client auth passed; only the fake
|
||||
code was rejected). So the Google clientId/secret in the running IAM are real
|
||||
and valid, the provider resolves + is enabled for `hanzo-console`, and the
|
||||
redirect_uri matches — the exchange will complete with a real Google code. No
|
||||
OAuth developer-console change is needed. The only thing not exercisable without
|
||||
a real Google/zoo.ngo account is the final code→user round-trip itself.
|
||||
|
||||
## Silent single sign-on — auto-continue authorize from the issuer session (0.1.26)
|
||||
|
||||
Sign in ONCE at the portal; every other app that authenticates through the same
|
||||
issuer host then logs in with NO form and NO credential re-entry. The IAM
|
||||
backend already supported this — its `Login` handler
|
||||
(`hanzoai/iam controllers/auth.go`) has an "already signed in to IAM" branch:
|
||||
when the request carries the `iam_session_id` cookie but NO username/password
|
||||
and NO provider, `GetSessionUsername()` is non-empty so it mints an
|
||||
authorization code via `HandleLoggedIn` straight from the session. The missing
|
||||
leg was the SPA: `Login.tsx` always rendered the login form, even with a live
|
||||
session.
|
||||
|
||||
The fix wires the SPA leg. `Login.tsx` now, when an app sends the user to the
|
||||
authorize page (`client_id` + `redirect_uri` on the query), first calls
|
||||
`client.silentLogin(...)` — a credential-less POST to `/v1/iam/login`
|
||||
(`type:code`, `application`, NO username/password, `credentials:'include'`). If a
|
||||
live issuer session exists IAM returns the code and the SPA redirects straight
|
||||
back to the app (`redirect_uri?code=…&state=…`); if there is no session IAM
|
||||
answers `status:error` and the SPA falls back to the interactive form — never a
|
||||
dead end. A bare portal visit (no `client_id`/`redirect_uri`) has nowhere to
|
||||
redirect, so it shows the form immediately as before.
|
||||
|
||||
In Hanzo IAM the OAuth `client_id` IS the application name (`hanzo-console`,
|
||||
`hanzo-chat`, …), so the SPA passes `application = client_id` with no extra
|
||||
lookup. The silent leg rides the SAME `HandleLoggedIn` as password/social, so it
|
||||
inherits the social-login `client_id`-resolution fix (`iam` ≥ v1.25.3) — without
|
||||
it the silently-minted code would carry an empty `client_id` and fail the
|
||||
downstream token exchange with `invalid_client`.
|
||||
|
||||
Proven end-to-end against live IAM (no UI needed): a session-cookie-only POST to
|
||||
`/v1/iam/login` returns `status:ok` + a code, and that code exchanges at
|
||||
`/v1/iam/oauth/access_token` (with the PKCE verifier) for a real
|
||||
`access_token`+`id_token`+`refresh_token` — i.e. an existing session silently
|
||||
yields a usable token for another app. Contract locked in
|
||||
`pkgs/auth/src/client.test.ts` ("silentLogin posts NO credentials and redirects
|
||||
with the minted code"; "returns { error } when there is no session").
|
||||
|
||||
Issuer-host note: SSO shares the `iam_session_id` cookie across apps only when
|
||||
they funnel auth through the SAME login host. Today both console
|
||||
(`IAM_SERVER_URL=iam.hanzo.ai`, which 302s to hanzo.id) and chat
|
||||
(`OPENID_ISSUER=hanzo.id`) land on the hanzo.id SPA, where the cookie lives — so
|
||||
the session is shared. `chat` has `OPENID_AUTO_REDIRECT=false`, so the user
|
||||
clicks "Log in with Hanzo" once (provider selection, not a credential prompt);
|
||||
set it `true` for fully zero-click entry into chat.
|
||||
|
||||
## Org-wide unified login — declare the provider set once per org (iam)
|
||||
|
||||
Enabling the same social/SSO set across an org's many apps is now ONE place:
|
||||
the org-level `Organization.DefaultProviders` (hanzoai/iam
|
||||
`object/organization.go`). Any application whose own `Providers` list is EMPTY
|
||||
inherits the org's `DefaultProviders` — resolved in the single app-read path
|
||||
`extendApplicationWithProviders` (`object/application.go`), so every app shares
|
||||
one provider set without repeating it. An app may still pin its own `Providers`
|
||||
to override. `init_data` adopts `DefaultProviders` onto an existing org
|
||||
additively (`initDefinedOrganization`, `initDataNewOnly` — like languages),
|
||||
never overwriting. The seed (`universe/infra/k8s/iam/init_data.json`) declares
|
||||
`defaultProviders` once per org and leaves per-app `providers: []`.
|
||||
|
||||
**Enable unified login for a NEW org/tenant:** add the org to `init_data.json`
|
||||
with `defaultProviders: [provider-github, provider-google, provider-web3,
|
||||
provider-apple]` (or any subset) and leave each app's `providers: []`. All apps
|
||||
inherit automatically — no per-app reconfiguration.
|
||||
|
||||
## Org-agnostic password login (0.1.23) — SUPERSEDED, do not restore
|
||||
|
||||
**This section describes behaviour that no longer works and must not be
|
||||
reinstated.** It is kept because the reasoning below is what makes the current
|
||||
design legible, and because someone reading only this section will otherwise
|
||||
"fix" the apex login straight back into an outage.
|
||||
|
||||
iam2 removed cross-org resolution deliberately. It scopes every credential
|
||||
lookup to one org and treats the collision this design leaned on as a defect —
|
||||
`internal/registry/registry.go` names it "the F-2 bug where z@hanzo.ai collided
|
||||
across admin and hanzo", because resolving across orgs coupled lockout counters
|
||||
between rows and handed out a brute-force oracle on the SuperAdmin. An org-less
|
||||
`POST /v1/iam/login` is now refused outright:
|
||||
|
||||
HTTP 200 {"status":"error","msg":"organization, username and password are required"}
|
||||
|
||||
Note the **200**. The form renders that as though the user's own password were
|
||||
wrong, and every status-code monitor reads it as healthy. Left unfixed it killed
|
||||
the bare sign-in on hanzo.id, lux.id, iam.hanzo.ai and pars.id at once while
|
||||
looking green.
|
||||
|
||||
So `LoginForm` now resolves the app's own org via `get-app-login` and posts it on
|
||||
BOTH entry points — the same thing the 0.2.2 fix below already established for
|
||||
the downstream-app path. A global admin is no longer reached by omission; they
|
||||
reach admin/* by signing into an admin-org app (e.g. `hanzo-admin-guard`), which
|
||||
is the explicit path 0.2.2/0.2.3 describe. `client.login()` stays a pure
|
||||
passthrough: it never invents an org, it only forwards one.
|
||||
|
||||
The original 0.1.23 note follows, for context only.
|
||||
|
||||
### (superseded) original text
|
||||
|
||||
|
||||
The portal login is now **org-agnostic**: it no longer pins
|
||||
`organization=<brand>` on `POST /v1/iam/login`. `LoginForm` passes
|
||||
`tenant.loginOrg` (a NEW, normally-UNSET `TenantConfig` field) as the
|
||||
`organization`, and `client.login()` OMITS the field entirely when it is
|
||||
empty/undefined. With no org posted, IAM runs its **cross-org resolution**
|
||||
(`object.GetUserByFields` → `GetUserByFieldCrossOrg`) and the session encodes the
|
||||
user's REAL owner-org (`GetOrganizationByUser`), never the posted hint.
|
||||
|
||||
Why this matters (the bug it fixes): IAM's `IsGlobalAdmin()` is
|
||||
`user.Owner == "admin"` (it ignores the stored `isGlobalAdmin` column), and
|
||||
`get-organizations` returns ALL orgs only for a global admin, else just the
|
||||
caller's own org. The seeded superusers (`z@hanzo.ai`, `a@hanzo.ai`,
|
||||
`woo@lux.network`) exist in BOTH the `admin` org (the global identity) AND their
|
||||
brand org (`hanzo/lux`). Pinning `organization=hanzo` made `GetUserByFields`
|
||||
hit the colliding `hanzo/z` row FIRST (in-org lookup succeeds → cross-org
|
||||
fallback never runs), so an admin got a 1-org `hanzo` session via the UI even
|
||||
though the API could reach the 45-org global session. Omitting the org makes the
|
||||
in-org lookups miss → cross-org fallback → `admin/z` (global) for the colliding
|
||||
hanzo-domain emails, while a brand-only identity (`z@lux.network`,
|
||||
`major@hanzo.ai`, …) still resolves to its own org. Verified live on
|
||||
`hanzo.id`: `z@hanzo.ai` → `owner=admin`, 45 orgs; a brand-only user → 1 org.
|
||||
|
||||
Boundaries (do NOT regress):
|
||||
- **Signup** still sends a concrete `organization` (`tenant.orgId`) — you cannot
|
||||
create a user in "no org". Only LOGIN omits it.
|
||||
- **Per-app SSO** (console/chat/team pass their own `client_id` + `redirect_uri`)
|
||||
is unaffected: `type=code` + the app's client_id still flow; the auth code is
|
||||
bound to the cross-org-resolved user. Proven against live IAM with
|
||||
`application=hanzo-console`.
|
||||
- A brand that deliberately wants single-org portal login can set `loginOrg` in
|
||||
its runtime catalog entry (`id-tenant-catalog` ConfigMap) — no rebuild.
|
||||
- Contract locked in `pkgs/auth/src/client.test.ts` (omit-when-unset,
|
||||
omit-when-empty, include-when-set, SSO still omits, signup still sends).
|
||||
|
||||
## PKCE on password login (fixed 0.1.13)
|
||||
|
||||
`client.login()` (POST `/v1/iam/login`) must forward `code_challenge`
|
||||
@@ -53,7 +351,7 @@ pars.id ──┘ │
|
||||
▼
|
||||
per-brand OIDC issuer host: hanzo.id / lux.id /
|
||||
zoo.id / pars.id (serves /.well-known + /v1/iam/*;
|
||||
same Casdoor-fork backend, tenant-scoped by org)
|
||||
same Hanzo IAM backend, tenant-scoped by org)
|
||||
│
|
||||
▼
|
||||
iam-* postgres in hanzo namespace
|
||||
@@ -80,7 +378,7 @@ placeholders every social button is hidden, so a user never hits a dead-end;
|
||||
they reappear automatically once real creds land. Clicking a configured OAuth
|
||||
provider runs the **hop** (`social.ts::startProviderLogin`), which redirects
|
||||
straight to the provider with a base64 `state` that round-trips the original
|
||||
authorize request — matching the IAM (Casdoor) `getAuthUrl` contract. The
|
||||
authorize request — matching the Hanzo IAM `getAuthUrl` contract. The
|
||||
provider returns to `/callback`; `Callback.tsx` detects the provider state and
|
||||
calls `client.providerLogin` to exchange the code at the IAM backend, then
|
||||
follows the continue-URL (which re-enters `/callback` as the normal OIDC code).
|
||||
@@ -201,7 +499,7 @@ Custom providers: implement the `IDVProvider` interface in
|
||||
|
||||
## Backend
|
||||
|
||||
The Go IAM backend lives at `~/work/hanzo/iam` (Casdoor fork, module
|
||||
The Go IAM backend lives at `~/work/hanzo/iam` (Hanzo IAM, module
|
||||
`github.com/hanzoai/iam`, image `ghcr.io/hanzoai/iam`). All paths are under
|
||||
the `/v1/iam` prefix — no legacy `/oauth/*`, no `/api/`. This portal talks
|
||||
to it via:
|
||||
@@ -221,3 +519,19 @@ to it via:
|
||||
All hostnames talk to the same IAM backend — the org is carried in the
|
||||
request body (`organization: <orgId>`), and the IAM backend tenant-scopes
|
||||
on that.
|
||||
|
||||
## Truth flows git.hanzo.ai -> GitHub (2026-07-26)
|
||||
|
||||
`hanzoai/id` is CANONICAL on git.hanzo.ai (`mirror:false`, default `main`).
|
||||
GitHub is a **push-mirror** of it, `sync_on_commit: true` with an 8h floor —
|
||||
push here and GitHub follows on its own.
|
||||
|
||||
It used to be the reverse, and that was the bug: as a pull-mirror this repo
|
||||
could run no CI at all, so `.hanzo/workflows` never fired and four commits
|
||||
shipped zero images without a single red signal. Do not re-point the sync.
|
||||
|
||||
Builds publish to BOTH `oci.hanzo.ai/id` (ours, the destination) and
|
||||
`ghcr.io/hanzoai/id` (kept so a rollback target always resolves), tagged from
|
||||
`package.json` — a release IS a version bump. Deploying is a `spec.image.tag`
|
||||
edit in `universe/infra/k8s/operator/crs/id.yaml`; CI must never patch that CR
|
||||
itself, because Hanzo CD reverts it within ~90s.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="id" width="880"></p>
|
||||
|
||||
# @hanzo/id
|
||||
|
||||
White-label login + identity verification portal. One Vite SPA, four hosts
|
||||
|
||||
+3
-1
@@ -4,7 +4,9 @@
|
||||
<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/png" href="data:," />
|
||||
<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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"version": "0.1.22",
|
||||
"description": "Hanzo ID — white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
|
||||
"version": "0.1.26",
|
||||
"description": "Hanzo ID \u2014 white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,19 +11,24 @@
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crossmarkio/sdk": "^0.4.0",
|
||||
"@hanzo/brand": "^1.3.0",
|
||||
"@hanzo/gui": "^7.2.4",
|
||||
"@hanzo/iam": "^0.11.0",
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"@hanzo/id-auth": "workspace:*",
|
||||
"@hanzo/id-connect": "workspace:*",
|
||||
"@hanzo/id-idv": "workspace:*",
|
||||
"@hanzo/id-onboarding": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@luxfi/brand": "^1.0.0",
|
||||
"@parsdao/brand": "^1.0.0",
|
||||
"@tanstack/react-router": "^1.168.0",
|
||||
"@tonconnect/sdk": "^4.0.0",
|
||||
"@zooai/brand": "^1.3.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"sats-connect": "^4.2.1",
|
||||
"viem": "^2.53.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
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 |
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { loadBrand, parseCatalog, resolveTenant, type BrandContract, type TenantConfig } from '@hanzo/id-shared'
|
||||
import { loadBrand, parseCatalog, resolveTenant, idBrandLabel, type BrandContract, type TenantConfig } from '@hanzo/id-shared'
|
||||
import { createAuthClient } from '@hanzo/id-auth'
|
||||
import { Portal } from './pages/Portal'
|
||||
import { Login } from './pages/Login'
|
||||
@@ -7,6 +7,7 @@ import { Signup } from './pages/Signup'
|
||||
import { Forgot } from './pages/Forgot'
|
||||
import { Callback } from './pages/Callback'
|
||||
import { Onboarding } from './pages/Onboarding'
|
||||
import { DeviceApproval } from './pages/DeviceApproval'
|
||||
|
||||
/**
|
||||
* Top-level wiring. Resolves tenant + brand once on mount, then routes via
|
||||
@@ -47,7 +48,7 @@ export function App() {
|
||||
const b = await loadBrand(t.brandPackage)
|
||||
if (cancelled) return
|
||||
setBrand(b)
|
||||
document.title = `Sign in — ${b.name}`
|
||||
document.title = idBrandLabel(b, t.orgId)
|
||||
const fav = document.getElementById('favicon') as HTMLLinkElement | null
|
||||
if (fav && b.faviconUrl) fav.href = b.faviconUrl
|
||||
} catch (e) {
|
||||
@@ -66,6 +67,10 @@ export function App() {
|
||||
if (!tenant || !brand || !client) return <div>Loading…</div>
|
||||
|
||||
const path = window.location.pathname
|
||||
// Device-authorization approval (RFC 8628). Must precede the `/login` catch
|
||||
// since it lives under `/login/oauth/device`.
|
||||
if (path === '/login/oauth/device' || path.startsWith('/login/oauth/device/'))
|
||||
return <DeviceApproval client={client} brand={brand} />
|
||||
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} />
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return <Signup client={client} brand={brand} />
|
||||
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} />
|
||||
|
||||
+53
-3
@@ -3,7 +3,7 @@
|
||||
--bg: #0a0a0a;
|
||||
--fg: #fafafa;
|
||||
--muted: #a3a3a3;
|
||||
--border: #262626;
|
||||
--border: #71717a;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -20,6 +20,10 @@ body {
|
||||
|
||||
.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). */
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
@@ -43,6 +47,7 @@ body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@@ -66,7 +71,9 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
color: var(--bg);
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
/* 13px vertical padding -> 44px tall (>=44px touch target). */
|
||||
padding: 13px 16px;
|
||||
min-height: 44px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -103,7 +110,34 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Forced TOTP enrollment ────────────────────────────────────── */
|
||||
/* ──Device-authorization approval ─────────────────────────────── */
|
||||
.hanzo-id-device main { gap: 18px; }
|
||||
.hanzo-id-device-prompt { color: var(--muted); font-size: 14px; line-height: 1.5; margin: 0; }
|
||||
.hanzo-id-device-prompt strong { color: var(--fg); }
|
||||
.hanzo-id-device-code-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.hanzo-id-device-code-field span { color: var(--muted); font-size: 13px; }
|
||||
.hanzo-id-device-code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hanzo-id-device-confirm {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hanzo-id-device-confirm input {
|
||||
width: auto;
|
||||
margin-top: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ──Forced TOTP enrollment ────────────────────────────────────── */
|
||||
.hanzo-id-mfa-enroll { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-mfa-enroll h2 { margin: 0; font-size: 22px; }
|
||||
.hanzo-id-mfa-qr {
|
||||
@@ -145,13 +179,29 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 11px 14px;
|
||||
/* Flex-centered above, so min-height keeps the label centered at
|
||||
the >=44px touch target. */
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hanzo-id-social-btn:hover { border-color: #3a3a3a; background: #161616; }
|
||||
.hanzo-id-social-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.hanzo-id-social-btn svg { flex: none; }
|
||||
|
||||
/* 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, same monochrome buttons. */
|
||||
.hanzo-id-wallet-chains {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-left: 4px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Labeled divider between social row and email form. */
|
||||
.hanzo-id-divider {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { createIam, createAuthClient } from '@hanzo/id-auth'
|
||||
import { createIam, createAuthClient, decodeState } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
@@ -22,11 +22,11 @@ import { BrandHeader } from '../components/BrandHeader'
|
||||
* - A bare portal sign-in → `/onboarding`.
|
||||
*/
|
||||
|
||||
/** Decode a social-provider `state` (base64 of the original authorize query). */
|
||||
/** Decode a social-provider `state` (URL-safe base64 of the authorize query). */
|
||||
function decodeProviderState(state: string | null): URLSearchParams | null {
|
||||
if (!state) return null
|
||||
try {
|
||||
const decoded = atob(state)
|
||||
const decoded = decodeState(state)
|
||||
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
|
||||
// A provider-login state always carries application + provider markers.
|
||||
if (params.get('provider') && params.get('application')) return params
|
||||
@@ -46,7 +46,7 @@ export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: Brand
|
||||
// the continue-URL back into case (1).
|
||||
if (providerState && search.get('code')) {
|
||||
const client = createAuthClient({ tenant })
|
||||
const oidcQuery = atob(search.get('state')!)
|
||||
const oidcQuery = decodeState(search.get('state')!)
|
||||
client
|
||||
.providerLogin({
|
||||
application: providerState.get('application') ?? '',
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { LoginForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* RFC 8628 device-authorization approval (`/login/oauth/device`).
|
||||
*
|
||||
* The terminal leg of `dev login --device-auth`: the CLI shows a short
|
||||
* `user_code` and sends the human here (the IAM `verification_uri`;
|
||||
* `verification_uri_complete` adds `?user_code=<code>`). The human signs in to
|
||||
* the SAME issuer, confirms the code matches what their device shows, and
|
||||
* approves — which flips the device code's `UserSignIn=true` so the CLI's token
|
||||
* poll completes.
|
||||
*
|
||||
* Auth is reused, never reimplemented: not-signed-in renders the normal
|
||||
* `<LoginForm>` + `<SocialButtons>`; once the issuer session cookie is set the
|
||||
* page reads it back from `/v1/iam/get-account` and shows the confirm step.
|
||||
* Approval rides that session cookie (`client.approveDevice`), so no token ever
|
||||
* touches the URL or logs.
|
||||
*/
|
||||
|
||||
type Phase =
|
||||
| { s: 'checking' }
|
||||
| { s: 'signin' }
|
||||
| { s: 'confirm'; email?: string }
|
||||
| { s: 'consent'; email?: string }
|
||||
| { s: 'approving' }
|
||||
| { s: 'approved' }
|
||||
|
||||
/** Read the user_code from `?user_code=` first, then a trailing path segment
|
||||
* (`/login/oauth/device/<code>`) so both the complete and bare verification
|
||||
* URIs work; absent → the user types it. */
|
||||
function readUserCode(): string {
|
||||
const fromQuery = new URLSearchParams(window.location.search).get('user_code')
|
||||
if (fromQuery) return fromQuery
|
||||
const m = window.location.pathname.match(/\/login\/oauth\/device\/([^/?#]+)/)
|
||||
return m ? decodeURIComponent(m[1]!) : ''
|
||||
}
|
||||
|
||||
/** A device-flow return must never leave tokens/codes sitting in the address
|
||||
* bar (history, referrer, shoulder-surf). Strip everything but `user_code`. */
|
||||
function scrubUrl() {
|
||||
const url = new URL(window.location.href)
|
||||
let changed = false
|
||||
for (const k of ['access_token', 'refresh_token', 'id_token', 'code', 'state']) {
|
||||
if (url.searchParams.has(k)) {
|
||||
url.searchParams.delete(k)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
|
||||
export function DeviceApproval({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
const [phase, setPhase] = useState<Phase>({ s: 'checking' })
|
||||
const [userCode, setUserCode] = useState(() => readUserCode())
|
||||
// Anti-phishing gate: the `?user_code=` prefill is DISPLAY-ONLY. A signed-in
|
||||
// victim who lands here from a crafted `verification_uri_complete` link must
|
||||
// NOT be able to approve an attacker's device with one click — they have to
|
||||
// explicitly affirm the code matches the one their OWN device shows. The
|
||||
// prefill cannot tick this box, so it can never auto-approve.
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const appLabel = client.tenant.appName
|
||||
|
||||
// Resolve the issuer session: signed in → confirm, else → sign-in form. Reads
|
||||
// same-origin from `/v1/iam/get-account` (cookie session; the brand `*.id`
|
||||
// host IS `iamUrl`, so the cookie rides along) — identical to the Portal.
|
||||
useEffect(() => {
|
||||
scrubUrl()
|
||||
let alive = true
|
||||
fetch(new URL('/v1/iam/get-account', client.tenant.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((b: Record<string, unknown>) => {
|
||||
if (!alive) return
|
||||
const d = b.data as Record<string, unknown> | undefined
|
||||
if (b.status === 'ok' && d && typeof d === 'object') {
|
||||
setPhase({ s: 'confirm', email: str(d.email) ?? str(d.name) })
|
||||
} else {
|
||||
setPhase({ s: 'signin' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setPhase({ s: 'signin' })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [client.tenant.iamUrl])
|
||||
|
||||
async function approve() {
|
||||
setError(null)
|
||||
setPhase({ s: 'approving' })
|
||||
const res = await client.approveDevice(userCode)
|
||||
if (res.ok) {
|
||||
setPhase({ s: 'approved' })
|
||||
} else if (res.required) {
|
||||
setPhase({ s: 'consent' })
|
||||
} else {
|
||||
setError(res.error ?? 'Approval failed. Restart sign-in on your device.')
|
||||
setPhase({ s: 'confirm' })
|
||||
}
|
||||
}
|
||||
|
||||
if (phase.s === 'checking') {
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase.s === 'signin') {
|
||||
// Sign in first (reuse the normal flow). The password leg stays on-page via
|
||||
// `onAuthenticated` and re-checks the session; the social leg round-trips and
|
||||
// returns to THIS page (postLoginRedirect), where the session check resumes.
|
||||
const returnTo = userCode
|
||||
? `${window.location.pathname}?user_code=${encodeURIComponent(userCode)}`
|
||||
: window.location.pathname
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>Sign in to approve your device</h1>
|
||||
<SocialButtons client={client} intent="signin" postLoginRedirect={returnTo} />
|
||||
<LoginForm client={client} onAuthenticated={() => setPhase({ s: 'confirm' })} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/forget">Forgot password?</a>
|
||||
</p>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase.s === 'approved') {
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>You're signed in on your device</h1>
|
||||
<p className="lede">Approval complete — you can close this window and return to your device.</p>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
const busy = phase.s === 'approving'
|
||||
const consent = phase.s === 'consent'
|
||||
const email = phase.s === 'confirm' || phase.s === 'consent' ? phase.email : undefined
|
||||
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>Approve this device</h1>
|
||||
{email ? <p className="lede">Signed in as {email}</p> : null}
|
||||
|
||||
<p className="hanzo-id-device-prompt">
|
||||
You are about to authorize <strong>{appLabel}</strong> to sign in on a device. Approve
|
||||
ONLY if this code matches the one shown on that device.
|
||||
</p>
|
||||
|
||||
<label className="hanzo-id-device-code-field">
|
||||
<span>Device code</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoCapitalize="characters"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoComplete="one-time-code"
|
||||
aria-label="Device code"
|
||||
className="hanzo-id-device-code"
|
||||
value={userCode}
|
||||
onChange={(e) => setUserCode(e.target.value)}
|
||||
placeholder="e.g. K7M4P2QH"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{consent ? (
|
||||
<p className="hanzo-id-info">
|
||||
{brand.name} needs your consent to continue. By approving you grant {appLabel} access to
|
||||
your profile.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<label className="hanzo-id-device-confirm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span>
|
||||
I started this sign-in on my own device, and this code matches the one it shows.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
|
||||
<div className="hanzo-id-cta-row">
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-btn primary"
|
||||
disabled={busy || userCode.trim().length === 0 || !confirmed}
|
||||
onClick={approve}
|
||||
>
|
||||
{busy ? 'Approving…' : consent ? 'Approve & grant access' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
function Shell({ brand, children }: { brand: BrandContract; children: ReactNode }) {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-device">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { idBrandLabel, type BrandContract } from '@hanzo/id-shared'
|
||||
import {
|
||||
LoginForm,
|
||||
MfaEnrollForm,
|
||||
@@ -18,6 +18,27 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
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.
|
||||
@@ -26,6 +47,37 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
|
||||
const clientId = clientIdOverride ?? client.tenant.clientId
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSilent) return
|
||||
let cancelled = false
|
||||
client
|
||||
.silentLogin({
|
||||
clientId: clientIdOverride!,
|
||||
application: clientIdOverride!,
|
||||
redirectUri: redirectUri!,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
nonce,
|
||||
})
|
||||
.then((r) => {
|
||||
if (cancelled) return
|
||||
if (r.redirectUrl) {
|
||||
window.location.assign(r.redirectUrl)
|
||||
} else {
|
||||
setPhase(fallback)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPhase(fallback)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Run once on mount; the OAuth params are fixed for the life of the page.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// The credential check succeeded (or MFA was satisfied). For a downstream
|
||||
// OIDC request, re-enter authorize with the now-established IAM session so it
|
||||
// mints the code; for a bare portal sign-in, land on onboarding.
|
||||
@@ -43,6 +95,41 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -94,7 +181,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Sign in to {brand.name}</h1>
|
||||
<h1>Sign in to {idBrandLabel(brand, client.tenant.orgId)}</h1>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
@@ -108,6 +195,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
clientIdOverride={clientIdOverride ?? undefined}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
nonce={nonce}
|
||||
onMfaRequired={setMfa}
|
||||
/>
|
||||
<p className="hanzo-id-footer-links">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { createIam } from '@hanzo/id-auth'
|
||||
import { OnboardingFlow, createOnboardingService, type OnboardingState } from '@hanzo/id-onboarding'
|
||||
import { getConnector } from '@hanzo/id-connect/connectors'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
@@ -55,18 +56,18 @@ export function Onboarding({ tenant, brand }: { tenant: TenantConfig; brand: Bra
|
||||
)
|
||||
}
|
||||
|
||||
/** Minimal EIP-1193 `eth_requestAccounts` connector. Null on cancel/no wallet. */
|
||||
/**
|
||||
* EVM wallet connector backed by @hanzo/id-connect (EIP-6963 multi-injection,
|
||||
* viem under the hood). Returns the checksummed 0x address, or null when the
|
||||
* user cancels or no injected EVM wallet is present. The onboarding wallet step
|
||||
* only needs the address (it stores it via update-user?columns=web3onboard), so
|
||||
* we connect and return account.address — no signature round-trip here.
|
||||
*/
|
||||
async function connectInjectedWallet(): Promise<string | null> {
|
||||
const eth = (window as unknown as { ethereum?: Eip1193 }).ethereum
|
||||
if (!eth) return null
|
||||
try {
|
||||
const accounts = (await eth.request({ method: 'eth_requestAccounts' })) as string[]
|
||||
return accounts?.[0] ?? null
|
||||
const account = await getConnector('evm').connect()
|
||||
return account.address ?? null
|
||||
} catch {
|
||||
return null // user rejected the connection prompt
|
||||
return null // user rejected, or no injected EVM wallet available
|
||||
}
|
||||
}
|
||||
|
||||
interface Eip1193 {
|
||||
request(args: { method: string; params?: unknown[] }): Promise<unknown>
|
||||
}
|
||||
|
||||
+6
-3
@@ -1,15 +1,18 @@
|
||||
{
|
||||
"name": "@hanzo/id",
|
||||
"private": true,
|
||||
"version": "0.1.29",
|
||||
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"version": "0.2.11",
|
||||
"description": "Hanzo ID \u2014 white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"scripts": {
|
||||
"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": {
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/id-auth",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
@@ -12,14 +12,16 @@
|
||||
"./forms": "./src/ui/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["src"],
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-connect": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.11.0",
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"@paulmillr/qr": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - the challenge re-POSTs `/v1/iam/login` with `{mfaType,passcode}` and NO
|
||||
* username, riding the MFA session cookie.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import { createAuthClient, mfaChannelOf, MFA_TOTP } from './client.ts'
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
|
||||
// A capturing fetch double: records the URL + parsed JSON body of the last call
|
||||
// and returns a canned IAM "ok" response. No network.
|
||||
function capturingFetch() {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, body })
|
||||
return new Response(JSON.stringify({ status: 'ok', data: 'AUTHCODE' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
// A routing fetch double for the silent-SSO org gate: silentLogin resolves the
|
||||
// app's org (`/v1/iam/get-app-login`) and the ambient session's owner
|
||||
// (`/v1/iam/get-account`) BEFORE minting a code (`/v1/iam/login`). This lets a
|
||||
// test set the app org + session owner independently and assert whether the mint
|
||||
// leg ran. `sessionOwner: null` models "no live session" (get-account errors).
|
||||
function routingFetch(opts: { appOrg: string; sessionOwner: string | null; code?: string }) {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = []
|
||||
const json = (payload: unknown) =>
|
||||
new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, body })
|
||||
if (url.includes('/get-app-login')) {
|
||||
return json({ status: 'ok', data: { name: 'app', organization: opts.appOrg, providers: [] } })
|
||||
}
|
||||
if (url.includes('/get-account')) {
|
||||
return opts.sessionOwner
|
||||
? json({ status: 'ok', data: { owner: opts.sessionOwner, name: 'z' } })
|
||||
: json({ status: 'error', msg: 'please sign in first' })
|
||||
}
|
||||
return json({ status: 'ok', data: opts.code ?? 'AUTHCODE' })
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
|
||||
return {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-console',
|
||||
appName: 'hanzo-console',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
oauthCallbackOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// `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({ tenant: tenant(), 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({ tenant: tenant(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: '',
|
||||
})
|
||||
assert.equal('organization' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// A brand that DELIBERATELY scopes its portal to one org can still force it.
|
||||
test('login INCLUDES organization when one is explicitly provided', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'someone',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
})
|
||||
|
||||
// Per-app SSO: the downstream app's client_id + redirect_uri still flow through;
|
||||
// `type` flips to `code` and the org is STILL omitted (resolution stays correct
|
||||
// for the SSO path too).
|
||||
test('app SSO (redirectUri present) uses type=code and still omits organization', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
state: 'xyz',
|
||||
})
|
||||
assert.equal(calls[0]!.body.type, 'code')
|
||||
assert.match(calls[0]!.url, /type=code/)
|
||||
assert.equal('organization' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// Signup MUST still carry a concrete org — you cannot create a user in "no org".
|
||||
test('signup STILL sends organization (unchanged — create needs a concrete org)', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
})
|
||||
|
||||
// REGRESSION (the `hanzo-iam does not exist` social-login bug): an IAM
|
||||
// app-provider LINK can carry an outer `name` that is NOT the provider record's
|
||||
// name (some seeds label it `<org>-iam`). The provider's real identity is the
|
||||
// nested `provider.name` the backend resolves on the social hop. getAppLogin
|
||||
// MUST surface the inner record name (`provider-github`), never the outer label,
|
||||
// or SocialButtons posts `provider=<org>-iam` and the backend 400s.
|
||||
function appLoginFetch(payload: unknown): typeof fetch {
|
||||
return async () =>
|
||||
new Response(JSON.stringify({ status: 'ok', data: payload }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
test('getAppLogin uses the nested provider record name, not the outer link label', async () => {
|
||||
const fetchImpl = appLoginFetch({
|
||||
name: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
providers: [
|
||||
{
|
||||
// Outer link label — a real-world seed set this to the per-app default.
|
||||
name: 'hanzo-iam',
|
||||
canSignIn: true,
|
||||
canSignUp: true,
|
||||
// Nested provider RECORD — the true identity + creds.
|
||||
provider: { name: 'provider-github', type: 'GitHub', clientId: 'Iv23li_real', scopes: '' },
|
||||
},
|
||||
],
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const app = await client.getAppLogin('hanzo-console')
|
||||
assert.ok(app, 'app login resolved')
|
||||
assert.equal(app!.providers.length, 1)
|
||||
const gh = app!.providers[0]!
|
||||
assert.equal(gh.name, 'provider-github', 'provider name comes from the nested record')
|
||||
assert.equal(gh.key, 'github', 'key strips the provider- prefix')
|
||||
assert.equal(gh.type, 'GitHub')
|
||||
assert.equal(gh.configured, true, 'a real (non-placeholder) clientId is configured')
|
||||
})
|
||||
|
||||
// The social code exchange must reuse the provider's REGISTERED callback host
|
||||
// (oauthCallbackOrigin), not the brand host (publicOrigin). IAM forwards this
|
||||
// redirect_uri verbatim to the provider's token endpoint, which requires it to
|
||||
// match the authorize hop or the exchange fails `invalid_grant`. When a brand
|
||||
// portal (hanzo.id) shares the iam.hanzo.ai OAuth client these two differ.
|
||||
test('providerLogin posts redirectUri from oauthCallbackOrigin (matches the hop), with the single provider + code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({
|
||||
tenant: tenant({ publicOrigin: 'https://hanzo.id', oauthCallbackOrigin: 'https://iam.hanzo.ai' }),
|
||||
fetchImpl,
|
||||
})
|
||||
const r = await client.providerLogin({
|
||||
application: 'hanzo-console',
|
||||
provider: 'provider-google',
|
||||
code: 'goog_code_xyz',
|
||||
oidcQuery:
|
||||
'?client_id=hanzo-console&redirect_uri=https%3A%2F%2Fconsole.hanzo.ai%2Fauth%2Fiam%2Fcallback&response_type=code&scope=openid&state=rp1',
|
||||
method: 'signin',
|
||||
})
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(
|
||||
calls[0]!.body.redirectUri,
|
||||
'https://iam.hanzo.ai/callback',
|
||||
'redirect_uri derives from oauthCallbackOrigin (the hop), never publicOrigin',
|
||||
)
|
||||
assert.equal(calls[0]!.body.provider, 'provider-google')
|
||||
assert.equal(calls[0]!.body.code, 'goog_code_xyz')
|
||||
// The upstream OIDC params ride the query so IAM continues the original authorize.
|
||||
assert.match(calls[0]!.url, /client_id=hanzo-console/)
|
||||
assert.match(calls[0]!.url, /state=rp1/)
|
||||
// parseLoginResponse builds the app redirect from the minted code + the
|
||||
// original OIDC redirect_uri/state (the same shape as login/silentLogin).
|
||||
assert.equal(r.redirectUrl, 'https://console.hanzo.ai/auth/iam/callback?code=AUTHCODE&state=rp1')
|
||||
})
|
||||
|
||||
// When there is NO nested record (degenerate seed), fall back to the outer label
|
||||
// so the provider is still surfaced rather than dropped.
|
||||
test('getAppLogin falls back to the outer name when no nested provider record', async () => {
|
||||
const fetchImpl = appLoginFetch({
|
||||
name: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
providers: [{ name: 'provider-google', canSignIn: true, canSignUp: true, provider: null }],
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const app = await client.getAppLogin('hanzo-console')
|
||||
assert.ok(app)
|
||||
assert.equal(app!.providers[0]!.name, 'provider-google')
|
||||
})
|
||||
|
||||
// TRUE SSO — the silent leg. silentLogin carries NO credentials: IAM mints the
|
||||
// code from the existing issuer session (cookie sent via credentials:include).
|
||||
// It builds the redirect back to the app from the minted code + state. The mint
|
||||
// runs ONLY when the ambient session's org matches the app's org (same-org SSO,
|
||||
// the common case: a hanzo session signing into a hanzo app).
|
||||
test('silentLogin (same-org session) mints the code and redirects, carrying NO credentials', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: 'hanzo' })
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
state: 'st1',
|
||||
codeChallenge: 'chal',
|
||||
})
|
||||
|
||||
// The mint leg (POST /v1/iam/login, type=code) ran after the org gate passed.
|
||||
const mint = calls.find((c) => c.body.type === 'code')
|
||||
assert.ok(mint, 'mint leg ran for a same-org session')
|
||||
// No credentials of any kind — this is session-only.
|
||||
assert.equal('username' in mint!.body, false, 'no username in silent login')
|
||||
assert.equal('password' in mint!.body, false, 'no password in silent login')
|
||||
assert.equal('provider' in mint!.body, false, 'no provider hop in silent login')
|
||||
assert.equal(mint!.body.application, 'hanzo-console')
|
||||
// OAuth params ride the query so IAM mints a code for the right client + PKCE.
|
||||
assert.match(mint!.url, /clientId=hanzo-console/)
|
||||
assert.match(mint!.url, /code_challenge=chal/)
|
||||
// The mint returns data:'AUTHCODE' -> a fully-formed app redirect.
|
||||
assert.equal(
|
||||
r.redirectUrl,
|
||||
'https://console.hanzo.ai/auth/iam/callback?code=AUTHCODE&state=st1',
|
||||
)
|
||||
})
|
||||
|
||||
// THE ADMIN-GUARD FIX: silent SSO must NOT reuse a session that belongs to a
|
||||
// DIFFERENT org than the app being signed into. An operator with an ambient
|
||||
// hanzo/* session hitting the admin-guard (org=admin) must fall through to the
|
||||
// interactive form (which authenticates in the admin org and resolves the
|
||||
// admin/* identity) — NOT silently mint a code from the hanzo session (which
|
||||
// would confer owner=hanzo and shadow the fix). No mint leg runs; no redirect.
|
||||
test('silentLogin (cross-org session) does NOT mint — falls back to the form', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'hanzo' })
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-admin-guard',
|
||||
application: 'hanzo-admin-guard',
|
||||
redirectUri: 'https://admin.hanzo.ai/__guard/callback',
|
||||
state: 'st1',
|
||||
codeChallenge: 'chal',
|
||||
})
|
||||
|
||||
assert.equal(r.redirectUrl, undefined, 'no silent redirect for a cross-org session')
|
||||
assert.equal(calls.some((c) => c.body.type === 'code'), false, 'the mint leg must NOT run')
|
||||
})
|
||||
|
||||
// Same-org SSO still holds when BOTH are the admin org: an operator already
|
||||
// signed in as admin/* silently re-enters the admin console.
|
||||
test('silentLogin (same admin-org session) mints for the admin-guard', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'admin', sessionOwner: 'admin' })
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-admin-guard',
|
||||
application: 'hanzo-admin-guard',
|
||||
redirectUri: 'https://admin.hanzo.ai/__guard/callback',
|
||||
state: 'st1',
|
||||
})
|
||||
assert.ok(calls.find((c) => c.body.type === 'code'), 'mint leg ran for a same-org admin session')
|
||||
assert.equal(r.redirectUrl, 'https://admin.hanzo.ai/__guard/callback?code=AUTHCODE&state=st1')
|
||||
})
|
||||
|
||||
// No live session: silentLogin returns an empty response (no mint) so Login.tsx
|
||||
// falls back to the interactive form (never a dead end).
|
||||
test('silentLogin returns empty (no mint) when there is no session (form fallback)', async () => {
|
||||
const { calls, fetchImpl } = routingFetch({ appOrg: 'hanzo', sessionOwner: null })
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const r = await client.silentLogin({
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
})
|
||||
assert.equal(r.redirectUrl, undefined)
|
||||
assert.equal(calls.some((c) => c.body.type === 'code'), false, 'no mint without a session')
|
||||
})
|
||||
|
||||
// ── Device-authorization approval (RFC 8628) ─────────────────────────────────
|
||||
// approveDevice rides the issuer SESSION (like silentLogin): NO credentials in
|
||||
// the body, `type:device` + the userCode IAM keys its DeviceAuthMap on, plus the
|
||||
// tenant application/organization for the app lookup. On {status:ok} the device
|
||||
// code is approved (UserSignIn=true) and the CLI's token poll succeeds.
|
||||
test('approveDevice posts type=device + normalized userCode + tenant app/org, NO credentials', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0]!.body.type, 'device')
|
||||
assert.equal(calls[0]!.body.userCode, 'K7M4P2QH')
|
||||
assert.equal(calls[0]!.body.application, 'hanzo-console')
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
// Session-only: never any credentials in a device approval.
|
||||
assert.equal('username' in calls[0]!.body, false)
|
||||
assert.equal('password' in calls[0]!.body, false)
|
||||
assert.equal('provider' in calls[0]!.body, false)
|
||||
assert.match(calls[0]!.url, /type=device/)
|
||||
assert.equal(r.ok, true)
|
||||
})
|
||||
|
||||
// IAM mints codes from an UPPERCASE unambiguous alphabet ([A-HJ-NP-Z2-9]); a
|
||||
// human may transcribe them lower-cased or with stray spaces/dashes. Normalize
|
||||
// TO uppercase so the lookup matches — case-insensitive entry, exact-match send.
|
||||
test('approveDevice uppercases and strips spaces/dashes before sending', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.approveDevice(' k7m4-p2qh ')
|
||||
assert.equal(calls[0]!.body.userCode, 'K7M4P2QH')
|
||||
})
|
||||
|
||||
// An empty/blank code never hits the network — fail fast with a clear message.
|
||||
test('approveDevice rejects an empty code without calling fetch', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const r = await client.approveDevice(' ')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(r.ok, false)
|
||||
assert.ok(r.error)
|
||||
})
|
||||
|
||||
// The IAM error message (e.g. "UserCode Expired") is surfaced verbatim.
|
||||
test('approveDevice surfaces the IAM error message', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response(JSON.stringify({ status: 'error', msg: 'UserCode Expired' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.error, 'UserCode Expired')
|
||||
})
|
||||
|
||||
// Consent branch: {status:ok, data:{required:true}} → {ok:false, required:true}
|
||||
// so the page can render consent instead of treating it as success or a dead end.
|
||||
test('approveDevice maps the consent-required branch to { required: true }', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response(JSON.stringify({ status: 'ok', data: { required: true } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const r = await client.approveDevice('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.required, true)
|
||||
assert.equal(r.error, undefined)
|
||||
})
|
||||
|
||||
// getAppLogin's redirectUri is validated by IAM against the app's REGISTERED
|
||||
// list. A cross-app SSO read (the console's `hanzo-cloud` viewed from hanzo.id)
|
||||
// MUST send the downstream app's OWN redirect_uri — the portal's `/callback` is
|
||||
// not in that app's list, so hardcoding it makes IAM drop the response and no
|
||||
// social buttons resolve. Absent, it defaults to the portal's own callback.
|
||||
test('getAppLogin sends the passed redirect_uri, and defaults to the portal callback when omitted', async () => {
|
||||
const urls: string[] = []
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
urls.push(typeof input === 'string' ? input : input.toString())
|
||||
return new Response(
|
||||
JSON.stringify({ status: 'ok', data: { name: 'hanzo-cloud', organization: 'hanzo', providers: [] } }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
// Cross-app read: the console's registered redirect_uri rides through verbatim.
|
||||
await client.getAppLogin('hanzo-cloud', 'https://console.hanzo.ai/auth/callback')
|
||||
const u1 = new URL(urls[0]!)
|
||||
assert.equal(u1.searchParams.get('clientId'), 'hanzo-cloud')
|
||||
assert.equal(u1.searchParams.get('redirectUri'), 'https://console.hanzo.ai/auth/callback')
|
||||
|
||||
// Bare/own read: no redirect_uri → default to the portal's own /callback.
|
||||
await client.getAppLogin('hanzo-id')
|
||||
const u2 = new URL(urls[1]!)
|
||||
assert.equal(u2.searchParams.get('redirectUri'), 'https://hanzo.id/callback')
|
||||
})
|
||||
+242
-29
@@ -2,6 +2,7 @@ import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type {
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
ForgotRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
MfaSetup,
|
||||
OAuthAuthorizeRequest,
|
||||
SignupRequest,
|
||||
SilentLoginRequest,
|
||||
TokenResponse,
|
||||
} from './types'
|
||||
|
||||
@@ -25,8 +27,8 @@ export function mfaChannelOf(iamType: string): MfaChannel {
|
||||
/**
|
||||
* Composable IAM client.
|
||||
*
|
||||
* Stateless wrapper around the canonical IAM REST surface (Casdoor-compat
|
||||
* paths under `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
|
||||
* Stateless wrapper around the canonical IAM REST surface (paths under
|
||||
* `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
|
||||
* client instance per tenant. The portal creates one in `createRoot()`;
|
||||
* downstream pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()`
|
||||
* directly.
|
||||
@@ -46,6 +48,27 @@ export function mfaChannelOf(iamType: string): MfaChannel {
|
||||
export interface AuthClient {
|
||||
readonly tenant: TenantConfig
|
||||
login(req: LoginRequest): Promise<LoginResponse>
|
||||
/**
|
||||
* Silent single-sign-on: mint an authorization code from the EXISTING issuer
|
||||
* session (the `iam_session_id` cookie set when the user signed in once for
|
||||
* another app) — no credentials, no provider hop. Returns `{ redirectUrl }`
|
||||
* (the app's `redirect_uri` + `?code=&state=`) when a live session exists, or
|
||||
* `{ error }` when it does not so the caller renders the interactive form.
|
||||
* This is the seamless 2nd/3rd-app login leg.
|
||||
*/
|
||||
silentLogin(req: SilentLoginRequest): Promise<LoginResponse>
|
||||
/**
|
||||
* Approve an RFC 8628 device-authorization request from the device-approval
|
||||
* page (`/login/oauth/device`). The user MUST already be signed in to the
|
||||
* issuer — this rides the SAME `iam_session_id` cookie as silent SSO
|
||||
* (`credentials:'include'`, no credentials in the body). It POSTs
|
||||
* `/v1/iam/login` with `type:'device'` + the `userCode` the device shows,
|
||||
* plus the tenant's `application`/`organization`; IAM resolves the user from
|
||||
* the session, flips the device code's `UserSignIn=true`, and the CLI's token
|
||||
* poll then succeeds. Returns `{required:true}` when the app needs consent
|
||||
* first (rare for first-party apps), or `{error}` with the IAM message.
|
||||
*/
|
||||
approveDevice(userCode: string): Promise<DeviceApprovalResult>
|
||||
signup(req: SignupRequest): Promise<LoginResponse>
|
||||
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
|
||||
authorize(req: OAuthAuthorizeRequest): string
|
||||
@@ -57,12 +80,19 @@ export interface AuthClient {
|
||||
* sign-in buttons (password / GitHub / Google / Web3) to render.
|
||||
* Resolves to null when the endpoint is unreachable so callers can fall
|
||||
* back to the tenant's declared default method set.
|
||||
*
|
||||
* `redirectUri` is validated by IAM against the app's registered list. For a
|
||||
* cross-app SSO read (e.g. console → hanzo.id, `clientId=hanzo-cloud`) pass the
|
||||
* DOWNSTREAM app's own OIDC `redirect_uri` — the portal's `/callback` is NOT in
|
||||
* that app's list, so hardcoding it makes IAM answer `status:error`
|
||||
* ("Redirect URI … doesn't exist in the allowed list") and drops the whole
|
||||
* response. Omit it for a bare/own-app read (defaults to the portal callback).
|
||||
*/
|
||||
getAppLogin(clientId?: string): Promise<AppLogin | null>
|
||||
getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null>
|
||||
/**
|
||||
* Complete a social provider login when the provider redirects back to
|
||||
* `/callback` with a `code` + base64 `state` (see `social.ts`). Exchanges the
|
||||
* provider code at the IAM backend (the Casdoor `AuthBackend.login` contract)
|
||||
* provider code at the IAM backend (the IAM `AuthBackend.login` contract)
|
||||
* and resolves the URL to redirect to — the original OIDC `redirect_uri` with
|
||||
* an authorization code, which the portal's normal PKCE callback then
|
||||
* completes. NOTE: pending live verification — runs only once real OAuth
|
||||
@@ -128,28 +158,150 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
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({
|
||||
type,
|
||||
username: req.identifier,
|
||||
password: req.password,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
signinMethod: 'Password',
|
||||
autoSignin: true,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
// Resolve the org of the user in the ambient IAM session (the `iam_session_id`
|
||||
// cookie), or null when there is no live session. Reads `/v1/iam/get-account`;
|
||||
// the org is the `owner` field (Casdoor returns the User at the top level or
|
||||
// under `data`). Used to keep silent SSO from reusing a session that belongs
|
||||
// to a DIFFERENT org than the app being signed into.
|
||||
async function sessionOwner(): Promise<string | null> {
|
||||
try {
|
||||
const res = await f(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
if (body.status === 'error') return null
|
||||
const nested = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
const owner = typeof body.owner === 'string' ? body.owner : nested.owner
|
||||
return typeof owner === 'string' && owner ? owner : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function silentLogin(req: SilentLoginRequest): Promise<LoginResponse> {
|
||||
// Silent SSO may reuse the ambient IAM session ONLY when that session's user
|
||||
// belongs to the SAME org as the app being signed into. A cross-org app —
|
||||
// e.g. the admin-guard (client_id=hanzo-admin-guard, org=admin) reached from
|
||||
// a browser that already holds a hanzo/* session — must NOT mint a code from
|
||||
// the wrong-org session: that confers owner=hanzo and silently shadows the
|
||||
// org-scoped credential form (which resolves the admin/* identity). Resolve
|
||||
// the app's org and the session owner; on no session or an org mismatch,
|
||||
// return an empty response so Login.tsx falls back to the interactive form,
|
||||
// which authenticates in the app's own org. Same-org SSO (the common case)
|
||||
// still mints silently, so seamless sign-in is preserved.
|
||||
const [app, owner] = await Promise.all([getAppLogin(req.clientId), sessionOwner()])
|
||||
if (!owner) return {}
|
||||
const appOrg = app?.organization
|
||||
if (appOrg && owner !== appOrg) return {}
|
||||
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', req.scope ?? 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
if (req.nonce) url.searchParams.set('nonce', req.nonce)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', 'code')
|
||||
// NO username/password and NO provider: IAM's Login handler falls through to
|
||||
// its "already signed in to IAM" branch (`GetSessionUsername() != ""`) and
|
||||
// mints an authorization code for `application` from the existing
|
||||
// `iam_session_id` cookie. `credentials: 'include'` sends that cookie. When
|
||||
// there is no live session IAM responds `status:error` -> parseLoginResponse
|
||||
// returns `{ error }`, and Login.tsx renders the interactive form instead.
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ type: 'code', application: req.application, autoSignin: true }),
|
||||
})
|
||||
return parseLoginResponse(res, { redirectUri: req.redirectUri, state: req.state })
|
||||
}
|
||||
|
||||
async function approveDevice(userCode: string): Promise<DeviceApprovalResult> {
|
||||
const code = normalizeUserCode(userCode)
|
||||
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
// IAM's device branch keys the cache off the `userCode` in the BODY; the
|
||||
// `type` echo on the query mirrors the other login legs. NO credentials —
|
||||
// the user is already signed in, so this rides the session cookie
|
||||
// (`credentials:'include'`) and IAM resolves the user from the session.
|
||||
url.searchParams.set('type', 'device')
|
||||
const body: Record<string, unknown> = {
|
||||
type: 'device',
|
||||
userCode: code,
|
||||
application: tenant.appName,
|
||||
}
|
||||
// `organization` scopes the application lookup (FindApplicationByName); it
|
||||
// does NOT resolve the user (that comes from the session), so pinning the
|
||||
// tenant org here is safe — unlike password login, which omits it.
|
||||
if (tenant.orgId) body.organization = tenant.orgId
|
||||
let res: Response
|
||||
try {
|
||||
res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
parsed = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { ok: false, error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || parsed.status === 'error') {
|
||||
return { ok: false, error: typeof parsed.msg === 'string' && parsed.msg ? parsed.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
// Consent branch: {status:ok, data:{required:true}}. First-party apps skip
|
||||
// this; surface it so the caller can render consent rather than dead-ending.
|
||||
const data = parsed.data
|
||||
if (data !== null && typeof data === 'object' && (data as Record<string, unknown>).required === true) {
|
||||
return { ok: false, required: true }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function signup(req: SignupRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/signup', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
@@ -246,12 +398,16 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function getAppLogin(clientId?: string): Promise<AppLogin | null> {
|
||||
async function getAppLogin(clientId?: string, redirectUri?: string): Promise<AppLogin | null> {
|
||||
const id = clientId ?? tenant.clientId
|
||||
const url = new URL('/v1/iam/get-app-login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', id)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
url.searchParams.set('redirectUri', `${tenant.publicOrigin}/callback`)
|
||||
// Validate against the downstream app's OWN redirect_uri when the caller has
|
||||
// one (the SSO authorize flow carries it); the portal's own /callback is not
|
||||
// registered for another app, so IAM would reject the read and we'd surface
|
||||
// no social buttons. Fall back to the portal callback for a bare/own read.
|
||||
url.searchParams.set('redirectUri', redirectUri || `${tenant.publicOrigin}/callback`)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
url.searchParams.set('state', 'app-login')
|
||||
let body: Record<string, unknown>
|
||||
@@ -269,17 +425,41 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
async function providerLogin(
|
||||
req: ProviderExchangeRequest,
|
||||
): Promise<{ redirectUrl?: string; error?: string }> {
|
||||
// POST the provider code to the IAM backend with the original OIDC params
|
||||
// as the query string (Casdoor `AuthBackend.login(body, oAuthParams)`). The
|
||||
// backend exchanges the code, signs the user in, and returns the URL to
|
||||
// continue the original authorize request.
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
// POST the provider code to the IAM backend together with the app's ORIGINAL
|
||||
// OIDC authorize params (recovered from the round-tripped `state`). IAM
|
||||
// exchanges the provider code, signs the user in, and mints an authorization
|
||||
// code BOUND TO THE APP'S request — the app's client_id and, crucially, its
|
||||
// PKCE `code_challenge` (C1) — which we then hand back to the originating app
|
||||
// so its OWN callback exchanges the code with ITS verifier (V1). The minted
|
||||
// code is bound to C1, so that exchange matches; this is the keystone of the
|
||||
// social-login PKCE round-trip.
|
||||
const oidc = new URLSearchParams(req.oidcQuery.replace(/^\?/, ''))
|
||||
const appRedirectUri = oidc.get('redirect_uri') ?? ''
|
||||
const appState = oidc.get('state') ?? ''
|
||||
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
// OIDC params ride the QUERY — IAM's HandleLoggedIn reads them there first
|
||||
// when minting the code. Forward exactly the app's request so the code
|
||||
// carries its client_id, scope, nonce and — load-bearing — its
|
||||
// `code_challenge`. (`redirect_uri` snake-case is NOT forwarded: IAM reads
|
||||
// the code's redirect binding from camelCase `redirectUri`, set below.)
|
||||
for (const [k, v] of oidc) {
|
||||
if (['client_id', 'redirect_uri', 'response_type', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method'].includes(k)) {
|
||||
if (['client_id', 'response_type', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method'].includes(k)) {
|
||||
url.searchParams.set(k, v)
|
||||
}
|
||||
}
|
||||
// Bind the minted code to the APP's redirect_uri (camelCase `redirectUri` —
|
||||
// the param HandleLoggedIn reads), so the code targets the app, NOT the
|
||||
// provider-callback host carried in the body below.
|
||||
if (appRedirectUri) url.searchParams.set('redirectUri', appRedirectUri)
|
||||
|
||||
// The redirect_uri IAM forwards to the PROVIDER's token endpoint MUST be
|
||||
// byte-identical to the hop's redirect_uri — the provider's REGISTERED
|
||||
// callback host (`oauthCallbackOrigin`, e.g. `iam.hanzo.ai`, shared across
|
||||
// brand portals) — or the provider rejects the exchange `invalid_grant`.
|
||||
// This is a DIFFERENT redirect_uri from the app's above: one drives the
|
||||
// provider exchange (body), one binds the minted app code (query).
|
||||
const callbackOrigin = tenant.oauthCallbackOrigin ?? tenant.publicOrigin
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -289,8 +469,10 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
application: req.application,
|
||||
provider: req.provider,
|
||||
code: req.code,
|
||||
// IAM's social state guard accepts state == application name.
|
||||
state: req.application,
|
||||
redirectUri: `${tenant.publicOrigin}/callback`,
|
||||
redirectUri: `${callbackOrigin}/callback`,
|
||||
// "signup" = find-or-create-LOGIN (see social.ts); never the link branch.
|
||||
method: req.method,
|
||||
}),
|
||||
})
|
||||
@@ -303,9 +485,18 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
// On success the backend returns the continue-URL in `data`.
|
||||
const data = typeof body.data === 'string' ? body.data : ''
|
||||
return data ? { redirectUrl: data } : { error: 'provider login returned no redirect' }
|
||||
// IAM returns the freshly-minted authorization CODE in `data` (codeToResponse
|
||||
// → the bare code string, NOT a URL). Build the redirect back to the app's
|
||||
// own redirect_uri + original state so the app runs the standard OIDC code
|
||||
// exchange. (Treating the bare code AS the redirect URL — the prior bug —
|
||||
// dead-ended the flow on the issuer host and never returned to the app.)
|
||||
const code = typeof body.data === 'string' ? body.data : ''
|
||||
if (!code) return { error: 'provider login returned no authorization code' }
|
||||
if (!appRedirectUri) return { error: 'provider login: missing redirect_uri in social state' }
|
||||
const sep = appRedirectUri.includes('?') ? '&' : '?'
|
||||
return {
|
||||
redirectUrl: `${appRedirectUri}${sep}code=${encodeURIComponent(code)}&state=${encodeURIComponent(appState)}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function getAccount(): Promise<MfaIdentity | null> {
|
||||
@@ -418,6 +609,8 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
return {
|
||||
tenant,
|
||||
login,
|
||||
silentLogin,
|
||||
approveDevice,
|
||||
signup,
|
||||
forgot,
|
||||
authorize,
|
||||
@@ -433,6 +626,17 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a user-entered device code to the form IAM generated. IAM mints
|
||||
* user_codes from an UPPERCASE unambiguous alphabet ([A-HJ-NP-Z2-9], no
|
||||
* I/L/O/0/1) and keys its DeviceAuthMap on the exact string. A human may
|
||||
* transcribe it lower-cased or with stray spaces/dashes, so normalize TO
|
||||
* uppercase and strip separators — case-insensitive entry, an exact-match send.
|
||||
*/
|
||||
function normalizeUserCode(raw: string): string {
|
||||
return raw.trim().toUpperCase().replace(/[\s-]+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an IAM provider record to its canonical authorize-endpoint `provider`
|
||||
* key. IAM names providers `provider-<key>` (e.g. `provider-github`); the
|
||||
@@ -465,14 +669,23 @@ function parseAppLogin(
|
||||
.map((p): AppProvider | null => {
|
||||
if (typeof p !== 'object' || p === null) return null
|
||||
const rec = p as Record<string, unknown>
|
||||
const name = typeof rec.name === 'string' ? rec.name : ''
|
||||
if (!name) return null
|
||||
// The clientId lives on the nested provider record (`rec.provider`), not
|
||||
// the outer link object.
|
||||
// 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,
|
||||
|
||||
@@ -10,8 +10,18 @@ export {
|
||||
startProviderLogin,
|
||||
buildProviderAuthUrl,
|
||||
isHoppableProvider,
|
||||
encodeState,
|
||||
decodeState,
|
||||
type ProviderLoginParams,
|
||||
} from './social'
|
||||
export {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletLoginContext,
|
||||
type WalletWindow,
|
||||
} from './web3'
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
@@ -25,5 +35,6 @@ export type {
|
||||
TokenResponse,
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
} from './types'
|
||||
export * from './ui'
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* Provider-hop URL builder tests — pure, no network. Run with:
|
||||
* pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Verifies the URL + base64 state match the Hanzo-IAM (Casdoor) `getAuthUrl`
|
||||
* Verifies the URL + base64 state match the Hanzo IAM `getAuthUrl`
|
||||
* contract so the backend `/callback` exchange accepts the return. The
|
||||
* end-to-end OAuth round-trip still needs live verification once real provider
|
||||
* creds are seeded — but the URL/state construction is locked down here.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildProviderAuthUrl, isHoppableProvider } from './social.ts'
|
||||
import { buildProviderAuthUrl, isHoppableProvider, matchProviderHint } from './social.ts'
|
||||
|
||||
const ORIGIN = 'https://hanzo.id'
|
||||
// The original OIDC authorize query the portal was bounced here with.
|
||||
@@ -29,6 +29,19 @@ test('GitHub hop builds the correct endpoint, client_id, redirect_uri, and scope
|
||||
assert.ok(url.includes('response_type=code'))
|
||||
})
|
||||
|
||||
test('GitLab hop builds the correct endpoint, client_id, redirect_uri, and scope', () => {
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-gitlab', type: 'GitLab', clientId: 'gl_real_5a68' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(url.startsWith('https://gitlab.com/oauth/authorize?'))
|
||||
assert.ok(url.includes('client_id=gl_real_5a68'))
|
||||
assert.ok(url.includes('redirect_uri=https://hanzo.id/callback'))
|
||||
assert.ok(url.includes('scope=read_user')) // GitLab identity read
|
||||
assert.ok(url.includes('response_type=code'))
|
||||
})
|
||||
|
||||
test('the registered callback origin overrides the browser origin in redirect_uri', () => {
|
||||
// The shared OAuth client is registered against iam.hanzo.ai/callback, so the
|
||||
// hop must return there even though the SPA runs on hanzo.id — otherwise the
|
||||
@@ -60,6 +73,32 @@ test('state base64-encodes the original OIDC query + application/provider/method
|
||||
assert.ok(decoded.includes('method=signup'))
|
||||
})
|
||||
|
||||
test('a pre-existing provider= in the upstream query is stripped — state carries exactly ONE provider', () => {
|
||||
// The console→hanzo.id SSO SDK appends `provider=hanzo-iam` (its per-org IDP
|
||||
// hint) to the upstream authorize query. The hop appends the REAL social
|
||||
// provider; the upstream one MUST be stripped, because `Callback` recovers the
|
||||
// provider with `URLSearchParams.get` (the FIRST match) — two `provider=`
|
||||
// params would make it post `hanzo-iam`, which the IAM backend rejects.
|
||||
const searchWithHint =
|
||||
'?client_id=hanzo-console&redirect_uri=https%3A%2F%2Fiam.hanzo.ai%2Fcallback&response_type=code&scope=openid&state=rp123&provider=hanzo-iam'
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-console', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
searchWithHint,
|
||||
'https://iam.hanzo.ai',
|
||||
)!
|
||||
const state = new URL(url).searchParams.get('state')!
|
||||
const decoded = Buffer.from(state, 'base64').toString('utf8')
|
||||
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
|
||||
// Exactly one provider, and it is the real social one (not the upstream hint).
|
||||
assert.deepEqual(params.getAll('provider'), ['provider-google'])
|
||||
assert.equal(params.get('provider'), 'provider-google') // FIRST match = the social provider
|
||||
assert.ok(!decoded.includes('hanzo-iam')) // the upstream hint is gone entirely
|
||||
// The rest of the upstream OIDC request is preserved so the backend completes it.
|
||||
assert.ok(decoded.includes('client_id=hanzo-console'))
|
||||
assert.ok(decoded.includes('state=rp123'))
|
||||
})
|
||||
|
||||
test('Google uses its own endpoint + scope; a custom provider scope overrides', () => {
|
||||
const g = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
@@ -84,6 +123,23 @@ test('an unconfigured (empty clientId) or unknown provider type yields no URL',
|
||||
|
||||
test('isHoppableProvider knows the OAuth set, not wallet', () => {
|
||||
assert.equal(isHoppableProvider('GitHub'), true)
|
||||
assert.equal(isHoppableProvider('GitLab'), true)
|
||||
assert.equal(isHoppableProvider('Google'), true)
|
||||
assert.equal(isHoppableProvider('Web3Onboard'), false)
|
||||
})
|
||||
|
||||
test('matchProviderHint resolves the console hint, the bare key, and case, else undefined', () => {
|
||||
const providers = [
|
||||
{ name: 'provider-github', key: 'github' },
|
||||
{ name: 'provider-google', key: 'google' },
|
||||
]
|
||||
// The console sends the IAM record name verbatim (`provider-github`).
|
||||
assert.equal(matchProviderHint(providers, 'provider-github')?.key, 'github')
|
||||
assert.equal(matchProviderHint(providers, 'provider-google')?.key, 'google')
|
||||
// The bare key and any case also resolve, so the two sides need no shared constant.
|
||||
assert.equal(matchProviderHint(providers, 'github')?.key, 'github')
|
||||
assert.equal(matchProviderHint(providers, 'GitHub')?.key, 'github')
|
||||
// A hint for a provider this app doesn't offer, or an empty hint, matches nothing.
|
||||
assert.equal(matchProviderHint(providers, 'provider-apple'), undefined)
|
||||
assert.equal(matchProviderHint(providers, ''), undefined)
|
||||
})
|
||||
|
||||
+79
-8
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Social provider redirect — the "hop" that sends the browser to GitHub /
|
||||
* Google / … to start an OAuth login, replicating the Hanzo-IAM (Casdoor)
|
||||
* Google / … to start an OAuth login, replicating the Hanzo IAM
|
||||
* front-end `Provider.getAuthUrl` contract so the IAM backend's `/callback`
|
||||
* exchange accepts the return.
|
||||
*
|
||||
@@ -15,10 +15,10 @@
|
||||
* getStateFromQueryParams` in the IAM fork):
|
||||
* url = `${endpoint}?client_id=${clientId}&redirect_uri=${origin}/callback`
|
||||
* `&scope=${scope}&response_type=code&state=${state}`
|
||||
* state = btoa(`${window.location.search}&application=${app}&provider=`
|
||||
* `${providerName}&method=${method}`) // base64 of the ORIGINAL
|
||||
* // OIDC query + app/provider/method, so the backend recovers the
|
||||
* // original request when the provider returns to /callback.
|
||||
* state = encodeState(`${window.location.search}&application=${app}&provider=`
|
||||
* `${providerName}&method=${method}`) // URL-SAFE base64 of the
|
||||
* // ORIGINAL OIDC query + app/provider/method, so the backend recovers
|
||||
* // the original request when the provider returns to /callback.
|
||||
*
|
||||
* Only the standard OAuth2 set is wired here (the providers a `-id` app
|
||||
* actually enables: github, google, +web3 handled elsewhere). Apple uses the
|
||||
@@ -35,6 +35,10 @@ const AUTH_INFO: Record<string, { endpoint: string; scope: string }> = {
|
||||
// Canonical Google OAuth2 authorize endpoint. (Google aliases the legacy
|
||||
// `/signin/oauth` path, but `/o/oauth2/v2/auth` is the documented, stable one.)
|
||||
Google: { endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', scope: 'profile+email' },
|
||||
// GitLab OAuth2 authorize endpoint. `read_user` grants the identity read
|
||||
// (profile + email) IAM needs to find-or-create the account — the same
|
||||
// provider record backs the cloud /v1/integrations/gitlab connector.
|
||||
GitLab: { endpoint: 'https://gitlab.com/oauth/authorize', scope: 'read_user' },
|
||||
}
|
||||
|
||||
export interface ProviderLoginParams {
|
||||
@@ -48,10 +52,38 @@ export interface ProviderLoginParams {
|
||||
readonly clientId: string
|
||||
/** Override scope; falls back to the type default. */
|
||||
readonly scopes?: string
|
||||
/** "signin" (default) or "signup" — passed through to the backend. */
|
||||
/**
|
||||
* IAM social-auth method. Defaults to "signup" — the find-or-create-LOGIN
|
||||
* branch (IAM canonical). "signin" is the account-LINK branch and needs
|
||||
* an existing session, so it is NOT used for interactive provider sign-in.
|
||||
*/
|
||||
readonly method?: 'signin' | 'signup'
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-safe base64 (RFC 4648 §5) for the round-tripped `state`.
|
||||
*
|
||||
* The provider reflects `state` back on a URL query string. Standard base64
|
||||
* (`btoa`) emits `+` `/` `=`, and on the return `URLSearchParams` turns `+`
|
||||
* into a space (then `atob` strips it) — silently CORRUPTING the encoded OIDC
|
||||
* request, including the `code_challenge`. A corrupted, non-empty challenge is
|
||||
* exactly what makes the app's later token exchange fail with
|
||||
* `invalid_grant: code_verifier does not match code_challenge`. Encoding the
|
||||
* state with the URL-safe alphabet (and decoding it the same way in `Callback`)
|
||||
* makes the round-trip byte-exact. The payload is ASCII (an OAuth query), so a
|
||||
* plain `btoa`/`atob` core is sufficient.
|
||||
*/
|
||||
export function encodeState(raw: string): string {
|
||||
return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/** Inverse of {@link encodeState}; tolerant of standard or URL-safe input. */
|
||||
export function decodeState(state: string): string {
|
||||
let b64 = state.replace(/-/g, '+').replace(/_/g, '/')
|
||||
while (b64.length % 4) b64 += '='
|
||||
return atob(b64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provider authorize URL (pure; testable without navigating).
|
||||
*
|
||||
@@ -78,10 +110,23 @@ export function buildProviderAuthUrl(
|
||||
if (!info || !p.clientId) return null
|
||||
const scope = p.scopes && p.scopes.trim() !== '' ? p.scopes : info.scope
|
||||
const redirectUri = `${callbackOrigin}/callback`
|
||||
const method = p.method ?? 'signin'
|
||||
// IAM's social branch does FIND-OR-CREATE-LOGIN only under method `signup`
|
||||
// (the canonical IAM default — web `Util.tsx` getEvent → getAuthUrl(...,
|
||||
// "signup")). Any other value (incl. `signin`) takes the account-LINK branch,
|
||||
// which requires an EXISTING session and 400s a fresh "Continue with Google".
|
||||
const method = p.method ?? 'signup'
|
||||
// The console SSO SDK appends a unified `provider=<org>-iam` hint to the
|
||||
// upstream authorize query (`search`). We append the REAL social provider
|
||||
// below, so strip any pre-existing `provider=` first — otherwise the state
|
||||
// carries TWO `provider=` params and the /callback exchange resolves the
|
||||
// wrong one (`<org>-iam`, which IAM rejects). One provider, one source of
|
||||
// truth — don't rely on "backend reads the last param".
|
||||
const baseQ = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
baseQ.delete('provider')
|
||||
const baseSearch = `?${baseQ.toString()}`
|
||||
// Base64 of the original OIDC query + routing — the backend decodes this on
|
||||
// the /callback return to complete the original authorize request.
|
||||
const state = btoa(`${search}&application=${encodeURIComponent(p.application)}&provider=${encodeURIComponent(p.providerName)}&method=${method}`)
|
||||
const state = encodeState(`${baseSearch}&application=${encodeURIComponent(p.application)}&provider=${encodeURIComponent(p.providerName)}&method=${method}`)
|
||||
return `${info.endpoint}?client_id=${p.clientId}&redirect_uri=${redirectUri}&scope=${scope}&response_type=code&state=${state}`
|
||||
}
|
||||
|
||||
@@ -90,6 +135,32 @@ export function isHoppableProvider(type: string): boolean {
|
||||
return type in AUTH_INFO
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `provider_hint` from the authorize query to one of the app's
|
||||
* configured providers. A client that already knows which provider the user
|
||||
* chose (the console passes `?provider_hint=provider-github` when a user clicks
|
||||
* "Continue with GitHub" over there) sends the hint so this portal launches that
|
||||
* provider straight away — no second button press, no bounce through a login
|
||||
* page. Accepts the IAM record name (`provider-github`), the normalized key
|
||||
* (`github`), or the record name with the `provider-` prefix stripped, so the
|
||||
* two sides agree without a shared constant. Returns undefined when nothing
|
||||
* matches (the caller falls back to the interactive form).
|
||||
*/
|
||||
export function matchProviderHint<P extends { name: string; key: string }>(
|
||||
providers: Iterable<P>,
|
||||
hint: string,
|
||||
): P | undefined {
|
||||
const h = hint.trim().toLowerCase()
|
||||
if (h === '') return undefined
|
||||
const bare = h.replace(/^provider-/, '')
|
||||
for (const p of providers) {
|
||||
const name = p.name.toLowerCase()
|
||||
const key = p.key.toLowerCase()
|
||||
if (name === h || key === h || key === bare) return p
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the browser to the provider to begin login. No-op return on bad input.
|
||||
*
|
||||
|
||||
+71
-3
@@ -3,11 +3,63 @@ export interface LoginRequest {
|
||||
readonly password: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
/**
|
||||
* Org-resolution anchor for the credential lookup. OPTIONAL by design.
|
||||
*
|
||||
* IAM resolves the user by (org, identifier); if the in-org lookup misses it
|
||||
* falls back to a CROSS-ORG lookup by email/username and the session always
|
||||
* encodes the user's REAL owner-org (`GetOrganizationByUser`), never this
|
||||
* value. So this field is a lookup HINT, not the session's org.
|
||||
*
|
||||
* Leaving it empty/undefined makes login ORG-AGNOSTIC: every in-org lookup
|
||||
* misses, the cross-org fallback runs, and an identity that lives in the
|
||||
* global `admin` org (a global admin) resolves to `admin` (→ full multi-org
|
||||
* session) while a brand-only identity resolves to its own brand org. This is
|
||||
* why the portal does NOT pin the brand org here — pinning `hanzo` would
|
||||
* resolve a colliding `hanzo/<name>` row and truncate a global admin to one
|
||||
* org. Set it only to FORCE a specific tenant (e.g. a brand that deliberately
|
||||
* scopes its portal to a single org). Signup, by contrast, MUST carry a
|
||||
* concrete org (you cannot create a user in "no org").
|
||||
*/
|
||||
readonly organization?: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/**
|
||||
* OIDC nonce from the downstream authorize request. MUST be threaded through
|
||||
* the password-login path so the minted code (and resulting id_token) echo it.
|
||||
* Confidential OIDC clients that validate strictly (e.g. LibreChat /
|
||||
* openid-client with OPENID_REUSE_TOKENS) reject an id_token whose nonce
|
||||
* doesn't match the one they sent -> "unexpected JWT claim value" -> callback
|
||||
* 500. Forward, never default — only echo what the authorize URL carried.
|
||||
*/
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs to {@link AuthClient.silentLogin} — the silent-SSO leg.
|
||||
*
|
||||
* Carries NO credentials. When the browser already holds an `iam_session_id`
|
||||
* cookie on the issuer host (the user signed in once for another app), IAM's
|
||||
* Login handler takes its "already signed in" branch and mints an authorization
|
||||
* code for `application` without a password or a provider hop. This is what
|
||||
* makes the 2nd/3rd app log in seamlessly. With no live session IAM returns an
|
||||
* error and the caller falls back to the interactive login form.
|
||||
*/
|
||||
export interface SilentLoginRequest {
|
||||
/** OAuth client id of the requesting app (== application name in Hanzo IAM). */
|
||||
readonly clientId: string
|
||||
/** IAM application name the code is minted for. */
|
||||
readonly application: string
|
||||
/** The requesting app's OAuth redirect_uri — the code is appended to it. */
|
||||
readonly redirectUri: string
|
||||
readonly state?: string
|
||||
readonly scope?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/** OIDC nonce, echoed into the minted code -> id_token (strict consumers). */
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
/** A multi-factor channel the portal can render a code entry for. */
|
||||
@@ -40,6 +92,22 @@ export interface LoginResponse {
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of approving an RFC 8628 device-authorization request
|
||||
* ({@link AuthClient.approveDevice}).
|
||||
*
|
||||
* `ok` — the device code was marked signed-in (the CLI's token poll now
|
||||
* succeeds). `required` — the application needs the user to grant consent
|
||||
* before approval can complete (`{status:ok, data:{required:true}}`); rare for
|
||||
* first-party apps. `error` — the IAM-surfaced failure message (e.g.
|
||||
* "UserCode Expired", "DeviceCode Invalid").
|
||||
*/
|
||||
export interface DeviceApprovalResult {
|
||||
readonly ok: boolean
|
||||
readonly required?: boolean
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The TOTP enrollment material minted by `/v1/iam/mfa/setup/initiate`. The
|
||||
* secret + `url` (an `otpauth://` URI) are rendered locally as a QR code — the
|
||||
@@ -111,9 +179,9 @@ export interface OAuthAuthorizeRequest {
|
||||
export interface ProviderInfo {
|
||||
readonly name: string
|
||||
readonly displayName?: string
|
||||
/** Casdoor provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
|
||||
/** IAM provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
|
||||
readonly type?: string
|
||||
/** Casdoor category, e.g. OAuth, Web3, SAML. */
|
||||
/** IAM category, e.g. OAuth, Web3, SAML. */
|
||||
readonly category?: string
|
||||
readonly canSignIn?: boolean
|
||||
readonly canSignUp?: boolean
|
||||
|
||||
@@ -9,8 +9,16 @@ export interface LoginFormProps {
|
||||
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) {
|
||||
@@ -25,21 +33,59 @@ export function LoginForm(props: LoginFormProps) {
|
||||
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.tenant.clientId)
|
||||
const application = app?.application ?? client.tenant.appName
|
||||
const organization = app?.organization ?? client.tenant.loginOrg
|
||||
const res = await client.login({
|
||||
identifier,
|
||||
password,
|
||||
clientId: props.clientIdOverride ?? client.tenant.clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
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 {
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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 { createIam } from '../iam'
|
||||
import { startProviderLogin, isHoppableProvider } from '../social'
|
||||
import { GitHubIcon, GoogleIcon, WalletIcon } from './icons'
|
||||
import { startProviderLogin, isHoppableProvider, matchProviderHint } from '../social'
|
||||
import {
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
ENABLED_WALLET_CHAINS,
|
||||
WALLET_CHAIN_LABELS,
|
||||
} from '../web3'
|
||||
import { GitHubIcon, GitLabIcon, GoogleIcon, WalletIcon } from './icons'
|
||||
import { Divider } from './Divider'
|
||||
|
||||
/**
|
||||
* Social + Web3 sign-in buttons.
|
||||
* 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
|
||||
@@ -17,10 +23,16 @@ import { Divider } from './Divider'
|
||||
* placeholder creds is hidden so its button never dead-ends, and reappears once
|
||||
* real creds land. When the config is unreadable we render none.
|
||||
*
|
||||
* Each OAuth button drives the provider "hop" (`startProviderLogin`) — it
|
||||
* redirects straight to GitHub/Google with a Casdoor-compatible state that
|
||||
* round-trips the original authorize request, so the IAM backend's `/callback`
|
||||
* exchange completes it. (Web3/wallet falls back to the `@hanzo/iam` redirect.)
|
||||
* Two sign-in shapes, decomplected:
|
||||
* - OAuth (github/google) → the provider "hop" (`startProviderLogin`): redirect
|
||||
* straight to the provider; the IAM backend `/callback` exchange completes it.
|
||||
* - Web3/wallet → native Sign-In-With-X (`loginWithWalletChain`): connect a
|
||||
* wallet with `@hanzo/id-connect` (no WalletConnect, no projectId), sign the
|
||||
* IAM-minted challenge, POST `/v1/iam/web3/verify`, then follow the SAME
|
||||
* redirect the password flow returns. The wallet provider 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
|
||||
@@ -30,12 +42,28 @@ export interface SocialButtonsProps {
|
||||
readonly intent?: 'signin' | 'signup'
|
||||
/**
|
||||
* Downstream app's `redirect_uri`, if this portal is mid-flow for another
|
||||
* app. Social/Web3 sign-in always returns to the portal's own `/callback`
|
||||
* (the SDK's fixed redirectUri), so we stash this target before the
|
||||
* redirect; `Callback` reads it back and forwards the tokens there. Absent
|
||||
* → a bare portal sign-in that lands on onboarding.
|
||||
* 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 {
|
||||
@@ -47,12 +75,13 @@ interface ProviderMeta {
|
||||
/** 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', 'google', 'web3']
|
||||
const ORDER = ['github', 'gitlab', 'google', 'web3']
|
||||
|
||||
interface Resolved {
|
||||
/** IAM application name (for the provider-hop state). */
|
||||
@@ -66,99 +95,230 @@ export function SocialButtons({
|
||||
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)
|
||||
|
||||
// The provider "hop": persist the downstream target across the IAM round-trip
|
||||
// (`Callback` reads it back and forwards tokens there, else lands on
|
||||
// onboarding), then redirect to the provider. Shared by the button click and
|
||||
// the `autoStart` auto-launch so both take the identical path.
|
||||
function hop(application: string, provider: AppProvider) {
|
||||
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
|
||||
else sessionStorage.removeItem('post_login_redirect')
|
||||
startProviderLogin(
|
||||
{
|
||||
application,
|
||||
providerName: provider.name,
|
||||
type: provider.type,
|
||||
clientId: provider.clientId,
|
||||
scopes: provider.scopes,
|
||||
// Interactive social login is ALWAYS find-or-create-login — IAM runs that
|
||||
// branch only under `signup` (auth.go: existing 3rd-party identity → sign
|
||||
// in, else create). `signin` is the account-LINK branch, which needs a
|
||||
// live session and errors "user doesn't exist" on a fresh "Continue with
|
||||
// GitHub". So both the sign-in and sign-up pages use `signup` here; the
|
||||
// intent only changes button copy, never the IAM method.
|
||||
method: 'signup',
|
||||
},
|
||||
// The shared OAuth client is registered against the IAM backend's
|
||||
// /callback (not this brand host), so the hop must return there or the
|
||||
// provider rejects the redirect_uri. Catalog-driven; defaults to host.
|
||||
client.tenant.oauthCallbackOrigin,
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
// Read the app config against the DOWNSTREAM app's own redirect_uri (carried
|
||||
// on the authorize query in the SSO flow), not the portal's /callback — IAM
|
||||
// validates it against the app's registered list, and a cross-app clientId
|
||||
// (e.g. console's `hanzo-cloud` viewed from hanzo.id) does NOT register the
|
||||
// portal callback, so hardcoding it drops the whole response and no social
|
||||
// resolves. Absent (bare portal / device flow) → getAppLogin defaults it.
|
||||
const oidcRedirectUri =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('redirect_uri') ?? undefined
|
||||
: undefined
|
||||
client
|
||||
.getAppLogin(clientIdOverride)
|
||||
.getAppLogin(clientIdOverride, oidcRedirectUri)
|
||||
.then((app) => {
|
||||
if (cancelled) return
|
||||
if (!app) {
|
||||
// Can't read the app config → render no social rather than risk a
|
||||
// dead-end button. Password / email-code still render.
|
||||
setResolved({ application: '', providers: {} })
|
||||
onAutoStartResolved?.(false)
|
||||
return
|
||||
}
|
||||
const want = intent === 'signup' ? (p: AppProvider) => p.canSignUp : (p: AppProvider) => p.canSignIn
|
||||
// Render ONLY providers IAM actually holds credentials for. A provider
|
||||
// with placeholder/empty creds would dead-end the OAuth redirect, so we
|
||||
// hide it; it reappears automatically once real creds are seeded.
|
||||
// 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) {
|
||||
if (want(p) && p.configured && p.key in PROVIDER_META) providers[p.key] = p
|
||||
const enabled = p.key === 'web3' ? want(p) : want(p) && p.configured
|
||||
if (enabled && p.key in PROVIDER_META) providers[p.key] = p
|
||||
}
|
||||
setResolved({ application: app.application, providers })
|
||||
// A client that already knows the provider (console `?provider_hint=…`)
|
||||
// launches it straight away — the SAME hop the button runs, so a click
|
||||
// over there lands directly in the provider flow, no second press and no
|
||||
// bounce through this login page.
|
||||
if (autoStart && !autoStarted.current) {
|
||||
autoStarted.current = true
|
||||
const target = matchProviderHint(Object.values(providers), autoStart)
|
||||
if (target && isHoppableProvider(target.type)) {
|
||||
onAutoStartResolved?.(true)
|
||||
hop(app.application, target)
|
||||
} else {
|
||||
// Hint names a provider this app doesn't offer → let the caller show
|
||||
// the form rather than dead-end on a blank "signing you in".
|
||||
onAutoStartResolved?.(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResolved({ application: '', providers: {} })
|
||||
if (cancelled) return
|
||||
setResolved({ application: '', providers: {} })
|
||||
onAutoStartResolved?.(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Run once on mount: getAppLogin is a one-shot and autoStart is fixed for
|
||||
// the life of the page; the ref guards the hop against a double-fire.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, clientIdOverride, intent])
|
||||
|
||||
// In autoStart mode the component is headless — it exists only to run the hop
|
||||
// above; the caller renders its own "signing you in" state. Render nothing.
|
||||
if (autoStart) return null
|
||||
if (resolved === null) return null // resolving — render nothing rather than flicker
|
||||
const ordered = ORDER.filter((k) => k in resolved.providers)
|
||||
if (ordered.length === 0) return null
|
||||
|
||||
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
|
||||
|
||||
function start(provider: AppProvider) {
|
||||
function startOAuth(provider: AppProvider) {
|
||||
setError(null)
|
||||
// Persist the downstream target across the IAM round-trip; `Callback`
|
||||
// reads it back and forwards tokens there (else lands on onboarding).
|
||||
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
|
||||
else sessionStorage.removeItem('post_login_redirect')
|
||||
const method = intent === 'signup' ? 'signup' : 'signin'
|
||||
// OAuth providers (github/google) hop straight to the provider; wallet/web3
|
||||
// falls back to the @hanzo/iam redirect.
|
||||
if (isHoppableProvider(provider.type)) {
|
||||
startProviderLogin(
|
||||
{
|
||||
application: resolved!.application,
|
||||
providerName: provider.name,
|
||||
type: provider.type,
|
||||
clientId: provider.clientId,
|
||||
scopes: provider.scopes,
|
||||
method,
|
||||
},
|
||||
// The shared OAuth client is registered against the IAM backend's
|
||||
// /callback (not this brand host), so the hop must return there or the
|
||||
// provider rejects the redirect_uri. Catalog-driven; defaults to host.
|
||||
client.tenant.oauthCallbackOrigin,
|
||||
)
|
||||
return
|
||||
}
|
||||
const iam = createIam(client.tenant, clientIdOverride)
|
||||
iam.signinRedirect({ additionalParams: { provider: provider.key } }).catch((e) => {
|
||||
hop(resolved!.application, provider)
|
||||
}
|
||||
|
||||
async function startWallet(chain: Chain) {
|
||||
setError(null)
|
||||
setBusyChain(chain)
|
||||
try {
|
||||
const sp = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
|
||||
const res = await loginWithWalletChain(client, chain, {
|
||||
clientId: clientIdOverride,
|
||||
redirectUri: postLoginRedirect,
|
||||
state: sp.get('state') ?? undefined,
|
||||
nonce: sp.get('nonce') ?? undefined,
|
||||
codeChallenge: sp.get('code_challenge') ?? undefined,
|
||||
codeChallengeMethod: (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined,
|
||||
})
|
||||
if (res.error) {
|
||||
setError(res.error)
|
||||
} else if (res.redirectUrl) {
|
||||
// Same post-login redirect the password flow performs.
|
||||
window.location.href = res.redirectUrl
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
})
|
||||
} finally {
|
||||
setBusyChain(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 meta = PROVIDER_META[k]
|
||||
const { Icon } = meta
|
||||
const provider = resolved.providers[k]!
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="hanzo-id-social-btn"
|
||||
data-provider={k}
|
||||
onClick={() => start(provider)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{verb} with {meta.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
{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-social-btn"
|
||||
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-social-btn hanzo-id-wallet-chain"
|
||||
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>
|
||||
)
|
||||
}
|
||||
if (!isHoppableProvider(provider.type)) return null
|
||||
const meta = PROVIDER_META[k]!
|
||||
const { Icon } = meta
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="hanzo-id-social-btn"
|
||||
data-provider={k}
|
||||
onClick={() => startOAuth(provider)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{verb} with {meta.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
</div>
|
||||
{/* The "or" separator belongs WITH the social block — render it only when
|
||||
there are buttons, so it never dangles above the password form when
|
||||
|
||||
@@ -32,6 +32,14 @@ export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
@@ -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 { TenantConfig } from '@hanzo/id-shared'
|
||||
|
||||
function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
|
||||
return {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-id',
|
||||
appName: 'hanzo-id',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
oauthCallbackOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const CHALLENGE: LoginChallenge = {
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id',
|
||||
nonce: 'NONCE1234567890A',
|
||||
issuedAt: '2026-01-01T00:00:00.000Z',
|
||||
expirationTime: '2026-01-01T00:10:00.000Z',
|
||||
version: '1',
|
||||
}
|
||||
|
||||
/**
|
||||
* Capturing fetch: returns the minted CHALLENGE for the nonce GET, and a canned
|
||||
* IAM "ok" body for the verify POST (an auth code in `data`, like /v1/iam/login).
|
||||
* Records every call so the test can assert the exact wire shape.
|
||||
*/
|
||||
function capturingFetch(verifyData: unknown = 'AUTHCODE') {
|
||||
const calls: { url: string; method: string; body: Record<string, unknown> }[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
const method = init?.method ?? 'GET'
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, method, body })
|
||||
if (url.includes('/v1/iam/web3/nonce')) {
|
||||
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
// verify
|
||||
return new Response(JSON.stringify({ status: 'ok', data: verifyData }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
/** Fake signer: records the (chain, challenge) it was handed, returns a proof. */
|
||||
function fakeSigner() {
|
||||
const seen: { chain: Chain; challenge: LoginChallenge }[] = []
|
||||
const proof: SignedProof = {
|
||||
chain: 'evm',
|
||||
scheme: 'secp256k1-eip191',
|
||||
address: '0xabc0000000000000000000000000000000000def',
|
||||
message: 'rendered CAIP-122 message',
|
||||
signature: '0xdeadbeef',
|
||||
}
|
||||
const sign: WalletSigner = async (chain, challenge) => {
|
||||
seen.push({ chain, challenge })
|
||||
return { ...proof, chain }
|
||||
}
|
||||
return { seen, sign, proof }
|
||||
}
|
||||
|
||||
test('fetches the nonce for the chosen chain, signs the returned challenge, POSTs the proof', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const { seen, sign } = fakeSigner()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
|
||||
// 1) nonce GET first, scoped to the chain, on the brand's own iamUrl.
|
||||
assert.equal(calls[0]!.method, 'GET')
|
||||
assert.match(calls[0]!.url, /^https:\/\/hanzo\.id\/v1\/iam\/web3\/nonce\?chain=evm$/)
|
||||
|
||||
// 2) the server challenge was handed to the signer (nonce/domain/uri/times
|
||||
// intact — the server-minted, single-use values the proof must bind).
|
||||
assert.equal(seen.length, 1)
|
||||
assert.equal(seen[0]!.chain, 'evm')
|
||||
assert.equal(seen[0]!.challenge.nonce, CHALLENGE.nonce)
|
||||
assert.equal(seen[0]!.challenge.domain, CHALLENGE.domain)
|
||||
assert.equal(seen[0]!.challenge.uri, CHALLENGE.uri)
|
||||
assert.equal(seen[0]!.challenge.issuedAt, CHALLENGE.issuedAt)
|
||||
assert.equal(seen[0]!.challenge.expirationTime, CHALLENGE.expirationTime)
|
||||
|
||||
// 3) the proof + routing was POSTed to verify.
|
||||
const verify = calls[1]!
|
||||
assert.equal(verify.method, 'POST')
|
||||
assert.match(verify.url, /\/v1\/iam\/web3\/verify$/)
|
||||
assert.equal(verify.body.chain, 'evm')
|
||||
assert.equal(verify.body.scheme, 'secp256k1-eip191')
|
||||
assert.equal(verify.body.address, '0xabc0000000000000000000000000000000000def')
|
||||
assert.equal(verify.body.message, 'rendered CAIP-122 message')
|
||||
assert.equal(verify.body.signature, '0xdeadbeef')
|
||||
// routing fields the controller needs.
|
||||
assert.equal(verify.body.application, 'hanzo-id')
|
||||
assert.equal(verify.body.method, 'login')
|
||||
assert.equal(verify.body.clientId, 'hanzo-id')
|
||||
// bare sign-in (no downstream redirectUri) → type=login.
|
||||
assert.equal(verify.body.type, 'login')
|
||||
|
||||
// 4) bare sign-in lands on onboarding — same destination as the password flow.
|
||||
assert.equal(res.redirectUrl, '/onboarding')
|
||||
assert.equal(res.error, undefined)
|
||||
})
|
||||
|
||||
test('SSO flow (downstream redirectUri) sends type=code and returns the app redirect with the minted code', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch('CODE_XYZ')
|
||||
const { sign } = fakeSigner()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
const res = await loginWithWalletChain(
|
||||
client,
|
||||
'evm',
|
||||
{ redirectUri: 'https://console.hanzo.ai/auth/iam/callback', state: 'rp123', clientId: 'hanzo-console' },
|
||||
fetchImpl,
|
||||
sign,
|
||||
)
|
||||
|
||||
const verify = calls[1]!
|
||||
assert.equal(verify.body.type, 'code')
|
||||
assert.equal(verify.body.redirectUri, 'https://console.hanzo.ai/auth/iam/callback')
|
||||
assert.equal(verify.body.state, 'rp123')
|
||||
assert.equal(verify.body.clientId, 'hanzo-console')
|
||||
assert.equal(
|
||||
res.redirectUrl,
|
||||
'https://console.hanzo.ai/auth/iam/callback?code=CODE_XYZ&state=rp123',
|
||||
)
|
||||
})
|
||||
|
||||
test('disabled chains are not offered and fail closed without any network or signer call', async () => {
|
||||
// The stub-verifier chains must NOT be in the enabled set...
|
||||
for (const stub of ['ton', 'xrp', 'bitcoin'] as const) {
|
||||
assert.equal(ENABLED_WALLET_CHAINS.includes(stub), false, `${stub} must be disabled`)
|
||||
}
|
||||
// ...and only the production-verifier chains are.
|
||||
assert.deepEqual([...ENABLED_WALLET_CHAINS], ['evm', 'solana'])
|
||||
// every enabled chain has a render label.
|
||||
for (const c of ENABLED_WALLET_CHAINS) assert.ok(WALLET_CHAIN_LABELS[c])
|
||||
|
||||
// Calling a disabled chain returns an error and touches neither fetch nor signer.
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
let signed = false
|
||||
const sign: WalletSigner = async () => {
|
||||
signed = true
|
||||
throw new Error('signer must not run for a disabled chain')
|
||||
}
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'ton', {}, fetchImpl, sign)
|
||||
assert.match(res.error ?? '', /not enabled/)
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(signed, false)
|
||||
})
|
||||
|
||||
test('a wallet rejection surfaces as { error } (not a throw), and verify is never called', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const sign: WalletSigner = async () => {
|
||||
throw new Error('User rejected the request')
|
||||
}
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.error, 'User rejected the request')
|
||||
// nonce was fetched (1 call) but verify was NOT (no 2nd call).
|
||||
assert.equal(calls.length, 1)
|
||||
assert.match(calls[0]!.url, /\/v1\/iam\/web3\/nonce/)
|
||||
})
|
||||
|
||||
test('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({ tenant: tenant(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.error, 'web3: bad signature')
|
||||
assert.equal(res.redirectUrl, undefined)
|
||||
})
|
||||
@@ -0,0 +1,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 { TenantConfig } from '@hanzo/id-shared'
|
||||
import type { Chain, LoginChallenge, SignedProof } from '@hanzo/id-connect'
|
||||
import type { AuthClient } from './client'
|
||||
import type { LoginResponse } from './types'
|
||||
|
||||
/**
|
||||
* Connect a wallet on `chain` and sign `challenge`, returning the proof. The
|
||||
* single seam between this orchestrator and the browser wallet libs: the default
|
||||
* lazy-loads `@hanzo/id-connect/login` (so importing this module never pulls
|
||||
* viem/sats-connect, and the wallet bundle is code-split until first use); tests
|
||||
* inject a fake. One signature, one way.
|
||||
*/
|
||||
export type WalletSigner = (chain: Chain, challenge: LoginChallenge) => Promise<SignedProof>
|
||||
|
||||
const defaultSigner: WalletSigner = async (chain, challenge) => {
|
||||
const { loginWithWallet } = await import('@hanzo/id-connect/login')
|
||||
const { proof } = await loginWithWallet({ chain, challenge })
|
||||
return proof
|
||||
}
|
||||
|
||||
/**
|
||||
* Chains whose wallet login is ENABLED. The server-side verifiers for EVM and
|
||||
* Solana are production-grade; TON / XRP / Bitcoin verifiers are still stubs and
|
||||
* would fail closed, so they are NOT offered. Adding a chain later is one line
|
||||
* here (once its Go verifier is real). Single source of truth — the UI renders
|
||||
* exactly this set.
|
||||
*/
|
||||
export const ENABLED_WALLET_CHAINS: readonly Chain[] = ['evm', 'solana']
|
||||
|
||||
/** Display label per chain, shown on each connect button. */
|
||||
export const WALLET_CHAIN_LABELS: Record<Chain, string> = {
|
||||
evm: 'Ethereum / EVM',
|
||||
solana: 'Solana',
|
||||
bitcoin: 'Bitcoin',
|
||||
ton: 'TON',
|
||||
xrp: 'XRP',
|
||||
}
|
||||
|
||||
/** 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 tenant.clientId. */
|
||||
readonly clientId?: string
|
||||
/** Downstream app `redirect_uri`; presence flips the flow to the auth-code (SSO) path. */
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
/** OIDC nonce from the downstream authorize request, echoed into the minted code. */
|
||||
readonly nonce?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a wallet on `chain`, sign the IAM-minted challenge, and verify it —
|
||||
* resolving to the SAME {@link LoginResponse} the password login returns (so the
|
||||
* caller reuses one redirect path). Throws only on a programming/transport error
|
||||
* the UI can't act on; expected failures (user rejects, bad signature) come back
|
||||
* as `{ error }`.
|
||||
*
|
||||
* `client.tenant.iamUrl` is the fetch base (HIP-0111 host-relative — the brand's
|
||||
* own `*.id` host), matching every other AuthClient call.
|
||||
*/
|
||||
export async function loginWithWalletChain(
|
||||
client: AuthClient,
|
||||
chain: Chain,
|
||||
ctx: WalletLoginContext = {},
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
sign: WalletSigner = defaultSigner,
|
||||
): Promise<LoginResponse> {
|
||||
const tenant = client.tenant
|
||||
if (!ENABLED_WALLET_CHAINS.includes(chain)) {
|
||||
return { error: `wallet login not enabled for ${chain}` }
|
||||
}
|
||||
|
||||
// 1. Mint the challenge, then connect+sign atomically (the connector
|
||||
// disconnects on failure). The nonce is fetched without an address — the
|
||||
// controller treats (chain,address) as advisory and binds the real address
|
||||
// from the SIGNED message, so there is no second round-trip to scope it.
|
||||
let proof: SignedProof
|
||||
try {
|
||||
const challenge = await fetchNonce(tenant, chain, fetchImpl)
|
||||
proof = await sign(chain, challenge)
|
||||
} catch (err) {
|
||||
return { error: errMessage(err) }
|
||||
}
|
||||
|
||||
// 2. Verify the proof + routing at IAM. Type defaults to "login" (session
|
||||
// cookie) server-side; a downstream redirectUri makes it the code flow.
|
||||
const url = new URL('/v1/iam/web3/verify', tenant.iamUrl)
|
||||
const res = await fetchImpl(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
// routing
|
||||
organization: tenant.loginOrg ?? '',
|
||||
application: tenant.appName,
|
||||
method: 'login',
|
||||
clientId: ctx.clientId ?? tenant.clientId,
|
||||
redirectUri: ctx.redirectUri ?? '',
|
||||
state: ctx.state ?? '',
|
||||
scope: 'openid profile email',
|
||||
type: ctx.redirectUri ? 'code' : 'login',
|
||||
nonce: ctx.nonce ?? '',
|
||||
codeChallenge: ctx.codeChallenge ?? '',
|
||||
codeChallengeMethod: ctx.codeChallengeMethod ?? '',
|
||||
// proof
|
||||
chain: proof.chain,
|
||||
scheme: proof.scheme,
|
||||
address: proof.address,
|
||||
publicKey: proof.publicKey ?? '',
|
||||
message: proof.message,
|
||||
signature: proof.signature,
|
||||
extra: proof.extra ?? {},
|
||||
}),
|
||||
})
|
||||
|
||||
return parseVerifyResponse(res, ctx)
|
||||
}
|
||||
|
||||
/** GET the CAIP-122 challenge for (chain) from IAM; throws on a non-ok payload. */
|
||||
async function fetchNonce(
|
||||
tenant: TenantConfig,
|
||||
chain: Chain,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<LoginChallenge> {
|
||||
const url = new URL('/v1/iam/web3/nonce', tenant.iamUrl)
|
||||
url.searchParams.set('chain', chain)
|
||||
const res = await fetchImpl(url.toString(), { headers: { Accept: 'application/json' } })
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
throw new Error(`web3 nonce: HTTP ${res.status} non-JSON response`)
|
||||
}
|
||||
if (!res.ok || body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) {
|
||||
throw new Error(typeof body.msg === 'string' ? body.msg : `web3 nonce: HTTP ${res.status}`)
|
||||
}
|
||||
const d = body.data as Record<string, unknown>
|
||||
return {
|
||||
domain: String(d.domain ?? ''),
|
||||
uri: String(d.uri ?? ''),
|
||||
statement: typeof d.statement === 'string' ? d.statement : undefined,
|
||||
nonce: String(d.nonce ?? ''),
|
||||
issuedAt: String(d.issuedAt ?? ''),
|
||||
expirationTime: typeof d.expirationTime === 'string' ? d.expirationTime : undefined,
|
||||
version: typeof d.version === 'string' ? d.version : '1',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape the `/v1/iam/web3/verify` response into a {@link LoginResponse}, mirroring
|
||||
* the password flow's `parseLoginResponse`: auth-code flow → a redirect back to
|
||||
* the downstream app; bare sign-in → land on onboarding.
|
||||
*/
|
||||
async function parseVerifyResponse(
|
||||
res: Response,
|
||||
ctx: WalletLoginContext,
|
||||
): Promise<LoginResponse> {
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
const data = body.data
|
||||
|
||||
// Auth-code (SSO) flow: a downstream redirectUri is present and `data` is the
|
||||
// minted code — hand back a fully-formed redirect to the app.
|
||||
if (ctx.redirectUri && typeof data === 'string' && data.length > 0) {
|
||||
const sep = ctx.redirectUri.includes('?') ? '&' : '?'
|
||||
return {
|
||||
redirectUrl: `${ctx.redirectUri}${sep}code=${encodeURIComponent(data)}&state=${encodeURIComponent(ctx.state ?? '')}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Bare portal sign-in: the IAM session cookie is set; land on onboarding —
|
||||
// identical to the password path so there is one post-login destination.
|
||||
return { redirectUrl: '/onboarding' }
|
||||
}
|
||||
|
||||
function errMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message
|
||||
return String(err)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# @luxwallet/connect
|
||||
|
||||
Multi-chain wallet connect + **Sign-In-With-X** for **EVM, Solana, Bitcoin, TON, XRP**.
|
||||
|
||||
One vocabulary, one canonical login message ([CAIP-122](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md)),
|
||||
one verifier. **MIT licensed — zero GPL.** This is the clean wallet stack; the
|
||||
Uniswap-derived GPL bones stay quarantined in `luxfi/exchange`.
|
||||
|
||||
## Why
|
||||
|
||||
`@luxfi/wallet` (Uniswap "Universe" fork) is GPL-3.0 and EVM-only. This package
|
||||
is a from-scratch, permissively-licensed connector that any Hanzo/Lux/Zoo/Pars
|
||||
surface — `hanzo.id` login, the browser extension, web and mobile apps — can use
|
||||
to authenticate a wallet on **any** supported chain.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
connect(chain) ─► Account ─► signLogin(challenge) ─► SignedProof ─► verifyProof()
|
||||
(browser, per-chain connector) (server, one pure fn)
|
||||
```
|
||||
|
||||
- **`caip122.ts`** — render/parse the canonical login message. `build ∘ parse` round-trips.
|
||||
- **`verify.ts`** — `verifyProof(proof, expected)`: parse → enforce domain/nonce/time → dispatch to the per-chain crypto verifier. Pure, fails closed, never throws.
|
||||
- **`<chain>/`** — per-chain connector (browser) + verifier (pure crypto).
|
||||
- **`go/walletconnect`** — Go port of `verifyProof`, imported by Hanzo IAM so the server verifies identically.
|
||||
|
||||
## Chain support
|
||||
|
||||
| Chain | Connect lib (license) | Login proof | Connector | Verifier |
|
||||
|-------|-----------------------|-------------|-----------|----------|
|
||||
| EVM | `viem` (MIT) — EIP-6963 / `window.ethereum` | EIP-191 `personal_sign` | ✅ `evm/connect.ts` | ✅ secp256k1 recover |
|
||||
| Solana | injected provider (Phantom/Solflare/Backpack) | ed25519 `signMessage` | ✅ `solana/connect.ts` | ✅ ed25519 |
|
||||
| Bitcoin | `sats-connect` (MIT) — Xverse/Leather/Unisat | BIP-322 | ✅ `bitcoin/connect.ts` | ✅ legacy + BIP-322 |
|
||||
| TON | `@tonconnect/sdk` (Apache-2.0) | `ton_proof` | ✅ `ton/connect.ts` | ✅ ed25519 envelope |
|
||||
| XRP | `@crossmarkio/sdk` (MIT) — Crossmark | `signInAndWait` | ✅ `xrp/connect.ts` | ✅ secp256k1 + ed25519 |
|
||||
|
||||
All connect libs are MIT/Apache/ISC — **no GPL anywhere** in the dependency tree.
|
||||
GemWallet is intentionally not wired: its only client, `@gemwallet/api`, ships
|
||||
under a custom dual license requiring GemWallet's permission for public/commercial
|
||||
use — incompatible with the MIT/Apache/ISC-only rule. Crossmark covers both XRPL
|
||||
key types, so the XRP path is complete without it.
|
||||
|
||||
### Architecture: server verify never pulls a wallet lib
|
||||
|
||||
The wallet libraries are **optional peer dependencies**. The server-side
|
||||
`verifyProof` path imports only `@noble/*` + `bs58`:
|
||||
|
||||
```ts
|
||||
import { verifyProof } from '@luxwallet/connect/verify'; // zero wallet libs
|
||||
import { buildSiwxMessage } from '@luxwallet/connect/caip122'; // zero deps
|
||||
```
|
||||
|
||||
Connectors live behind separate entrypoints, so a server bundle stays clean:
|
||||
|
||||
```ts
|
||||
import { loginWithWallet, getConnector } from '@luxwallet/connect/connectors';
|
||||
import { EvmConnector } from '@luxwallet/connect/evm/connect';
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
```ts
|
||||
// Server: mint a challenge
|
||||
import { newChallenge, verifyProof } from '@luxwallet/connect';
|
||||
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login' });
|
||||
// → store challenge.nonce, send challenge to the client
|
||||
|
||||
// Server: verify what comes back
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: challenge.nonce });
|
||||
if (res.ok) { /* res.address is authenticated on res.chain */ }
|
||||
```
|
||||
|
||||
```ts
|
||||
// Client (browser): connect a wallet and sign the challenge in one call.
|
||||
import { loginWithWallet } from '@luxwallet/connect/connectors';
|
||||
|
||||
const { account, proof } = await loginWithWallet({ chain: 'evm', challenge });
|
||||
// → POST `proof` to the server, which calls verifyProof(proof, { domain, nonce }).
|
||||
|
||||
// Or drive a connector directly:
|
||||
import { getConnector } from '@luxwallet/connect/connectors';
|
||||
const c = getConnector('solana');
|
||||
const acct = await c.connect(); // provider.connect()
|
||||
const p = await c.signLogin(acct, challenge); // ed25519 signMessage → SignedProof
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm test # vitest — crypto verifiers run against generated keypairs
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT © Lux Industries Inc.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@hanzo/id-connect",
|
||||
"version": "0.1.0",
|
||||
"description": "Multi-chain wallet connect + Sign-In-With-X (EVM, Solana, Bitcoin, TON, XRP). Vendored from luxwallet/connect (MIT).",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./verify": "./src/verify.ts",
|
||||
"./caip122": "./src/caip122.ts",
|
||||
"./connectors": "./src/connectors.ts",
|
||||
"./login": "./src/login.ts",
|
||||
"./evm/connect": "./src/evm/connect.ts",
|
||||
"./solana/connect": "./src/solana/connect.ts",
|
||||
"./bitcoin/connect": "./src/bitcoin/connect.ts",
|
||||
"./ton/connect": "./src/ton/connect.ts",
|
||||
"./xrp/connect": "./src/xrp/connect.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.6.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"bs58": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@crossmarkio/sdk": "^0.4.0",
|
||||
"@tonconnect/sdk": "^4.0.0",
|
||||
"sats-connect": "^4.2.1",
|
||||
"viem": "^2.53.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Bitcoin verifier tests.
|
||||
*
|
||||
* Coverage:
|
||||
* • Legacy "Bitcoin Signed Message" (recoverable ECDSA) over a real CAIP-122
|
||||
* login message — for P2PKH, P2WPKH and P2TR (BIP-86) addresses.
|
||||
* • BIP-322 "simple" for P2WPKH (ECDSA / BIP-143) and P2TR key-path
|
||||
* (Schnorr / BIP-341).
|
||||
* • Tamper / wrong-address / wrong-type negatives (fail closed).
|
||||
* • Anchors: the BIP-322 message hash + to_spend txid + P2WPKH derivation
|
||||
* are pinned to the official Bitcoin Core BIP-322 test vectors, so the
|
||||
* sighash construction is verified against a known-answer source rather
|
||||
* than only self-consistently.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { secp256k1, schnorr } from '@noble/curves/secp256k1';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { ripemd160 } from '@noble/hashes/legacy';
|
||||
import { verifyBitcoin } from '../bitcoin/verify.js';
|
||||
import { encodeSegwitAddress } from '../bitcoin/bech32.js';
|
||||
import { base58checkEncode } from '../bitcoin/base58check.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { utf8ToBytes, concatBytes, bytesToHex } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// ── local crypto helpers (independent of the verifier internals) ─────────────
|
||||
|
||||
const enc = (s: string) => utf8ToBytes(s);
|
||||
const sha256d = (b: Uint8Array) => sha256(sha256(b));
|
||||
const hash160 = (b: Uint8Array) => ripemd160(sha256(b));
|
||||
|
||||
function taggedHash(tag: string, ...m: Uint8Array[]): Uint8Array {
|
||||
const t = sha256(enc(tag));
|
||||
return sha256(concatBytes(t, t, ...m));
|
||||
}
|
||||
|
||||
function compactSize(n: number): Uint8Array {
|
||||
if (n < 0xfd) return new Uint8Array([n]);
|
||||
if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >>> 8) & 0xff]);
|
||||
return new Uint8Array([0xfe, n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
}
|
||||
const u32le = (n: number) =>
|
||||
new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
const u64le = (n: bigint) => {
|
||||
const o = new Uint8Array(8);
|
||||
let v = n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
o[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return o;
|
||||
};
|
||||
const varBytes = (b: Uint8Array) => concatBytes(compactSize(b.length), b);
|
||||
|
||||
function bytesToBig(b: Uint8Array): bigint {
|
||||
let v = 0n;
|
||||
for (const x of b) v = (v << 8n) | BigInt(x);
|
||||
return v;
|
||||
}
|
||||
function bigToXonly(x: bigint): Uint8Array {
|
||||
const o = new Uint8Array(32);
|
||||
let v = x;
|
||||
for (let i = 31; i >= 0; i--) {
|
||||
o[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function toBase64(b: Uint8Array): string {
|
||||
return Buffer.from(b).toString('base64');
|
||||
}
|
||||
|
||||
// Address derivations (must match the verifier's, independently written).
|
||||
function p2pkh(pubkey: Uint8Array): string {
|
||||
return base58checkEncode(0x00, hash160(pubkey));
|
||||
}
|
||||
function p2wpkh(pubCompressed: Uint8Array): string {
|
||||
return encodeSegwitAddress('bc', 0, hash160(pubCompressed))!;
|
||||
}
|
||||
function taprootTweak(internalXonly: Uint8Array): Uint8Array {
|
||||
const n = secp256k1.CURVE.n;
|
||||
const P = schnorr.utils.lift_x(bytesToBig(internalXonly));
|
||||
const t = bytesToBig(taggedHash('TapTweak', internalXonly)) % n;
|
||||
const Q = P.add(secp256k1.Point.BASE.multiply(t));
|
||||
return bigToXonly(Q.toAffine().x);
|
||||
}
|
||||
function p2tr(internalXonly: Uint8Array): { address: string; program: Uint8Array } {
|
||||
const program = taprootTweak(internalXonly);
|
||||
return { address: encodeSegwitAddress('bc', 1, program)!, program };
|
||||
}
|
||||
|
||||
// ── legacy "Bitcoin Signed Message" signer ───────────────────────────────────
|
||||
|
||||
function legacyDigest(message: string): Uint8Array {
|
||||
const msg = enc(message);
|
||||
const magic = enc('\x18Bitcoin Signed Message:\n');
|
||||
return sha256d(concatBytes(magic, compactSize(msg.length), msg));
|
||||
}
|
||||
|
||||
/** Produce a 65-byte [header || r || s] legacy signature. */
|
||||
function signLegacy(priv: Uint8Array, message: string, compressed: boolean): Uint8Array {
|
||||
const digest = legacyDigest(message);
|
||||
const sig = secp256k1.sign(digest, priv);
|
||||
const recid = sig.recovery!;
|
||||
const header = 27 + recid + (compressed ? 4 : 0);
|
||||
return concatBytes(new Uint8Array([header]), sig.toBytes('compact'));
|
||||
}
|
||||
|
||||
// ── BIP-322 simple signers (sign exactly the verifier's sighash) ─────────────
|
||||
|
||||
function toSpendTxid(message: string, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const msgHash = taggedHash('BIP0322-signed-message', enc(message));
|
||||
const scriptSig = concatBytes(new Uint8Array([0x00, 0x20]), msgHash);
|
||||
const ser = concatBytes(
|
||||
u32le(0),
|
||||
compactSize(1),
|
||||
new Uint8Array(32),
|
||||
u32le(0xffffffff),
|
||||
varBytes(scriptSig),
|
||||
u32le(0),
|
||||
compactSize(1),
|
||||
u64le(0n),
|
||||
varBytes(scriptPubKey),
|
||||
u32le(0),
|
||||
);
|
||||
return sha256d(ser);
|
||||
}
|
||||
|
||||
function bip143SighashP2WPKH(txid: Uint8Array, h160: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(txid, u32le(0));
|
||||
const nSequence = u32le(0);
|
||||
const hashPrevouts = sha256d(outpoint);
|
||||
const hashSequence = sha256d(nSequence);
|
||||
const scriptCode = concatBytes(
|
||||
new Uint8Array([0x19, 0x76, 0xa9, 0x14]),
|
||||
h160,
|
||||
new Uint8Array([0x88, 0xac]),
|
||||
);
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
|
||||
const hashOutputs = sha256d(output);
|
||||
const preimage = concatBytes(
|
||||
u32le(0),
|
||||
hashPrevouts,
|
||||
hashSequence,
|
||||
outpoint,
|
||||
scriptCode,
|
||||
u64le(0n),
|
||||
nSequence,
|
||||
hashOutputs,
|
||||
u32le(0),
|
||||
u32le(1),
|
||||
);
|
||||
return sha256d(preimage);
|
||||
}
|
||||
|
||||
function bip341SighashP2TR(txid: Uint8Array, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(txid, u32le(0));
|
||||
const nSequence = u32le(0);
|
||||
const shaPrevouts = sha256(outpoint);
|
||||
const shaAmounts = sha256(u64le(0n));
|
||||
const shaScriptPubkeys = sha256(varBytes(scriptPubKey));
|
||||
const shaSequences = sha256(nSequence);
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
|
||||
const shaOutputs = sha256(output);
|
||||
const sigMsg = concatBytes(
|
||||
new Uint8Array([0x00]), // hash_type SIGHASH_DEFAULT
|
||||
u32le(0),
|
||||
u32le(0),
|
||||
shaPrevouts,
|
||||
shaAmounts,
|
||||
shaScriptPubkeys,
|
||||
shaSequences,
|
||||
shaOutputs,
|
||||
new Uint8Array([0x00]), // spend_type
|
||||
u32le(0), // input index
|
||||
);
|
||||
return taggedHash('TapSighash', concatBytes(new Uint8Array([0x00]), sigMsg));
|
||||
}
|
||||
|
||||
function serializeWitness(items: Uint8Array[]): Uint8Array {
|
||||
let out = compactSize(items.length);
|
||||
for (const it of items) out = concatBytes(out, varBytes(it));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** BIP-322 simple P2WPKH signature (serialized witness [sig||SIGHASH_ALL, pubkey]). */
|
||||
function signBip322P2WPKH(priv: Uint8Array, message: string): Uint8Array {
|
||||
const pub = secp256k1.getPublicKey(priv, true);
|
||||
const h160 = hash160(pub);
|
||||
const spk = concatBytes(new Uint8Array([0x00, 0x14]), h160);
|
||||
const txid = toSpendTxid(message, spk);
|
||||
const sighash = bip143SighashP2WPKH(txid, h160);
|
||||
const sig = secp256k1.sign(sighash, priv, { lowS: true });
|
||||
const der = concatBytes(sig.toBytes('der'), new Uint8Array([0x01])); // SIGHASH_ALL
|
||||
return serializeWitness([der, pub]);
|
||||
}
|
||||
|
||||
/** BIP-322 simple P2TR key-path signature (serialized witness [schnorr_sig]). */
|
||||
function signBip322P2TR(priv: Uint8Array, message: string): { sig: Uint8Array; address: string } {
|
||||
const internalXonly = secp256k1.getPublicKey(priv, true).slice(1);
|
||||
const { address, program } = p2tr(internalXonly);
|
||||
const spk = concatBytes(new Uint8Array([0x51, 0x20]), program);
|
||||
const txid = toSpendTxid(message, spk);
|
||||
const sighash = bip341SighashP2TR(txid, spk);
|
||||
// Taproot key-path must sign with the *tweaked* private key.
|
||||
const n = secp256k1.CURVE.n;
|
||||
let d = bytesToBig(priv) % n;
|
||||
// BIP-340: if the internal pubkey has odd Y, negate d.
|
||||
const Pfull = secp256k1.Point.BASE.multiply(d);
|
||||
if (Pfull.toAffine().y % 2n === 1n) d = n - d;
|
||||
const t = bytesToBig(taggedHash('TapTweak', internalXonly)) % n;
|
||||
const tweaked = (d + t) % n;
|
||||
const tweakedBytes = bigToXonly(tweaked);
|
||||
const sig = schnorr.sign(sighash, tweakedBytes);
|
||||
return { sig: serializeWitness([sig]), address };
|
||||
}
|
||||
|
||||
// ── fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMessage(address: string): string {
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
statement: 'Sign in to Hanzo',
|
||||
nonce: 'abc123XYZ789',
|
||||
now: Date.UTC(2026, 0, 1),
|
||||
});
|
||||
return buildSiwxMessage({ challenge, address, chain: 'bitcoin' });
|
||||
}
|
||||
|
||||
// A fixed key so failures are reproducible.
|
||||
const PRIV = new Uint8Array(32).fill(0);
|
||||
PRIV[31] = 0x2a; // d = 42
|
||||
|
||||
describe('verifyBitcoin — anchors against Bitcoin Core BIP-322 vectors', () => {
|
||||
it('message_hash + to_spend txid match the official vectors', () => {
|
||||
const addr = 'bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l';
|
||||
// Reconstruct that address's witness program from the known WIF private key.
|
||||
// WIF L3VFe…: 0x80 || priv(32) || 0x01 || checksum(4) → priv extracted below.
|
||||
const wifPriv = hexToBytesLocal(
|
||||
'bb051cd0dda0246f33c5a9e133ebd8e7bc02a92af6c41adc131ccd7826c5b004',
|
||||
);
|
||||
const pub = secp256k1.getPublicKey(wifPriv, true);
|
||||
expect(p2wpkh(pub)).toBe(addr); // P2WPKH derivation anchor
|
||||
|
||||
expect(bytesToHex(taggedHash('BIP0322-signed-message', enc('')))).toBe(
|
||||
'c90c269c4f8fcbe6880f72a721ddfbf1914268a794cbb21cfafee13770ae19f1',
|
||||
);
|
||||
expect(bytesToHex(taggedHash('BIP0322-signed-message', enc('Hello World')))).toBe(
|
||||
'f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a',
|
||||
);
|
||||
const spk = concatBytes(new Uint8Array([0x00, 0x14]), hash160(pub));
|
||||
const display = (b: Uint8Array) => bytesToHex(Uint8Array.from([...b].reverse()));
|
||||
expect(display(toSpendTxid('', spk))).toBe(
|
||||
'c5680aa69bb8d860bf82d4e9cd3504b55dde018de765a91bb566283c545a99a7',
|
||||
);
|
||||
expect(display(toSpendTxid('Hello World', spk))).toBe(
|
||||
'b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function hexToBytesLocal(hex: string): Uint8Array {
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('verifyBitcoin — legacy Bitcoin Signed Message', () => {
|
||||
const pubC = secp256k1.getPublicKey(PRIV, true);
|
||||
const pubU = secp256k1.getPublicKey(PRIV, false);
|
||||
|
||||
it('P2PKH (compressed) verifies, tamper + wrong address fail', () => {
|
||||
const address = p2pkh(pubC);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, true);
|
||||
const proof: SignedProof = {
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
};
|
||||
expect(verifyBitcoin(proof)).toBe(true);
|
||||
|
||||
// Tamper the signature (flip a byte in r).
|
||||
const bad = sig.slice();
|
||||
bad[5] = bad[5]! ^ 0xff;
|
||||
expect(verifyBitcoin({ ...proof, signature: toBase64(bad) })).toBe(false);
|
||||
|
||||
// Tamper the message.
|
||||
expect(verifyBitcoin({ ...proof, message: message + ' ' })).toBe(false);
|
||||
|
||||
// Wrong address (different key's P2PKH).
|
||||
const other = secp256k1.getPublicKey(hexToBytesLocal('11'.repeat(32)), true);
|
||||
expect(verifyBitcoin({ ...proof, address: p2pkh(other) })).toBe(false);
|
||||
});
|
||||
|
||||
it('P2PKH (uncompressed) verifies and is key-encoding-bound', () => {
|
||||
const address = p2pkh(pubU);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, false);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
|
||||
).toBe(true);
|
||||
|
||||
// The compressed-key address must NOT verify against an uncompressed-header sig.
|
||||
const cAddr = p2pkh(pubC);
|
||||
const cMsg = makeMessage(cAddr);
|
||||
const uncompSig = signLegacy(PRIV, cMsg, false);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address: cAddr, message: cMsg, signature: toBase64(uncompSig) }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('P2WPKH verifies via legacy recoverable sig, rejects uncompressed header', () => {
|
||||
const address = p2wpkh(pubC);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, true);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
|
||||
).toBe(true);
|
||||
|
||||
// Uncompressed header can't back a segwit address → reject.
|
||||
const uncompSig = signLegacy(PRIV, message, false);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(uncompSig) }),
|
||||
).toBe(false);
|
||||
|
||||
// A P2WPKH address from a DIFFERENT key must not verify against this sig.
|
||||
const otherP2wpkh = p2wpkh(secp256k1.getPublicKey(hexToBytesLocal('05'.repeat(32)), true));
|
||||
expect(
|
||||
verifyBitcoin({
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address: otherP2wpkh,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('P2TR (BIP-86) verifies via legacy recoverable sig', () => {
|
||||
const internalXonly = pubC.slice(1);
|
||||
const { address } = p2tr(internalXonly);
|
||||
const message = makeMessage(address);
|
||||
const sig = signLegacy(PRIV, message, true);
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(sig) }),
|
||||
).toBe(true);
|
||||
|
||||
// Tamper → false.
|
||||
const bad = sig.slice();
|
||||
bad[40] = bad[40]! ^ 0x01;
|
||||
expect(
|
||||
verifyBitcoin({ chain: 'bitcoin', scheme: 'bip322', address, message, signature: toBase64(bad) }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBitcoin — BIP-322 simple', () => {
|
||||
it('P2WPKH (BIP-143 / ECDSA) verifies, tamper + wrong address fail', () => {
|
||||
const pub = secp256k1.getPublicKey(PRIV, true);
|
||||
const address = p2wpkh(pub);
|
||||
const message = makeMessage(address);
|
||||
const sig = signBip322P2WPKH(PRIV, message);
|
||||
const proof: SignedProof = {
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
extra: { addressType: 'p2wpkh' },
|
||||
};
|
||||
expect(verifyBitcoin(proof)).toBe(true);
|
||||
|
||||
// Tamper the message → sighash changes → false.
|
||||
expect(verifyBitcoin({ ...proof, message: message + 'x' })).toBe(false);
|
||||
|
||||
// Wrong address → witness pubkey no longer hashes to it → false.
|
||||
const other = p2wpkh(secp256k1.getPublicKey(hexToBytesLocal('07'.repeat(32)), true));
|
||||
expect(verifyBitcoin({ ...proof, address: other })).toBe(false);
|
||||
|
||||
// Truncated witness → false.
|
||||
expect(verifyBitcoin({ ...proof, signature: toBase64(sig.slice(0, sig.length - 3)) })).toBe(false);
|
||||
});
|
||||
|
||||
it('P2TR key-path (BIP-341 / Schnorr) verifies, tamper fails', () => {
|
||||
const message0 = 'placeholder';
|
||||
const { address } = signBip322P2TR(PRIV, message0); // get the address first
|
||||
const message = makeMessage(address);
|
||||
const { sig } = signBip322P2TR(PRIV, message);
|
||||
const proof: SignedProof = {
|
||||
chain: 'bitcoin',
|
||||
scheme: 'bip322',
|
||||
address,
|
||||
message,
|
||||
signature: toBase64(sig),
|
||||
extra: { addressType: 'p2tr' },
|
||||
};
|
||||
expect(verifyBitcoin(proof)).toBe(true);
|
||||
|
||||
// Tamper the message → false.
|
||||
expect(verifyBitcoin({ ...proof, message: message + 'z' })).toBe(false);
|
||||
|
||||
// Flip a byte in the schnorr sig → false.
|
||||
const bad = sig.slice();
|
||||
bad[bad.length - 1] = bad[bad.length - 1]! ^ 0x01;
|
||||
expect(verifyBitcoin({ ...proof, signature: toBase64(bad) })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBitcoin — malformed input fails closed', () => {
|
||||
const address = p2wpkh(secp256k1.getPublicKey(PRIV, true));
|
||||
const message = makeMessage(address);
|
||||
const base: SignedProof = { chain: 'bitcoin', scheme: 'bip322', address, message, signature: '' };
|
||||
|
||||
it('empty signature → false', () => {
|
||||
expect(verifyBitcoin(base)).toBe(false);
|
||||
});
|
||||
it('garbage base64 → false', () => {
|
||||
expect(verifyBitcoin({ ...base, signature: '!!!notbase64!!!' })).toBe(false);
|
||||
});
|
||||
it('unknown address prefix → false', () => {
|
||||
expect(verifyBitcoin({ ...base, address: '3unsupportedP2SHaddress', signature: 'AQID' })).toBe(false);
|
||||
});
|
||||
it('legacy sig with out-of-range header → false', () => {
|
||||
const sig = new Uint8Array(65);
|
||||
sig[0] = 99; // invalid header
|
||||
expect(verifyBitcoin({ ...base, signature: toBase64(sig) })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSiwxMessage, parseSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
|
||||
describe('CAIP-122 message', () => {
|
||||
const now = 1_700_000_000_000; // fixed epoch for determinism
|
||||
|
||||
it('build → parse round-trips all fields', () => {
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
statement: 'Sign in to Hanzo.',
|
||||
nonce: 'abc12345',
|
||||
now,
|
||||
ttlSeconds: 600,
|
||||
requestId: 'req-1',
|
||||
resources: ['https://hanzo.ai/api', 'https://hanzo.chat'],
|
||||
});
|
||||
const msg = buildSiwxMessage({
|
||||
challenge,
|
||||
address: '0x1111111111111111111111111111111111111111',
|
||||
chain: 'evm',
|
||||
chainId: 'eip155:1',
|
||||
});
|
||||
const p = parseSiwxMessage(msg);
|
||||
expect(p.domain).toBe('hanzo.id');
|
||||
expect(p.address).toBe('0x1111111111111111111111111111111111111111');
|
||||
expect(p.statement).toBe('Sign in to Hanzo.');
|
||||
expect(p.uri).toBe('https://hanzo.id/login');
|
||||
expect(p.version).toBe('1');
|
||||
expect(p.chainId).toBe('eip155:1');
|
||||
expect(p.nonce).toBe('abc12345');
|
||||
expect(p.issuedAt).toBe(new Date(now).toISOString());
|
||||
expect(p.expirationTime).toBe(new Date(now + 600_000).toISOString());
|
||||
expect(p.requestId).toBe('req-1');
|
||||
expect(p.resources).toEqual(['https://hanzo.ai/api', 'https://hanzo.chat']);
|
||||
});
|
||||
|
||||
it('renders the chain label on the header line', () => {
|
||||
const c = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id', nonce: 'nonce123', now });
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'So1aNa', chain: 'solana' })).toContain(
|
||||
'wants you to sign in with your Solana account:',
|
||||
);
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'bc1q', chain: 'bitcoin' })).toContain(
|
||||
'with your Bitcoin account:',
|
||||
);
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'EQxx', chain: 'ton' })).toContain(
|
||||
'with your TON account:',
|
||||
);
|
||||
expect(buildSiwxMessage({ challenge: c, address: 'rXYZ', chain: 'xrp' })).toContain(
|
||||
'with your XRP Ledger account:',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits optional fields when absent', () => {
|
||||
const c = newChallenge({ domain: 'd', uri: 'https://d', nonce: 'nonce123', now });
|
||||
const msg = buildSiwxMessage({ challenge: { ...c, expirationTime: undefined }, address: 'a', chain: 'evm' });
|
||||
expect(msg).not.toContain('Chain ID:');
|
||||
expect(msg).not.toContain('Request ID:');
|
||||
expect(msg).not.toContain('Resources:');
|
||||
});
|
||||
|
||||
it('rejects a multi-line statement', () => {
|
||||
const c = newChallenge({ domain: 'd', uri: 'https://d', nonce: 'nonce123', now, statement: 'a\nb' });
|
||||
expect(() => buildSiwxMessage({ challenge: c, address: 'a', chain: 'evm' })).toThrow();
|
||||
});
|
||||
|
||||
it('throws on malformed message', () => {
|
||||
expect(() => parseSiwxMessage('not a siwx message')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Connector tests.
|
||||
*
|
||||
* Two things are exercised without a real wallet or browser:
|
||||
* 1. getConnector(chain) returns a connector whose `.chain` matches.
|
||||
* 2. EVM + Solana round-trip: a MOCKED injected provider (backed by a real
|
||||
* keypair) signs the CAIP-122 message via the connector's signLogin, and
|
||||
* the resulting SignedProof passes the server-side verifyProof.
|
||||
*
|
||||
* The other connectors (Bitcoin/TON/XRP) drive third-party SDKs whose wallet
|
||||
* handshakes cannot be faithfully mocked headlessly; they are covered by their
|
||||
* verifiers' round-trip tests and need a real wallet to exercise end-to-end.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import bs58 from 'bs58';
|
||||
import { getConnector, allConnectors } from '../connectors.js';
|
||||
import { EvmConnector } from '../evm/connect.js';
|
||||
import { SolanaConnector } from '../solana/connect.js';
|
||||
import { verifyProof } from '../verify.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { CHAINS, type Chain } from '../types.js';
|
||||
import {
|
||||
eip191Digest,
|
||||
addressFromPublicKey,
|
||||
recoverEvmAddress,
|
||||
} from '../evm/verify.js';
|
||||
import { bytesToHex, utf8ToBytes, hexToBytes } from '../bytes.js';
|
||||
|
||||
// ── factory ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getConnector', () => {
|
||||
it('returns a connector whose chain matches, for every chain', () => {
|
||||
for (const chain of CHAINS) {
|
||||
const c = getConnector(chain);
|
||||
expect(c.chain).toBe(chain);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds the right class per chain', () => {
|
||||
expect(getConnector('evm')).toBeInstanceOf(EvmConnector);
|
||||
expect(getConnector('solana')).toBeInstanceOf(SolanaConnector);
|
||||
});
|
||||
|
||||
it('allConnectors() yields one connector per chain, in canonical order', () => {
|
||||
const all = allConnectors();
|
||||
expect(all.map((c) => c.chain)).toEqual(CHAINS as Chain[]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── shared window shim ───────────────────────────────────────────────────────
|
||||
// A bare window without addEventListener/dispatchEvent so the EVM connector's
|
||||
// EIP-6963 discovery short-circuits to the legacy window.ethereum path (fast,
|
||||
// deterministic — no 300ms announce wait).
|
||||
|
||||
const realWindow = (globalThis as Record<string, unknown>).window;
|
||||
|
||||
function setWindow(props: Record<string, unknown>): void {
|
||||
(globalThis as Record<string, unknown>).window = props;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (realWindow === undefined) {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
} else {
|
||||
(globalThis as Record<string, unknown>).window = realWindow;
|
||||
}
|
||||
});
|
||||
|
||||
// ── EVM mock provider (EIP-1193, real secp256k1 key) ─────────────────────────
|
||||
|
||||
function makeEvmProvider() {
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const pub = secp256k1.getPublicKey(priv, false);
|
||||
const address = addressFromPublicKey(pub); // lowercased 0x…
|
||||
|
||||
const provider = {
|
||||
async request({ method, params }: { method: string; params?: unknown[] }): Promise<unknown> {
|
||||
switch (method) {
|
||||
case 'eth_requestAccounts':
|
||||
case 'eth_accounts':
|
||||
return [address];
|
||||
case 'eth_chainId':
|
||||
return '0x1';
|
||||
case 'personal_sign': {
|
||||
// viem sends [data, account]; data is the 0x-hex of the UTF-8 message.
|
||||
const dataHex = params?.[0] as string;
|
||||
const msgBytes = hexToBytes(dataHex);
|
||||
const message = new TextDecoder().decode(msgBytes);
|
||||
const sig = secp256k1.sign(eip191Digest(message), priv);
|
||||
const full = new Uint8Array(65);
|
||||
full.set(sig.toCompactRawBytes(), 0);
|
||||
full[64] = (sig.recovery ?? 0) + 27;
|
||||
return '0x' + bytesToHex(full);
|
||||
}
|
||||
default:
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
return { provider, address };
|
||||
}
|
||||
|
||||
describe('EvmConnector round-trip (mocked injected wallet)', () => {
|
||||
beforeEach(() => {
|
||||
const { provider } = makeEvmProvider();
|
||||
setWindow({ ethereum: provider });
|
||||
});
|
||||
|
||||
it('connects, signs the CAIP-122 message, and verifyProof accepts it', async () => {
|
||||
const c = new EvmConnector();
|
||||
const account = await c.connect();
|
||||
expect(account.chain).toBe('evm');
|
||||
expect(account.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
||||
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'evmNonce123',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
|
||||
expect(proof.scheme).toBe('secp256k1-eip191');
|
||||
expect(proof.chain).toBe('evm');
|
||||
// The signature recovers the connected address (the verifier's core check).
|
||||
expect(recoverEvmAddress(proof.message, proof.signature)?.toLowerCase()).toBe(
|
||||
account.address.toLowerCase(),
|
||||
);
|
||||
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'evmNonce123', now });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.address?.toLowerCase()).toBe(account.address.toLowerCase());
|
||||
expect(res.chain).toBe('evm');
|
||||
});
|
||||
|
||||
it('available() discovers the injected wallet via the legacy path', async () => {
|
||||
const c = new EvmConnector();
|
||||
const wallets = await c.available();
|
||||
expect(wallets.length).toBe(1);
|
||||
expect(wallets[0]?.chain).toBe('evm');
|
||||
expect(wallets[0]?.installed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Solana mock provider (real ed25519 key) ──────────────────────────────────
|
||||
|
||||
function makeSolanaProvider() {
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const address = bs58.encode(pub);
|
||||
|
||||
const publicKey = {
|
||||
toBytes: () => pub,
|
||||
toString: () => address,
|
||||
};
|
||||
const provider = {
|
||||
isPhantom: true,
|
||||
publicKey,
|
||||
async connect() {
|
||||
return { publicKey };
|
||||
},
|
||||
async signMessage(message: Uint8Array, _encoding?: string) {
|
||||
return { signature: ed25519.sign(message, priv) };
|
||||
},
|
||||
async disconnect() {},
|
||||
};
|
||||
return { provider, address };
|
||||
}
|
||||
|
||||
describe('SolanaConnector round-trip (mocked injected wallet)', () => {
|
||||
let expectedAddress: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const { provider, address } = makeSolanaProvider();
|
||||
expectedAddress = address;
|
||||
setWindow({ solana: provider });
|
||||
});
|
||||
|
||||
it('connects, signs the CAIP-122 message, and verifyProof accepts it', async () => {
|
||||
const c = new SolanaConnector();
|
||||
const account = await c.connect();
|
||||
expect(account.chain).toBe('solana');
|
||||
expect(account.address).toBe(expectedAddress);
|
||||
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'solNonce4567',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
|
||||
expect(proof.scheme).toBe('ed25519');
|
||||
expect(proof.chain).toBe('solana');
|
||||
expect(proof.address).toBe(expectedAddress);
|
||||
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'solNonce4567', now });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.address).toBe(expectedAddress);
|
||||
expect(res.chain).toBe('solana');
|
||||
});
|
||||
|
||||
it('handles the bare-Uint8Array signMessage return shape', async () => {
|
||||
// Some wallets return the raw signature bytes instead of {signature}.
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const address = bs58.encode(pub);
|
||||
const publicKey = { toBytes: () => pub, toString: () => address };
|
||||
setWindow({
|
||||
solana: {
|
||||
isPhantom: true,
|
||||
publicKey,
|
||||
connect: async () => ({ publicKey }),
|
||||
signMessage: async (m: Uint8Array) => ed25519.sign(m, priv),
|
||||
},
|
||||
});
|
||||
|
||||
const c = new SolanaConnector();
|
||||
const account = await c.connect();
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'solBareSig99',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'solBareSig99', now }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a proof whose nonce was tampered after signing', async () => {
|
||||
const c = new SolanaConnector();
|
||||
const account = await c.connect();
|
||||
const now = 1_700_000_000_000;
|
||||
const challenge = newChallenge({
|
||||
domain: 'hanzo.id',
|
||||
uri: 'https://hanzo.id/login',
|
||||
nonce: 'solGood0001',
|
||||
now,
|
||||
});
|
||||
const proof = await c.signLogin(account, challenge);
|
||||
// Server expects a different nonce → rejected before crypto.
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'solOther0002', now }).reason).toBe(
|
||||
'nonce-mismatch',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── browser-only guard ───────────────────────────────────────────────────────
|
||||
|
||||
describe('connectors are browser-only', () => {
|
||||
it('EVM connect throws without a window', async () => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
await expect(new EvmConnector().connect()).rejects.toThrow(/browser-only/);
|
||||
});
|
||||
|
||||
it('Solana connect throws without a window', async () => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
await expect(new SolanaConnector().connect()).rejects.toThrow(/browser-only/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { verifyTon } from '../ton/verify.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { bytesToHex, bytesToBase64, utf8ToBytes, concatBytes } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// --- Reproduce the TON Connect ton_proof signing algorithm (the wallet side). ---
|
||||
// This MUST mirror src/ton/verify.ts byte-for-byte; if they ever drift, the
|
||||
// round-trip "accepts a valid proof" test fails — which is the whole point.
|
||||
|
||||
interface ProofEnvelope {
|
||||
timestamp: number;
|
||||
domain: string;
|
||||
payload: string;
|
||||
workchain: number;
|
||||
addressHashHex: string;
|
||||
}
|
||||
|
||||
function tonProofDigest(env: ProofEnvelope): Uint8Array {
|
||||
const addressHash = hexFix(env.addressHashHex);
|
||||
|
||||
const wc = new Uint8Array(4);
|
||||
new DataView(wc.buffer).setInt32(0, env.workchain, false); // big-endian, signed
|
||||
|
||||
const domainBytes = utf8ToBytes(env.domain);
|
||||
const dlen = new Uint8Array(4);
|
||||
new DataView(dlen.buffer).setUint32(0, domainBytes.length, true); // little-endian
|
||||
|
||||
const ts = new Uint8Array(8);
|
||||
new DataView(ts.buffer).setBigUint64(0, BigInt(env.timestamp), true); // little-endian
|
||||
|
||||
const message = concatBytes(
|
||||
utf8ToBytes('ton-proof-item-v2/'),
|
||||
wc,
|
||||
addressHash,
|
||||
dlen,
|
||||
domainBytes,
|
||||
ts,
|
||||
utf8ToBytes(env.payload),
|
||||
);
|
||||
|
||||
const fullMsg = concatBytes(Uint8Array.of(0xff, 0xff), utf8ToBytes('ton-connect'), sha256(message));
|
||||
return sha256(fullMsg);
|
||||
}
|
||||
|
||||
/** Local hex→bytes (no 0x) so the test does not depend on verifier internals. */
|
||||
function hexFix(hex: string): Uint8Array {
|
||||
const h = hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||
const out = new Uint8Array(h.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh, self-consistent TON proof: random ed25519 key, a CAIP-122
|
||||
* message whose Nonce equals the ton_proof payload, and a real signature over
|
||||
* the reconstructed digest.
|
||||
*/
|
||||
function mintProof(overrides?: {
|
||||
workchain?: number;
|
||||
domain?: string;
|
||||
now?: number;
|
||||
}): { proof: SignedProof; env: ProofEnvelope; priv: Uint8Array; pub: Uint8Array } {
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
|
||||
// A TON address-hash (account state-init hash). For the verifier it is just
|
||||
// 32 opaque bytes; use a deterministic-but-arbitrary value here.
|
||||
const addressHash = sha256(pub); // 32 bytes
|
||||
const addressHashHex = bytesToHex(addressHash);
|
||||
|
||||
const workchain = overrides?.workchain ?? 0;
|
||||
const domain = overrides?.domain ?? 'hanzo.id';
|
||||
const now = overrides?.now ?? 1_700_000_000_000;
|
||||
const timestamp = Math.floor(now / 1000);
|
||||
|
||||
// Server mints the nonce; the connector reuses it as the ton_proof payload.
|
||||
const challenge = newChallenge({ domain, uri: `https://${domain}/login`, now });
|
||||
const payload = challenge.nonce;
|
||||
|
||||
// The on-chain "address" we use for binding: friendly form would be base64url,
|
||||
// but for verifier purposes the address must simply equal the SIWx address
|
||||
// line. Use "<workchain>:<addressHashHex>" (raw TON address form).
|
||||
const address = `${workchain}:${addressHashHex}`;
|
||||
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'ton' });
|
||||
|
||||
const env: ProofEnvelope = { timestamp, domain, payload, workchain, addressHashHex };
|
||||
const digest = tonProofDigest(env);
|
||||
const signature = bytesToBase64(ed25519.sign(digest, priv));
|
||||
|
||||
const proof: SignedProof = {
|
||||
chain: 'ton',
|
||||
scheme: 'ton-proof',
|
||||
address,
|
||||
publicKey: bytesToHex(pub),
|
||||
message,
|
||||
signature,
|
||||
extra: { ...env },
|
||||
};
|
||||
return { proof, env, priv, pub };
|
||||
}
|
||||
|
||||
describe('TON ton_proof verify', () => {
|
||||
it('accepts a valid proof (full round-trip)', () => {
|
||||
const { proof } = mintProof();
|
||||
expect(verifyTon(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a valid proof on the masterchain (workchain = -1)', () => {
|
||||
// Exercises signed int32BE encoding: -1 must serialize as 0xFFFFFFFF on
|
||||
// both the signing and verifying sides.
|
||||
const { proof } = mintProof({ workchain: -1 });
|
||||
expect(verifyTon(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered timestamp', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), timestamp: (proof.extra as any).timestamp + 1 },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a tampered domain', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), domain: 'evil.com' },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a tampered payload (signature no longer matches)', () => {
|
||||
const { proof } = mintProof();
|
||||
// Mutate BOTH the SIWx nonce and the envelope payload so the binding check
|
||||
// passes and we isolate the cryptographic rejection.
|
||||
const tamperedPayload = (proof.extra as any).payload + 'X';
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
message: proof.message.replace(/Nonce: .*/, `Nonce: ${tamperedPayload}`),
|
||||
address: proof.address,
|
||||
extra: { ...(proof.extra as object), payload: tamperedPayload },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong public key', () => {
|
||||
const { proof } = mintProof();
|
||||
const otherPub = ed25519.getPublicKey(ed25519.utils.randomPrivateKey());
|
||||
const bad: SignedProof = { ...proof, publicKey: bytesToHex(otherPub) };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a nonce/payload mismatch (binding failure, before crypto)', () => {
|
||||
const { proof } = mintProof();
|
||||
// Envelope payload no longer equals the SIWx Nonce → binding rejects it
|
||||
// even though the (still-valid-for-old-payload) signature is untouched.
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), payload: 'a-different-nonce' },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an address that does not match the SIWx message', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, address: '0:deadbeef' };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed signature (wrong length)', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, signature: bytesToBase64(new Uint8Array(63)) };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed public key (wrong length)', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, publicKey: bytesToHex(new Uint8Array(31)) };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a malformed address hash (wrong length)', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = {
|
||||
...proof,
|
||||
extra: { ...(proof.extra as object), addressHashHex: 'dead' },
|
||||
};
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing envelope', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad: SignedProof = { ...proof, extra: undefined };
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing public key', () => {
|
||||
const { proof } = mintProof();
|
||||
const bad = { ...proof, publicKey: undefined } as SignedProof;
|
||||
expect(verifyTon(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw on garbage input', () => {
|
||||
const garbage = {
|
||||
chain: 'ton',
|
||||
scheme: 'ton-proof',
|
||||
address: 'x',
|
||||
publicKey: 'nothex',
|
||||
message: 'not a siwx message',
|
||||
signature: '!!!!',
|
||||
extra: { timestamp: 'soon', domain: 1, payload: null, workchain: 0.5, addressHashHex: 7 },
|
||||
} as unknown as SignedProof;
|
||||
expect(() => verifyTon(garbage)).not.toThrow();
|
||||
expect(verifyTon(garbage)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import bs58 from 'bs58';
|
||||
import { verifyEvm, eip191Digest, addressFromPublicKey } from '../evm/verify.js';
|
||||
import { verifySolana } from '../solana/verify.js';
|
||||
import { verifyProof } from '../verify.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { bytesToHex, bytesToBase64, utf8ToBytes } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// --- test signers (produce real signatures the verifier must accept) ---
|
||||
|
||||
function evmSign(message: string) {
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const pub = secp256k1.getPublicKey(priv, false);
|
||||
const address = addressFromPublicKey(pub);
|
||||
const sig = secp256k1.sign(eip191Digest(message), priv);
|
||||
const full = new Uint8Array(65);
|
||||
full.set(sig.toCompactRawBytes(), 0);
|
||||
full[64] = (sig.recovery ?? 0) + 27;
|
||||
return { address, signature: '0x' + bytesToHex(full) };
|
||||
}
|
||||
|
||||
function solanaSign(message: string) {
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
const address = bs58.encode(pub);
|
||||
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
|
||||
return { address, signature };
|
||||
}
|
||||
|
||||
describe('EVM EIP-191 verify', () => {
|
||||
it('accepts a valid signature', () => {
|
||||
const message = 'hello hanzo';
|
||||
const { address, signature } = evmSign(message);
|
||||
expect(verifyEvm(message, signature, address)).toBe(true);
|
||||
});
|
||||
it('is case-insensitive on the address', () => {
|
||||
const message = 'hello';
|
||||
const { address, signature } = evmSign(message);
|
||||
expect(verifyEvm(message, signature, address.toUpperCase().replace('0X', '0x'))).toBe(true);
|
||||
});
|
||||
it('rejects a tampered message', () => {
|
||||
const { address, signature } = evmSign('original');
|
||||
expect(verifyEvm('tampered', signature, address)).toBe(false);
|
||||
});
|
||||
it('rejects a wrong address', () => {
|
||||
const { signature } = evmSign('m');
|
||||
expect(verifyEvm('m', signature, '0x0000000000000000000000000000000000000000')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Solana ed25519 verify', () => {
|
||||
it('accepts a valid signature', () => {
|
||||
const message = 'hello solana';
|
||||
const { address, signature } = solanaSign(message);
|
||||
expect(verifySolana(message, signature, address)).toBe(true);
|
||||
});
|
||||
it('rejects a tampered message', () => {
|
||||
const { address, signature } = solanaSign('original');
|
||||
expect(verifySolana('tampered', signature, address)).toBe(false);
|
||||
});
|
||||
it('rejects a wrong address', () => {
|
||||
const { signature } = solanaSign('m');
|
||||
const other = bs58.encode(ed25519.getPublicKey(ed25519.utils.randomPrivateKey()));
|
||||
expect(verifySolana('m', signature, other)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyProof end-to-end', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const base = { domain: 'hanzo.id', uri: 'https://hanzo.id/login', nonce: 'abc12345', now };
|
||||
|
||||
it('accepts a fresh EVM proof', () => {
|
||||
const challenge = newChallenge(base);
|
||||
// EVM: derive the address from the key, embed it in the message, then sign.
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const address = addressFromPublicKey(secp256k1.getPublicKey(priv, false));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'evm' });
|
||||
const sig = secp256k1.sign(eip191Digest(message), priv);
|
||||
const full = new Uint8Array(65);
|
||||
full.set(sig.toCompactRawBytes(), 0);
|
||||
full[64] = (sig.recovery ?? 0) + 27;
|
||||
const proof: SignedProof = {
|
||||
chain: 'evm', scheme: 'secp256k1-eip191', address, message,
|
||||
signature: '0x' + bytesToHex(full),
|
||||
};
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.address).toBe(address);
|
||||
});
|
||||
|
||||
it('accepts a fresh Solana proof', () => {
|
||||
const challenge = newChallenge(base);
|
||||
// address is the pubkey; sign the message that embeds that address
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const address = bs58.encode(ed25519.getPublicKey(priv));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
|
||||
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
|
||||
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature };
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong nonce / domain', () => {
|
||||
const challenge = newChallenge(base);
|
||||
const address = bs58.encode(ed25519.getPublicKey(ed25519.utils.randomPrivateKey()));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
|
||||
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature: 'AAAA' };
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'WRONG', now }).reason).toBe('nonce-mismatch');
|
||||
expect(verifyProof(proof, { domain: 'evil.com', nonce: 'abc12345', now }).reason).toBe('domain-mismatch');
|
||||
});
|
||||
|
||||
it('rejects an expired proof', () => {
|
||||
const challenge = newChallenge({ ...base, ttlSeconds: 60 });
|
||||
const priv = ed25519.utils.randomPrivateKey();
|
||||
const address = bs58.encode(ed25519.getPublicKey(priv));
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'solana' });
|
||||
const signature = bytesToBase64(ed25519.sign(utf8ToBytes(message), priv));
|
||||
const proof: SignedProof = { chain: 'solana', scheme: 'ed25519', address, message, signature };
|
||||
// now is 1h after issuance, well past 60s ttl + skew
|
||||
const res = verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now: now + 3_600_000 });
|
||||
expect(res.reason).toBe('expired');
|
||||
});
|
||||
|
||||
it('fails closed on an unknown scheme', () => {
|
||||
const challenge = newChallenge(base);
|
||||
const message = buildSiwxMessage({ challenge, address: 'rXYZ', chain: 'xrp' });
|
||||
const proof = { chain: 'xrp', scheme: 'totally-unknown', address: 'rXYZ', message, signature: '00' } as unknown as SignedProof;
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).reason).toBe('unsupported-scheme');
|
||||
});
|
||||
|
||||
it('fails closed (bad-signature) on a wired-but-unverifiable proof', () => {
|
||||
const challenge = newChallenge(base);
|
||||
const message = buildSiwxMessage({ challenge, address: 'rXYZ', chain: 'xrp' });
|
||||
const proof: SignedProof = { chain: 'xrp', scheme: 'secp256k1-xrpl', address: 'rXYZ', message, signature: '00' };
|
||||
expect(verifyProof(proof, { domain: 'hanzo.id', nonce: 'abc12345', now }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { sha512 } from '@noble/hashes/sha512';
|
||||
import { ripemd160 } from '@noble/hashes/ripemd160';
|
||||
import { verifyXrp } from '../xrp/verify.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { newChallenge } from '../nonce.js';
|
||||
import { bytesToHex, concatBytes, utf8ToBytes } from '../bytes.js';
|
||||
import type { SignedProof } from '../types.js';
|
||||
|
||||
// --- Reproduce the XRPL signing + r-address derivation (the wallet side). ---
|
||||
// This is an INDEPENDENT implementation of the same spec the verifier uses; if
|
||||
// the two ever drift, the round-trip "accepts a valid proof" test fails. That
|
||||
// is the whole point of mirroring rather than importing verifier internals.
|
||||
|
||||
const XRPL_ALPHABET = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz';
|
||||
|
||||
/** Independent XRPL base58check (account/version-prefixed payload in). */
|
||||
function base58CheckXrpl(payload: Uint8Array): string {
|
||||
const checksum = sha256(sha256(payload)).slice(0, 4);
|
||||
const full = concatBytes(payload, checksum);
|
||||
let acc = 0n;
|
||||
for (const b of full) acc = (acc << 8n) | BigInt(b);
|
||||
let out = '';
|
||||
while (acc > 0n) {
|
||||
out = XRPL_ALPHABET[Number(acc % 58n)] + out;
|
||||
acc /= 58n;
|
||||
}
|
||||
for (let i = 0; i < full.length && full[i] === 0; i++) out = XRPL_ALPHABET[0] + out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** r-address from a full 33-byte XRPL public key. */
|
||||
function rAddressFromPubkey(pubkey33: Uint8Array): string {
|
||||
const accountId = ripemd160(sha256(pubkey33));
|
||||
return base58CheckXrpl(concatBytes(Uint8Array.of(0x00), accountId));
|
||||
}
|
||||
|
||||
function sha512Half(d: Uint8Array): Uint8Array {
|
||||
return sha512(d).slice(0, 32);
|
||||
}
|
||||
|
||||
interface Minted {
|
||||
proof: SignedProof;
|
||||
address: string;
|
||||
}
|
||||
|
||||
/** Mint a self-consistent ed25519-xrpl proof: key → r-address → SIWx → raw sig. */
|
||||
function mintEd25519(now = 1_700_000_000_000): Minted {
|
||||
const seed = ed25519.utils.randomPrivateKey();
|
||||
const raw32 = ed25519.getPublicKey(seed);
|
||||
// XRPL ed25519 public key = 0xED || 32-byte Edwards key.
|
||||
const pubkey33 = concatBytes(Uint8Array.of(0xed), raw32);
|
||||
const address = rAddressFromPubkey(pubkey33);
|
||||
|
||||
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login', now });
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'xrp' });
|
||||
|
||||
// ed25519-xrpl signs the raw UTF-8 message bytes.
|
||||
const sig = ed25519.sign(utf8ToBytes(message), seed);
|
||||
|
||||
return {
|
||||
address,
|
||||
proof: {
|
||||
chain: 'xrp',
|
||||
scheme: 'ed25519-xrpl',
|
||||
address,
|
||||
publicKey: bytesToHex(pubkey33),
|
||||
message,
|
||||
signature: bytesToHex(sig),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Mint a self-consistent secp256k1-xrpl proof: key → r-address → SIWx → DER sig. */
|
||||
function mintSecp256k1(now = 1_700_000_000_000): Minted {
|
||||
// Reject keys whose compressed form is not the usual length (defensive).
|
||||
const priv = secp256k1.utils.randomPrivateKey();
|
||||
const pubkey33 = secp256k1.getPublicKey(priv, true); // compressed: 0x02/0x03 || 32
|
||||
const address = rAddressFromPubkey(pubkey33);
|
||||
|
||||
const challenge = newChallenge({ domain: 'hanzo.id', uri: 'https://hanzo.id/login', now });
|
||||
const message = buildSiwxMessage({ challenge, address, chain: 'xrp' });
|
||||
|
||||
// secp256k1-xrpl signs the sha512half of the message, DER-encoded.
|
||||
const digest = sha512Half(utf8ToBytes(message));
|
||||
const sig = secp256k1.sign(digest, priv, { prehash: false, lowS: true });
|
||||
const der = bytesToHex(sig.toBytes('der'));
|
||||
|
||||
return {
|
||||
address,
|
||||
proof: {
|
||||
chain: 'xrp',
|
||||
scheme: 'secp256k1-xrpl',
|
||||
address,
|
||||
publicKey: bytesToHex(pubkey33),
|
||||
message,
|
||||
signature: der,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('XRPL base58check (known-answer vector)', () => {
|
||||
it('encodes the canonical xrpl.org AccountID example', () => {
|
||||
// From https://xrpl.org/addresses.html (Address Encoding worked example):
|
||||
// AccountID = BA8E78626EE42C41B46D46C3048DF3A1C3C87072
|
||||
// r-address = rJrRMgiRgrU6hDF4pgu5DXQdWyPbY35ErN
|
||||
const accountId = Uint8Array.from(
|
||||
'BA8E78626EE42C41B46D46C3048DF3A1C3C87072'.match(/../g)!.map((h) => parseInt(h, 16)),
|
||||
);
|
||||
const addr = base58CheckXrpl(concatBytes(Uint8Array.of(0x00), accountId));
|
||||
expect(addr).toBe('rJrRMgiRgrU6hDF4pgu5DXQdWyPbY35ErN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyXrp — ed25519-xrpl', () => {
|
||||
it('accepts a valid proof (full round-trip)', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered message (signature no longer matches)', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const bad: SignedProof = { ...proof, message: proof.message + ' ' };
|
||||
expect(verifyXrp(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a flipped signature bit', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const sig = Uint8Array.from(proof.signature.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
sig[0] = (sig[0]! ^ 0x01) & 0xff;
|
||||
expect(verifyXrp({ ...proof, signature: bytesToHex(sig) })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong address (binding failure, key/sig still valid)', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const other = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, address: other.address })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a mismatched public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const other = mintEd25519();
|
||||
// Valid 0xED-prefixed key but not the signer → sig verify fails.
|
||||
expect(verifyXrp({ ...proof, publicKey: other.proof.publicKey })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing 0xED prefix on the public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
const bytes = Uint8Array.from(proof.publicKey!.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
bytes[0] = 0xee; // wrong family tag
|
||||
expect(verifyXrp({ ...proof, publicKey: bytesToHex(bytes) })).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, publicKey: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyXrp — secp256k1-xrpl', () => {
|
||||
it('accepts a valid proof (full round-trip)', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
expect(verifyXrp(proof)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered message (signature no longer matches)', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const bad: SignedProof = { ...proof, message: proof.message + ' ' };
|
||||
expect(verifyXrp(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a corrupted DER signature', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const der = Uint8Array.from(proof.signature.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
const last = der.length - 1;
|
||||
der[last] = (der[last]! ^ 0x01) & 0xff; // mangle last byte of s
|
||||
expect(verifyXrp({ ...proof, signature: bytesToHex(der) })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong address (binding failure, key/sig still valid)', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const other = mintSecp256k1();
|
||||
expect(verifyXrp({ ...proof, address: other.address })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a mismatched public key', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const other = mintSecp256k1();
|
||||
expect(verifyXrp({ ...proof, publicKey: other.proof.publicKey })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an ed25519-tagged key under the secp256k1 scheme', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
const bytes = Uint8Array.from(proof.publicKey!.match(/../g)!.map((h) => parseInt(h, 16)));
|
||||
bytes[0] = 0xed; // not a valid compressed-point tag
|
||||
expect(verifyXrp({ ...proof, publicKey: bytesToHex(bytes) })).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on a missing public key', () => {
|
||||
const { proof } = mintSecp256k1();
|
||||
expect(verifyXrp({ ...proof, publicKey: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyXrp — fail-closed hardening', () => {
|
||||
it('rejects an unknown scheme via this verifier', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, scheme: 'ed25519' as SignedProof['scheme'] })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw on garbage input', () => {
|
||||
const garbage = {
|
||||
chain: 'xrp',
|
||||
scheme: 'ed25519-xrpl',
|
||||
address: 'x',
|
||||
publicKey: 'nothex',
|
||||
message: 'not a siwx message',
|
||||
signature: '!!!!',
|
||||
} as unknown as SignedProof;
|
||||
expect(() => verifyXrp(garbage)).not.toThrow();
|
||||
expect(verifyXrp(garbage)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong-length public key', () => {
|
||||
const { proof } = mintEd25519();
|
||||
expect(verifyXrp({ ...proof, publicKey: bytesToHex(new Uint8Array(31)) })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Base58Check (Bitcoin alphabet) — inline, no deps. Encode-only: we build a
|
||||
* P2PKH address from a recovered pubkey-hash and compare it byte-for-byte
|
||||
* against `proof.address`. Checksum = first 4 bytes of sha256d(payload).
|
||||
*/
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { concatBytes } from '../bytes.js';
|
||||
|
||||
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
/** Plain base58 (big-endian) encode. */
|
||||
function base58encode(bytes: Uint8Array): string {
|
||||
// Count leading zero bytes → leading '1's.
|
||||
let zeros = 0;
|
||||
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
||||
|
||||
// Convert base-256 → base-58 via repeated division on a digit buffer.
|
||||
const digits: number[] = [0];
|
||||
for (let i = zeros; i < bytes.length; i++) {
|
||||
let carry = bytes[i]!;
|
||||
for (let j = 0; j < digits.length; j++) {
|
||||
carry += digits[j]! << 8;
|
||||
digits[j] = carry % 58;
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
while (carry > 0) {
|
||||
digits.push(carry % 58);
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
}
|
||||
|
||||
let out = '';
|
||||
for (let i = 0; i < zeros; i++) out += '1';
|
||||
for (let i = digits.length - 1; i >= 0; i--) out += ALPHABET[digits[i]!];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** version-byte || payload, append sha256d checksum, base58-encode. */
|
||||
export function base58checkEncode(version: number, payload: Uint8Array): string {
|
||||
const data = concatBytes(new Uint8Array([version & 0xff]), payload);
|
||||
const checksum = sha256(sha256(data)).slice(0, 4);
|
||||
return base58encode(concatBytes(data, checksum));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* bech32 (BIP-173) and bech32m (BIP-350) segwit address encoding — inline, no
|
||||
* deps. Encode-only: we derive an address from a recovered pubkey and compare
|
||||
* it against the claimed `proof.address` string, so we never need to decode.
|
||||
*
|
||||
* A SegWit v0 address (P2WPKH) uses the bech32 constant; v1+ (P2TR) uses
|
||||
* bech32m. The two differ only in the final XOR constant of the checksum —
|
||||
* the source of the 2017-era "bech32 is malleable for v1" fix.
|
||||
*/
|
||||
|
||||
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
|
||||
const BECH32_CONST = 1;
|
||||
const BECH32M_CONST = 0x2bc830a3;
|
||||
|
||||
function polymod(values: number[]): number {
|
||||
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
let chk = 1;
|
||||
for (const v of values) {
|
||||
const top = chk >>> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if ((top >>> i) & 1) chk ^= GEN[i]!;
|
||||
}
|
||||
}
|
||||
return chk >>> 0;
|
||||
}
|
||||
|
||||
function hrpExpand(hrp: string): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) >>> 5);
|
||||
out.push(0);
|
||||
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) & 31);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert a byte array (8-bit) to 5-bit groups (frombits=8, tobits=5, pad=true). */
|
||||
function convert8to5(data: Uint8Array): number[] | null {
|
||||
let acc = 0;
|
||||
let bits = 0;
|
||||
const out: number[] = [];
|
||||
const maxv = 31;
|
||||
for (const value of data) {
|
||||
if (value < 0 || value >> 8 !== 0) return null;
|
||||
acc = ((acc << 8) | value) & 0xffffffff;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
out.push((acc >>> bits) & maxv);
|
||||
}
|
||||
}
|
||||
if (bits > 0) out.push((acc << (5 - bits)) & maxv);
|
||||
return out;
|
||||
}
|
||||
|
||||
function createChecksum(hrp: string, data5: number[], constant: number): number[] {
|
||||
const values = hrpExpand(hrp).concat(data5);
|
||||
const mod = polymod(values.concat([0, 0, 0, 0, 0, 0])) ^ constant;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < 6; i++) out.push((mod >>> (5 * (5 - i))) & 31);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a SegWit address. witver 0 → bech32 (P2WPKH); witver 1 → bech32m
|
||||
* (P2TR). Returns null on any invalid input (fail closed; never throws).
|
||||
*/
|
||||
export function encodeSegwitAddress(
|
||||
hrp: string,
|
||||
witver: number,
|
||||
program: Uint8Array,
|
||||
): string | null {
|
||||
if (witver < 0 || witver > 16) return null;
|
||||
// BIP-141 program length bounds: 2..40 bytes; v0 must be 20 or 32.
|
||||
if (program.length < 2 || program.length > 40) return null;
|
||||
if (witver === 0 && program.length !== 20 && program.length !== 32) return null;
|
||||
|
||||
const data5 = convert8to5(program);
|
||||
if (data5 === null) return null;
|
||||
const payload = [witver, ...data5];
|
||||
const constant = witver === 0 ? BECH32_CONST : BECH32M_CONST;
|
||||
const checksum = createChecksum(hrp, payload, constant);
|
||||
const combined = payload.concat(checksum);
|
||||
|
||||
let out = hrp + '1';
|
||||
for (const d of combined) {
|
||||
if (d < 0 || d > 31) return null;
|
||||
out += CHARSET[d];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Bitcoin wallet connector — message signing via `sats-connect` (Xverse,
|
||||
* Leather, Unisat, and any wallet implementing the sats-connect provider RPC).
|
||||
*
|
||||
* Connect requests the wallet's addresses and prefers a P2WPKH ('bc1q…')
|
||||
* payment address (the broadest-compatibility key-path login form). Signing
|
||||
* uses `signMessage` with the BIP-322 protocol, which returns a base64
|
||||
* signature — a serialized witness stack for segwit/taproot addresses, or a
|
||||
* recoverable ECDSA sig for legacy. {@link verifyBitcoin} dispatches on that
|
||||
* shape, so the proof here carries the address *type* in `extra.addressType`
|
||||
* and scheme `bip322`.
|
||||
*/
|
||||
import Wallet, {
|
||||
AddressPurpose,
|
||||
MessageSigningProtocols,
|
||||
type Address,
|
||||
} from 'sats-connect';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
|
||||
/** sats-connect addressType → the hint string {@link verifyBitcoin} expects. */
|
||||
type BtcAddressTypeHint = 'p2pkh' | 'p2wpkh' | 'p2tr';
|
||||
|
||||
function toAddressTypeHint(addressType: string): BtcAddressTypeHint | null {
|
||||
if (addressType === 'p2pkh' || addressType === 'p2wpkh' || addressType === 'p2tr') {
|
||||
return addressType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the address to sign with. Order of preference:
|
||||
* 1. P2WPKH payment ('bc1q…') — widest wallet + verifier support
|
||||
* 2. P2TR payment/ordinals — taproot key-path
|
||||
* 3. P2PKH — legacy
|
||||
* Returns the chosen entry plus its verifier address-type hint.
|
||||
*/
|
||||
function chooseAddress(addresses: readonly Address[]): { addr: Address; hint: BtcAddressTypeHint } | null {
|
||||
const ranked: BtcAddressTypeHint[] = ['p2wpkh', 'p2tr', 'p2pkh'];
|
||||
for (const want of ranked) {
|
||||
const found = addresses.find((a) => toAddressTypeHint(a.addressType) === want);
|
||||
if (found) return { addr: found, hint: want };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class BitcoinConnector implements WalletConnector {
|
||||
readonly chain = 'bitcoin' as const;
|
||||
|
||||
#address: string | null = null;
|
||||
#addressType: BtcAddressTypeHint | null = null;
|
||||
#walletId = 'sats-connect';
|
||||
|
||||
/**
|
||||
* sats-connect resolves the concrete wallet at request time (it shows its own
|
||||
* provider picker), so discovery here advertises the aggregate provider.
|
||||
*/
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
if (typeof window === 'undefined') return [];
|
||||
return [
|
||||
{
|
||||
id: 'sats-connect',
|
||||
name: 'Bitcoin Wallet (Xverse / Leather / Unisat)',
|
||||
chain: this.chain,
|
||||
installed: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect and pick a signing address. `walletId` is forwarded to sats-connect
|
||||
* as the provider id when given; otherwise its built-in picker is used.
|
||||
*/
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('bitcoin: no window — connectors are browser-only');
|
||||
}
|
||||
if (walletId != null) this.#walletId = walletId;
|
||||
|
||||
const res = await Wallet.request('getAddresses', {
|
||||
purposes: [AddressPurpose.Payment, AddressPurpose.Ordinals],
|
||||
message: 'Connect to sign in',
|
||||
});
|
||||
if (res.status !== 'success') {
|
||||
throw new Error(`bitcoin: getAddresses failed (${res.error?.message ?? 'rejected'})`);
|
||||
}
|
||||
|
||||
const chosen = chooseAddress(res.result.addresses);
|
||||
if (!chosen) throw new Error('bitcoin: wallet returned no usable address');
|
||||
|
||||
this.#address = chosen.addr.address;
|
||||
this.#addressType = chosen.hint;
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
address: chosen.addr.address,
|
||||
publicKey: chosen.addr.publicKey,
|
||||
walletId: this.#walletId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CAIP-122 message and have the wallet sign it (BIP-322).
|
||||
* Produces a `bip322` proof carrying `extra.addressType` so the verifier
|
||||
* derives the correct address form.
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
if (!this.#address || !this.#addressType) {
|
||||
throw new Error('bitcoin: not connected — call connect() first');
|
||||
}
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
});
|
||||
|
||||
const res = await Wallet.request('signMessage', {
|
||||
address: account.address,
|
||||
message,
|
||||
protocol: MessageSigningProtocols.BIP322,
|
||||
});
|
||||
if (res.status !== 'success') {
|
||||
throw new Error(`bitcoin: signMessage failed (${res.error?.message ?? 'rejected'})`);
|
||||
}
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: 'bip322',
|
||||
address: account.address,
|
||||
message,
|
||||
signature: res.result.signature, // base64
|
||||
extra: { addressType: this.#addressType },
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
try {
|
||||
await Wallet.disconnect();
|
||||
} catch {
|
||||
// sats-connect throws if no session; ignore on teardown.
|
||||
} finally {
|
||||
this.#address = null;
|
||||
this.#addressType = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Bitcoin login-signature verifier.
|
||||
*
|
||||
* Two signing conventions are supported, both verifying a CAIP-122 message
|
||||
* against a BTC address (P2PKH '1…', P2WPKH 'bc1q…', P2TR 'bc1p…'):
|
||||
*
|
||||
* 1. Legacy "Bitcoin Signed Message" — recoverable ECDSA over the
|
||||
* double-SHA256 of the magic-prefixed message. Primary path; works for
|
||||
* every address type (the wallet picks the key form via the header byte).
|
||||
*
|
||||
* 2. BIP-322 "simple" — a virtual to_spend/to_sign transaction pair whose
|
||||
* witness is verified with BIP-143 (P2WPKH, ECDSA) or BIP-341 (P2TR,
|
||||
* Schnorr) sighash. Used when the signature is a serialized witness stack
|
||||
* rather than a 65-byte recoverable sig.
|
||||
*
|
||||
* Security posture: fail closed. Every parse/branch returns `false` on the
|
||||
* slightest irregularity and the whole function is wrapped so it never throws.
|
||||
* The recovered/derived address must match the *type* claimed by the proof and
|
||||
* be byte-for-byte equal to `proof.address`.
|
||||
*/
|
||||
import { secp256k1, schnorr } from '@noble/curves/secp256k1';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import { ripemd160 } from '@noble/hashes/legacy';
|
||||
import type { SignedProof } from '../types.js';
|
||||
import { base64ToBytes, utf8ToBytes, concatBytes } from '../bytes.js';
|
||||
import { encodeSegwitAddress } from './bech32.js';
|
||||
import { base58checkEncode } from './base58check.js';
|
||||
|
||||
// ── address type ──────────────────────────────────────────────────────────
|
||||
|
||||
type BtcAddressType = 'p2pkh' | 'p2wpkh' | 'p2tr';
|
||||
|
||||
/** Bitcoin mainnet bech32 human-readable part. */
|
||||
const HRP = 'bc';
|
||||
|
||||
/** Determine the address type from an explicit hint, else from the prefix. */
|
||||
function addressType(proof: SignedProof): BtcAddressType | null {
|
||||
const hint = (proof.extra?.addressType as string | undefined)?.toLowerCase();
|
||||
if (hint === 'p2pkh' || hint === 'p2wpkh' || hint === 'p2tr') return hint;
|
||||
|
||||
const a = proof.address;
|
||||
if (a.startsWith('bc1p')) return 'p2tr';
|
||||
if (a.startsWith('bc1q')) return 'p2wpkh';
|
||||
if (a.startsWith('1')) return 'p2pkh';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── hashing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const sha256d = (b: Uint8Array): Uint8Array => sha256(sha256(b));
|
||||
const hash160 = (b: Uint8Array): Uint8Array => ripemd160(sha256(b));
|
||||
|
||||
/** BIP-340 tagged hash: sha256(sha256(tag) || sha256(tag) || msg…). */
|
||||
function taggedHash(tag: string, ...messages: Uint8Array[]): Uint8Array {
|
||||
const tagHash = sha256(utf8ToBytes(tag));
|
||||
return sha256(concatBytes(tagHash, tagHash, ...messages));
|
||||
}
|
||||
|
||||
// ── address derivation (pubkey → address string) ────────────────────────────
|
||||
|
||||
function deriveP2PKH(pubkey: Uint8Array): string {
|
||||
// base58check( 0x00 || hash160(pubkey) )
|
||||
return base58checkEncode(0x00, hash160(pubkey));
|
||||
}
|
||||
|
||||
function deriveP2WPKH(pubkeyCompressed: Uint8Array): string | null {
|
||||
// bech32(hrp='bc', witver=0, program=hash160(compressed pubkey))
|
||||
return encodeSegwitAddress(HRP, 0, hash160(pubkeyCompressed));
|
||||
}
|
||||
|
||||
/** x-only (32-byte) coordinate of a point given as its affine x bigint. */
|
||||
function xonlyFromBigInt(x: bigint): Uint8Array {
|
||||
const out = new Uint8Array(32);
|
||||
let v = x;
|
||||
for (let i = 31; i >= 0; i--) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-86 key-path taproot output for an internal pubkey.
|
||||
* t = taggedHash('TapTweak', xonly(P))
|
||||
* Q = lift_x(xonly(P)) + t*G // internal key is the even-Y lift
|
||||
* program = xonly(Q)
|
||||
* Returns the 32-byte tweaked x-only program, or null on any failure.
|
||||
*/
|
||||
function taprootTweak(internalXonly: Uint8Array): Uint8Array | null {
|
||||
try {
|
||||
const Point = secp256k1.Point;
|
||||
const n = secp256k1.CURVE.n;
|
||||
const x = bytesToBigInt(internalXonly);
|
||||
const P = schnorr.utils.lift_x(x); // even-Y point with this x
|
||||
const t = bytesToBigInt(taggedHash('TapTweak', internalXonly)) % n;
|
||||
if (t === 0n) return null;
|
||||
const Q = P.add(Point.BASE.multiply(t));
|
||||
const qx = Q.toAffine().x;
|
||||
return xonlyFromBigInt(qx);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveP2TR(internalXonly: Uint8Array): string | null {
|
||||
const program = taprootTweak(internalXonly);
|
||||
if (program === null) return null;
|
||||
return encodeSegwitAddress(HRP, 1, program);
|
||||
}
|
||||
|
||||
function bytesToBigInt(b: Uint8Array): bigint {
|
||||
let v = 0n;
|
||||
for (const byte of b) v = (v << 8n) | BigInt(byte);
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── Bitcoin var-int / serialization (CompactSize) ───────────────────────────
|
||||
|
||||
function compactSize(n: number): Uint8Array {
|
||||
if (n < 0) throw new Error('negative compactSize');
|
||||
if (n < 0xfd) return new Uint8Array([n]);
|
||||
if (n <= 0xffff) return new Uint8Array([0xfd, n & 0xff, (n >>> 8) & 0xff]);
|
||||
if (n <= 0xffffffff) {
|
||||
return new Uint8Array([0xfe, n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
}
|
||||
// 64-bit lengths are never needed for login messages.
|
||||
const out = new Uint8Array(9);
|
||||
out[0] = 0xff;
|
||||
let v = BigInt(n);
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function u32le(n: number): Uint8Array {
|
||||
return new Uint8Array([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
}
|
||||
|
||||
function u64le(n: bigint): Uint8Array {
|
||||
const out = new Uint8Array(8);
|
||||
let v = n;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** length-prefixed (CompactSize) byte string. */
|
||||
function varBytes(b: Uint8Array): Uint8Array {
|
||||
return concatBytes(compactSize(b.length), b);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 1. LEGACY "Bitcoin Signed Message" (recoverable ECDSA)
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const MSG_MAGIC = utf8ToBytes('\x18Bitcoin Signed Message:\n');
|
||||
|
||||
/** digest = sha256d( magic || varint(len) || message ). */
|
||||
function legacyMessageDigest(message: string): Uint8Array {
|
||||
const msg = utf8ToBytes(message);
|
||||
return sha256d(concatBytes(MSG_MAGIC, compactSize(msg.length), msg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a 65-byte recoverable signature [header || r || s] over the legacy
|
||||
* message digest, deriving the address of `type` and comparing to `address`.
|
||||
*/
|
||||
function verifyLegacy(
|
||||
sig: Uint8Array,
|
||||
message: string,
|
||||
address: string,
|
||||
type: BtcAddressType,
|
||||
): boolean {
|
||||
if (sig.length !== 65) return false;
|
||||
const header = sig[0]!;
|
||||
// 27-30: uncompressed key; 31-34: compressed key. (BIP-137 also defines
|
||||
// 35-42 for segwit, but the recovered key form is what matters here, so we
|
||||
// accept the canonical 27-34 range and infer compression from it.)
|
||||
if (header < 27 || header > 34) return false;
|
||||
const recid = (header - 27) & 3;
|
||||
const compressed = header >= 31;
|
||||
|
||||
const r = sig.slice(1, 33);
|
||||
const s = sig.slice(33, 65);
|
||||
const digest = legacyMessageDigest(message);
|
||||
|
||||
let point;
|
||||
try {
|
||||
point = secp256k1.Signature.fromCompact(concatBytes(r, s))
|
||||
.addRecoveryBit(recid)
|
||||
.recoverPublicKey(digest);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For segwit address types the key MUST be compressed — an uncompressed key
|
||||
// cannot back a P2WPKH/P2TR script, so reject rather than silently coercing.
|
||||
if ((type === 'p2wpkh' || type === 'p2tr') && !compressed) return false;
|
||||
|
||||
let derived: string | null;
|
||||
switch (type) {
|
||||
case 'p2pkh': {
|
||||
// P2PKH commits to the exact key encoding chosen by the header byte.
|
||||
const pub = point.toBytes(compressed);
|
||||
derived = deriveP2PKH(pub);
|
||||
break;
|
||||
}
|
||||
case 'p2wpkh': {
|
||||
derived = deriveP2WPKH(point.toBytes(true));
|
||||
break;
|
||||
}
|
||||
case 'p2tr': {
|
||||
// Internal key = x-only of the recovered (compressed) key.
|
||||
const xonly = point.toBytes(true).slice(1);
|
||||
derived = deriveP2TR(xonly);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return derived !== null && constTimeStrEq(derived, address);
|
||||
}
|
||||
|
||||
/** Length-checked, content-comparing string equality (addresses are public). */
|
||||
function constTimeStrEq(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 2. BIP-322 "simple"
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Parse a serialized witness stack: count, then length-prefixed elements. */
|
||||
function parseWitness(buf: Uint8Array): Uint8Array[] | null {
|
||||
let off = 0;
|
||||
const readCompact = (): number | null => {
|
||||
if (off >= buf.length) return null;
|
||||
const first = buf[off++]!;
|
||||
if (first < 0xfd) return first;
|
||||
if (first === 0xfd) {
|
||||
if (off + 2 > buf.length) return null;
|
||||
const v = buf[off]! | (buf[off + 1]! << 8);
|
||||
off += 2;
|
||||
return v;
|
||||
}
|
||||
if (first === 0xfe) {
|
||||
if (off + 4 > buf.length) return null;
|
||||
const v = buf[off]! | (buf[off + 1]! << 8) | (buf[off + 2]! << 16) | (buf[off + 3]! * 0x1000000);
|
||||
off += 4;
|
||||
return v;
|
||||
}
|
||||
return null; // 64-bit lengths never appear in witness logins
|
||||
};
|
||||
|
||||
const count = readCompact();
|
||||
if (count === null || count < 1 || count > 4) return null;
|
||||
const items: Uint8Array[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const len = readCompact();
|
||||
if (len === null || len < 0 || off + len > buf.length) return null;
|
||||
items.push(buf.slice(off, off + len));
|
||||
off += len;
|
||||
}
|
||||
if (off !== buf.length) return null; // no trailing garbage
|
||||
return items;
|
||||
}
|
||||
|
||||
/** scriptPubKey bytes for each supported address type given its program. */
|
||||
function scriptPubKeyP2WPKH(hash160Pub: Uint8Array): Uint8Array {
|
||||
// OP_0 PUSH20 <hash160>
|
||||
return concatBytes(new Uint8Array([0x00, 0x14]), hash160Pub);
|
||||
}
|
||||
function scriptPubKeyP2TR(programXonly: Uint8Array): Uint8Array {
|
||||
// OP_1 PUSH32 <tweaked xonly>
|
||||
return concatBytes(new Uint8Array([0x51, 0x20]), programXonly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the BIP-322 to_spend txid.
|
||||
* to_spend: nVersion=0, vin=[ {prevout=0..00:0xFFFFFFFF,
|
||||
* scriptSig=OP_0 PUSH32 <msgHash>, nSequence=0} ],
|
||||
* vout=[ {value=0, scriptPubKey} ], nLockTime=0
|
||||
* txid = sha256d(serialization without witness).
|
||||
*/
|
||||
function toSpendTxid(message: string, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const msgHash = taggedHash('BIP0322-signed-message', utf8ToBytes(message));
|
||||
const scriptSig = concatBytes(new Uint8Array([0x00, 0x20]), msgHash); // OP_0 PUSH32
|
||||
|
||||
const ser = concatBytes(
|
||||
u32le(0), // nVersion = 0
|
||||
compactSize(1), // vin count
|
||||
new Uint8Array(32), // prevout hash = 0
|
||||
u32le(0xffffffff), // prevout index = 0xFFFFFFFF
|
||||
varBytes(scriptSig),
|
||||
u32le(0), // nSequence = 0
|
||||
compactSize(1), // vout count
|
||||
u64le(0n), // value = 0
|
||||
varBytes(scriptPubKey),
|
||||
u32le(0), // nLockTime = 0
|
||||
);
|
||||
return sha256d(ser);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-143 sighash for the single input of the BIP-322 to_sign tx (P2WPKH).
|
||||
* SIGHASH_ALL. scriptCode for P2WPKH = OP_DUP OP_HASH160 PUSH20 <h160>
|
||||
* OP_EQUALVERIFY OP_CHECKSIG.
|
||||
*/
|
||||
function bip143SighashP2WPKH(toSpendTxid: Uint8Array, hash160Pub: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(toSpendTxid, u32le(0)); // to_spend:0
|
||||
const nSequence = u32le(0);
|
||||
const hashPrevouts = sha256d(outpoint);
|
||||
const hashSequence = sha256d(nSequence);
|
||||
|
||||
const scriptCode = concatBytes(
|
||||
new Uint8Array([0x19, 0x76, 0xa9, 0x14]), // len(25) OP_DUP OP_HASH160 PUSH20
|
||||
hash160Pub,
|
||||
new Uint8Array([0x88, 0xac]), // OP_EQUALVERIFY OP_CHECKSIG
|
||||
);
|
||||
|
||||
// to_sign single output: value=0, scriptPubKey = OP_RETURN (0x6a).
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a])));
|
||||
const hashOutputs = sha256d(output);
|
||||
|
||||
const preimage = concatBytes(
|
||||
u32le(0), // nVersion = 0
|
||||
hashPrevouts,
|
||||
hashSequence,
|
||||
outpoint,
|
||||
scriptCode,
|
||||
u64le(0n), // amount of the spent output = 0
|
||||
nSequence,
|
||||
hashOutputs,
|
||||
u32le(0), // nLockTime = 0
|
||||
u32le(1), // SIGHASH_ALL
|
||||
);
|
||||
return sha256d(preimage);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-341 (taproot key-path) sighash for the single input of the to_sign tx,
|
||||
* SIGHASH_DEFAULT (0x00). Single P2TR input, single OP_RETURN output.
|
||||
*/
|
||||
function bip341SighashP2TR(toSpendTxid: Uint8Array, scriptPubKey: Uint8Array): Uint8Array {
|
||||
const outpoint = concatBytes(toSpendTxid, u32le(0));
|
||||
const nSequence = u32le(0);
|
||||
|
||||
const shaPrevouts = sha256(outpoint);
|
||||
const shaAmounts = sha256(u64le(0n)); // single spent amount = 0
|
||||
const shaScriptPubkeys = sha256(varBytes(scriptPubKey));
|
||||
const shaSequences = sha256(nSequence);
|
||||
const output = concatBytes(u64le(0n), varBytes(new Uint8Array([0x6a]))); // value=0, OP_RETURN
|
||||
const shaOutputs = sha256(output);
|
||||
|
||||
const epoch = new Uint8Array([0x00]);
|
||||
const hashType = new Uint8Array([0x00]); // SIGHASH_DEFAULT
|
||||
const spendType = new Uint8Array([0x00]); // no annex, key-path
|
||||
const inputIndex = u32le(0);
|
||||
|
||||
const sigMsg = concatBytes(
|
||||
hashType,
|
||||
u32le(0), // nVersion = 0
|
||||
u32le(0), // nLockTime = 0
|
||||
shaPrevouts,
|
||||
shaAmounts,
|
||||
shaScriptPubkeys,
|
||||
shaSequences,
|
||||
shaOutputs,
|
||||
spendType,
|
||||
inputIndex,
|
||||
);
|
||||
// BIP-341: tagged hash "TapSighash" over (epoch || sigMsg).
|
||||
return taggedHash('TapSighash', concatBytes(epoch, sigMsg));
|
||||
}
|
||||
|
||||
/** Strip a trailing SIGHASH byte from a DER ECDSA signature, returning (der, sighash). */
|
||||
function splitDerSighash(witnessSig: Uint8Array): { der: Uint8Array; sighash: number } | null {
|
||||
if (witnessSig.length < 1) return null;
|
||||
const sighash = witnessSig[witnessSig.length - 1]!;
|
||||
return { der: witnessSig.slice(0, witnessSig.length - 1), sighash };
|
||||
}
|
||||
|
||||
/** Parse a 64- or 65-byte BIP-340 schnorr sig (optional trailing sighash). */
|
||||
function splitSchnorrSighash(witnessSig: Uint8Array): { sig: Uint8Array; sighash: number } | null {
|
||||
if (witnessSig.length === 64) return { sig: witnessSig, sighash: 0x00 };
|
||||
if (witnessSig.length === 65) {
|
||||
const sighash = witnessSig[64]!;
|
||||
return { sig: witnessSig.slice(0, 64), sighash };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function verifyBip322P2WPKH(
|
||||
witness: Uint8Array[],
|
||||
message: string,
|
||||
address: string,
|
||||
): boolean {
|
||||
// Witness stack for P2WPKH is exactly [signature, pubkey].
|
||||
if (witness.length !== 2) return false;
|
||||
const [sigBytes, pubkey] = witness as [Uint8Array, Uint8Array];
|
||||
if (pubkey.length !== 33 || (pubkey[0] !== 0x02 && pubkey[0] !== 0x03)) return false;
|
||||
|
||||
// Address binding: the witness pubkey must hash to the claimed P2WPKH address.
|
||||
const h160 = hash160(pubkey);
|
||||
const derived = deriveP2WPKH(pubkey);
|
||||
if (derived === null || !constTimeStrEq(derived, address)) return false;
|
||||
|
||||
const parsed = splitDerSighash(sigBytes);
|
||||
if (parsed === null) return false;
|
||||
// BIP-322 simple for single-key uses SIGHASH_ALL.
|
||||
if (parsed.sighash !== 0x01) return false;
|
||||
|
||||
const txid = toSpendTxid(message, scriptPubKeyP2WPKH(h160));
|
||||
const sighash = bip143SighashP2WPKH(txid, h160);
|
||||
|
||||
try {
|
||||
const sig = secp256k1.Signature.fromDER(parsed.der);
|
||||
// Reject high-S (BIP-146 / consensus-standardness, anti-malleability).
|
||||
if (sig.hasHighS()) return false;
|
||||
return secp256k1.verify(sig.toCompactRawBytes(), sighash, pubkey, { lowS: true });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function verifyBip322P2TR(
|
||||
witness: Uint8Array[],
|
||||
message: string,
|
||||
address: string,
|
||||
): boolean {
|
||||
// Key-path spend: witness is exactly [schnorr_sig].
|
||||
if (witness.length !== 1) return false;
|
||||
const parsed = splitSchnorrSighash(witness[0]!);
|
||||
if (parsed === null) return false;
|
||||
if (parsed.sighash !== 0x00) return false; // SIGHASH_DEFAULT only
|
||||
|
||||
// Recover the tweaked output key from the claimed address by re-deriving the
|
||||
// scriptPubKey from… the address itself: we must decode the program. Since we
|
||||
// only have the address string, derive the program by trusting the bech32m
|
||||
// body is the output key. We re-encode and compare, then verify schnorr
|
||||
// against that x-only output key.
|
||||
const program = decodeP2TRProgram(address);
|
||||
if (program === null) return false;
|
||||
|
||||
const txid = toSpendTxid(message, scriptPubKeyP2TR(program));
|
||||
const sighash = bip341SighashP2TR(txid, scriptPubKeyP2TR(program));
|
||||
|
||||
try {
|
||||
return schnorr.verify(parsed.sig, sighash, program);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a bech32m P2TR ('bc1p…') address to its 32-byte witness program.
|
||||
* Minimal decoder used only to recover the output key for schnorr verify; it
|
||||
* re-validates the checksum by re-encoding and comparing (fail closed).
|
||||
*/
|
||||
function decodeP2TRProgram(address: string): Uint8Array | null {
|
||||
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
const lower = address.toLowerCase();
|
||||
if (lower !== address && address.toUpperCase() !== address) return null; // mixed case
|
||||
const pos = lower.lastIndexOf('1');
|
||||
if (pos < 1) return null;
|
||||
const hrp = lower.slice(0, pos);
|
||||
if (hrp !== HRP) return null;
|
||||
const dataPart = lower.slice(pos + 1);
|
||||
if (dataPart.length < 7) return null; // 1 (witver) + program + 6 checksum
|
||||
|
||||
const values: number[] = [];
|
||||
for (const ch of dataPart) {
|
||||
const v = CHARSET.indexOf(ch);
|
||||
if (v === -1) return null;
|
||||
values.push(v);
|
||||
}
|
||||
const witver = values[0]!;
|
||||
if (witver !== 1) return null; // only taproot here
|
||||
|
||||
// Convert 5-bit data (excluding witver and 6-byte checksum) → 8-bit program.
|
||||
const data5 = values.slice(1, values.length - 6);
|
||||
const program = convert5to8(data5);
|
||||
if (program === null || program.length !== 32) return null;
|
||||
|
||||
// Re-encode with bech32m and compare to validate the checksum.
|
||||
const reencoded = encodeSegwitAddress(HRP, 1, program);
|
||||
if (reencoded === null || reencoded !== lower) return null;
|
||||
return program;
|
||||
}
|
||||
|
||||
/** 5-bit groups → 8-bit bytes (frombits=5, tobits=8, pad=false). */
|
||||
function convert5to8(data: number[]): Uint8Array | null {
|
||||
let acc = 0;
|
||||
let bits = 0;
|
||||
const out: number[] = [];
|
||||
for (const value of data) {
|
||||
if (value < 0 || value >> 5 !== 0) return null;
|
||||
acc = ((acc << 5) | value) & 0xffffffff;
|
||||
bits += 5;
|
||||
while (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push((acc >>> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
// Reject if leftover bits form a non-zero pad (strict, per BIP-173).
|
||||
if (bits >= 5) return null;
|
||||
if ((acc << (8 - bits)) & 0xff) return null;
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// dispatch
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function verifyBitcoin(proof: SignedProof): boolean {
|
||||
try {
|
||||
const type = addressType(proof);
|
||||
if (type === null) return false;
|
||||
|
||||
let sig: Uint8Array;
|
||||
try {
|
||||
sig = base64ToBytes(proof.signature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (sig.length === 0) return false;
|
||||
|
||||
// Shape-based dispatch (exactly one path per shape):
|
||||
// • 65 bytes with a valid header → legacy recoverable ECDSA.
|
||||
// • otherwise → BIP-322 simple (serialized witness stack).
|
||||
const header = sig[0]!;
|
||||
const looksLegacy = sig.length === 65 && header >= 27 && header <= 34;
|
||||
|
||||
if (looksLegacy) {
|
||||
return verifyLegacy(sig, proof.message, proof.address, type);
|
||||
}
|
||||
|
||||
// BIP-322 simple — only P2WPKH and P2TR are defined for key-path here.
|
||||
const witness = parseWitness(sig);
|
||||
if (witness === null) return false;
|
||||
if (type === 'p2wpkh') return verifyBip322P2WPKH(witness, proof.message, proof.address);
|
||||
if (type === 'p2tr') return verifyBip322P2TR(witness, proof.message, proof.address);
|
||||
// BIP-322 for P2PKH is not standardized for "simple"; legacy covers it.
|
||||
return false;
|
||||
} catch {
|
||||
// Absolute backstop: never throw out of a verifier.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Byte helpers shared by every verifier. Cross-runtime (Node + browser). */
|
||||
import { hexToBytes as nobleHexToBytes, bytesToHex, utf8ToBytes, concatBytes } from '@noble/hashes/utils';
|
||||
|
||||
export { bytesToHex, utf8ToBytes, concatBytes };
|
||||
|
||||
/** Hex → bytes, tolerant of a leading 0x. */
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
return nobleHexToBytes(hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex);
|
||||
}
|
||||
|
||||
/** base64 (standard, with padding) → bytes. */
|
||||
export function base64ToBytes(b64: string): Uint8Array {
|
||||
if (typeof atob === 'function') {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
// Node
|
||||
return new Uint8Array(Buffer.from(b64, 'base64'));
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
if (typeof btoa === 'function') {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin);
|
||||
}
|
||||
return Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
/** Decode a signature that may be hex (0x…) or base64 into raw bytes. */
|
||||
export function decodeSignature(sig: string): Uint8Array {
|
||||
const s = sig.trim();
|
||||
if (s.startsWith('0x') || s.startsWith('0X')) return hexToBytes(s);
|
||||
// Heuristic: pure hex (even length, [0-9a-f]) → hex, else base64.
|
||||
if (/^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0) return hexToBytes(s);
|
||||
return base64ToBytes(s);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* CAIP-122 "Sign-In-With-X" message — the one canonical login string for every
|
||||
* chain. Generalizes EIP-4361 (SIWE) so a Solana / Bitcoin / TON / XRP wallet
|
||||
* signs the exact same human-readable assertion an Ethereum wallet does.
|
||||
*
|
||||
* Spec: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md
|
||||
* (which itself generalizes https://eips.ethereum.org/EIPS/eip-4361)
|
||||
*
|
||||
* build ∘ parse round-trips. Both are pure — no I/O, no clock — so the Go port
|
||||
* for IAM can mirror them byte-for-byte.
|
||||
*/
|
||||
import type { Chain, LoginChallenge } from './types.js';
|
||||
|
||||
/** Human chain label used on the first line of the message. */
|
||||
const CHAIN_LABEL: Record<Chain, string> = {
|
||||
evm: 'Ethereum',
|
||||
solana: 'Solana',
|
||||
bitcoin: 'Bitcoin',
|
||||
ton: 'TON',
|
||||
xrp: 'XRP Ledger',
|
||||
};
|
||||
|
||||
export interface ParsedSiwx {
|
||||
domain: string;
|
||||
address: string;
|
||||
statement?: string;
|
||||
uri: string;
|
||||
version?: string;
|
||||
chainId?: string;
|
||||
nonce: string;
|
||||
issuedAt: string;
|
||||
expirationTime?: string;
|
||||
notBefore?: string;
|
||||
requestId?: string;
|
||||
resources?: string[];
|
||||
}
|
||||
|
||||
export interface BuildParams {
|
||||
challenge: LoginChallenge;
|
||||
address: string;
|
||||
chain: Chain;
|
||||
/** CAIP-2 network id, e.g. 'eip155:1', 'solana:5eykt...'. Optional. */
|
||||
chainId?: string;
|
||||
}
|
||||
|
||||
/** Render a {@link LoginChallenge} to the canonical CAIP-122 message string. */
|
||||
export function buildSiwxMessage(params: BuildParams): string {
|
||||
const { challenge: c, address, chain, chainId } = params;
|
||||
const label = CHAIN_LABEL[chain];
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`${c.domain} wants you to sign in with your ${label} account:`);
|
||||
lines.push(address);
|
||||
lines.push('');
|
||||
// Statement block is optional. When present it sits on its own line between
|
||||
// two blank lines (per EIP-4361 ABNF).
|
||||
if (c.statement != null && c.statement.length > 0) {
|
||||
if (c.statement.includes('\n')) {
|
||||
throw new Error('caip122: statement must be a single line');
|
||||
}
|
||||
lines.push(c.statement);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(`URI: ${c.uri}`);
|
||||
lines.push(`Version: ${c.version ?? '1'}`);
|
||||
if (chainId != null) {
|
||||
lines.push(`Chain ID: ${chainId}`);
|
||||
}
|
||||
lines.push(`Nonce: ${c.nonce}`);
|
||||
lines.push(`Issued At: ${c.issuedAt}`);
|
||||
if (c.expirationTime != null) {
|
||||
lines.push(`Expiration Time: ${c.expirationTime}`);
|
||||
}
|
||||
if (c.notBefore != null) {
|
||||
lines.push(`Not Before: ${c.notBefore}`);
|
||||
}
|
||||
if (c.requestId != null) {
|
||||
lines.push(`Request ID: ${c.requestId}`);
|
||||
}
|
||||
if (c.resources != null && c.resources.length > 0) {
|
||||
lines.push('Resources:');
|
||||
for (const r of c.resources) {
|
||||
lines.push(`- ${r}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const HEADER_RE = /^(?<domain>[^\n]+?) wants you to sign in with your .+ account:$/;
|
||||
const FIELD_RE = /^(?<key>URI|Version|Chain ID|Nonce|Issued At|Expiration Time|Not Before|Request ID): (?<val>.*)$/;
|
||||
|
||||
/** Parse a CAIP-122 message back into its fields. Throws on malformed input. */
|
||||
export function parseSiwxMessage(message: string): ParsedSiwx {
|
||||
const raw = message.split('\n');
|
||||
if (raw.length < 2) {
|
||||
throw new Error('caip122: message too short');
|
||||
}
|
||||
const header = HEADER_RE.exec(raw[0] ?? '');
|
||||
if (!header?.groups) {
|
||||
throw new Error('caip122: malformed header line');
|
||||
}
|
||||
const domain = header.groups.domain;
|
||||
const address = (raw[1] ?? '').trim();
|
||||
if (address.length === 0) {
|
||||
throw new Error('caip122: missing address line');
|
||||
}
|
||||
|
||||
// Everything from line 2 onward: an optional statement block, then fields.
|
||||
const out: Partial<ParsedSiwx> = { domain, address };
|
||||
const resources: string[] = [];
|
||||
let inResources = false;
|
||||
let statementParts: string[] = [];
|
||||
let sawField = false;
|
||||
|
||||
for (let i = 2; i < raw.length; i++) {
|
||||
const line = raw[i] ?? '';
|
||||
if (inResources) {
|
||||
if (line.startsWith('- ')) {
|
||||
resources.push(line.slice(2));
|
||||
continue;
|
||||
}
|
||||
inResources = false;
|
||||
}
|
||||
if (line === 'Resources:') {
|
||||
inResources = true;
|
||||
sawField = true;
|
||||
continue;
|
||||
}
|
||||
const f = FIELD_RE.exec(line);
|
||||
if (f?.groups) {
|
||||
sawField = true;
|
||||
const v = f.groups.val;
|
||||
switch (f.groups.key) {
|
||||
case 'URI': out.uri = v; break;
|
||||
case 'Version': out.version = v; break;
|
||||
case 'Chain ID': out.chainId = v; break;
|
||||
case 'Nonce': out.nonce = v; break;
|
||||
case 'Issued At': out.issuedAt = v; break;
|
||||
case 'Expiration Time': out.expirationTime = v; break;
|
||||
case 'Not Before': out.notBefore = v; break;
|
||||
case 'Request ID': out.requestId = v; break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Pre-field, non-empty, non-field lines are the statement.
|
||||
if (!sawField && line.length > 0) {
|
||||
statementParts.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (statementParts.length > 0) {
|
||||
out.statement = statementParts.join('\n');
|
||||
}
|
||||
if (resources.length > 0) {
|
||||
out.resources = resources;
|
||||
}
|
||||
|
||||
if (out.uri == null || out.nonce == null || out.issuedAt == null) {
|
||||
throw new Error('caip122: missing required field (URI / Nonce / Issued At)');
|
||||
}
|
||||
return out as ParsedSiwx;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Browser wallet connectors — the client side of @luxwallet/connect.
|
||||
*
|
||||
* One factory, one vocabulary: {@link getConnector} returns the
|
||||
* {@link WalletConnector} for a {@link Chain}; every connector produces a
|
||||
* {@link SignedProof} the matching server-side verifier accepts.
|
||||
*
|
||||
* IMPORTANT: this module (and everything it imports) pulls the wallet libraries
|
||||
* (viem, sats-connect, @tonconnect/sdk, @crossmarkio/sdk). The server verify
|
||||
* path (`@luxwallet/connect/verify`) imports NONE of this — keep it that way.
|
||||
*/
|
||||
import type { Chain, WalletConnector } from './types.js';
|
||||
import { EvmConnector } from './evm/connect.js';
|
||||
import { SolanaConnector } from './solana/connect.js';
|
||||
import { BitcoinConnector } from './bitcoin/connect.js';
|
||||
import { TonConnector, type TonConnectorOptions } from './ton/connect.js';
|
||||
import { XrpConnector } from './xrp/connect.js';
|
||||
|
||||
export { EvmConnector } from './evm/connect.js';
|
||||
export { SolanaConnector } from './solana/connect.js';
|
||||
export { BitcoinConnector } from './bitcoin/connect.js';
|
||||
export { TonConnector, type TonConnectorOptions } from './ton/connect.js';
|
||||
export { XrpConnector } from './xrp/connect.js';
|
||||
|
||||
/** Per-chain construction options. Only TON needs one (its dApp manifest URL). */
|
||||
export interface ConnectorOptions {
|
||||
ton?: TonConnectorOptions;
|
||||
}
|
||||
|
||||
/** Build the connector for a chain. Pure construction — no I/O, no window touch. */
|
||||
export function getConnector(chain: Chain, options: ConnectorOptions = {}): WalletConnector {
|
||||
switch (chain) {
|
||||
case 'evm':
|
||||
return new EvmConnector();
|
||||
case 'solana':
|
||||
return new SolanaConnector();
|
||||
case 'bitcoin':
|
||||
return new BitcoinConnector();
|
||||
case 'ton':
|
||||
return new TonConnector(options.ton);
|
||||
case 'xrp':
|
||||
return new XrpConnector();
|
||||
default: {
|
||||
// Exhaustiveness: a new Chain must be handled here.
|
||||
const _never: never = chain;
|
||||
throw new Error(`no connector for chain '${String(_never)}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One connector per supported chain, in canonical order. */
|
||||
export function allConnectors(options: ConnectorOptions = {}): WalletConnector[] {
|
||||
return [
|
||||
getConnector('evm', options),
|
||||
getConnector('solana', options),
|
||||
getConnector('bitcoin', options),
|
||||
getConnector('ton', options),
|
||||
getConnector('xrp', options),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* EVM wallet connector — EIP-191 `personal_sign` over the CAIP-122 message.
|
||||
*
|
||||
* Discovery: EIP-6963 multi-injection (`window.dispatchEvent` /
|
||||
* `eip6963:requestProvider`) when wallets announce themselves, with a fallback
|
||||
* to the legacy single `window.ethereum`. Connection uses viem's `custom`
|
||||
* transport over the chosen EIP-1193 provider; signing uses `personal_sign`.
|
||||
*
|
||||
* The produced {@link SignedProof} is exactly what {@link verifyEvm} accepts:
|
||||
* a 65-byte hex signature, address recoverable from it, scheme
|
||||
* `secp256k1-eip191`. viem stays out of the verify core (see ../verify.ts) —
|
||||
* it lives here, on the browser side only.
|
||||
*/
|
||||
import {
|
||||
createWalletClient,
|
||||
custom,
|
||||
getAddress as toChecksum,
|
||||
type WalletClient,
|
||||
type EIP1193Provider,
|
||||
} from 'viem';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
|
||||
/** EIP-6963 provider announcement detail. */
|
||||
interface Eip6963ProviderInfo {
|
||||
uuid: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
rdns: string;
|
||||
}
|
||||
interface Eip6963ProviderDetail {
|
||||
info: Eip6963ProviderInfo;
|
||||
provider: EIP1193Provider;
|
||||
}
|
||||
|
||||
interface Eip6963AnnounceEvent extends Event {
|
||||
detail: Eip6963ProviderDetail;
|
||||
}
|
||||
|
||||
/** A discovered injected provider, keyed by a stable id. */
|
||||
interface DiscoveredProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
provider: EIP1193Provider;
|
||||
}
|
||||
|
||||
/** window shape we touch — kept local so the browser libs stay optional. */
|
||||
interface EvmWindow {
|
||||
ethereum?: EIP1193Provider & { providers?: EIP1193Provider[] };
|
||||
addEventListener?: typeof addEventListener;
|
||||
removeEventListener?: typeof removeEventListener;
|
||||
dispatchEvent?: typeof dispatchEvent;
|
||||
}
|
||||
|
||||
function getWindow(): EvmWindow | undefined {
|
||||
return typeof window === 'undefined' ? undefined : (window as unknown as EvmWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect EIP-6963 providers. Wallets respond to `eip6963:requestProvider`
|
||||
* synchronously by dispatching `eip6963:announceProvider`; we listen for a
|
||||
* short window and dedupe by rdns.
|
||||
*/
|
||||
function discoverEip6963(win: EvmWindow, waitMs = 300): Promise<DiscoveredProvider[]> {
|
||||
if (typeof win.addEventListener !== 'function' || typeof win.dispatchEvent !== 'function') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const byRdns = new Map<string, DiscoveredProvider>();
|
||||
const onAnnounce = (ev: Event): void => {
|
||||
const e = ev as Eip6963AnnounceEvent;
|
||||
const d = e.detail;
|
||||
if (d?.info?.rdns && d.provider && !byRdns.has(d.info.rdns)) {
|
||||
byRdns.set(d.info.rdns, {
|
||||
id: d.info.rdns,
|
||||
name: d.info.name,
|
||||
icon: d.info.icon,
|
||||
provider: d.provider,
|
||||
});
|
||||
}
|
||||
};
|
||||
win.addEventListener!('eip6963:announceProvider', onAnnounce as EventListener);
|
||||
win.dispatchEvent!(new Event('eip6963:requestProvider'));
|
||||
setTimeout(() => {
|
||||
win.removeEventListener?.('eip6963:announceProvider', onAnnounce as EventListener);
|
||||
resolve([...byRdns.values()]);
|
||||
}, waitMs);
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy fallback: window.ethereum (and any window.ethereum.providers fan-out). */
|
||||
function discoverLegacy(win: EvmWindow): DiscoveredProvider[] {
|
||||
const eth = win.ethereum;
|
||||
if (!eth) return [];
|
||||
const list = Array.isArray(eth.providers) && eth.providers.length > 0 ? eth.providers : [eth];
|
||||
return list.map((provider, i) => ({
|
||||
id: i === 0 ? 'injected' : `injected-${i}`,
|
||||
name: 'Injected Wallet',
|
||||
provider,
|
||||
}));
|
||||
}
|
||||
|
||||
export class EvmConnector implements WalletConnector {
|
||||
readonly chain = 'evm' as const;
|
||||
|
||||
#provider: EIP1193Provider | null = null;
|
||||
#client: WalletClient | null = null;
|
||||
|
||||
/** Discover injected EVM wallets via EIP-6963, falling back to window.ethereum. */
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
const win = getWindow();
|
||||
if (!win) return [];
|
||||
const discovered = await this.#discover(win);
|
||||
return discovered.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
chain: this.chain,
|
||||
icon: d.icon,
|
||||
installed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
async #discover(win: EvmWindow): Promise<DiscoveredProvider[]> {
|
||||
const announced = await discoverEip6963(win);
|
||||
if (announced.length > 0) return announced;
|
||||
return discoverLegacy(win);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an injected wallet. `walletId` selects an EIP-6963 provider by
|
||||
* its rdns (or the legacy `injected[-n]` id); omit it to use the first.
|
||||
*/
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
const win = getWindow();
|
||||
if (!win) throw new Error('evm: no window — connectors are browser-only');
|
||||
|
||||
const discovered = await this.#discover(win);
|
||||
if (discovered.length === 0) {
|
||||
throw new Error('evm: no injected EVM wallet found');
|
||||
}
|
||||
const chosen = walletId != null ? discovered.find((d) => d.id === walletId) : discovered[0];
|
||||
if (!chosen) {
|
||||
throw new Error(`evm: wallet '${walletId}' not found`);
|
||||
}
|
||||
|
||||
const provider = chosen.provider;
|
||||
const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[];
|
||||
if (!Array.isArray(accounts) || accounts.length === 0) {
|
||||
throw new Error('evm: wallet returned no accounts');
|
||||
}
|
||||
const address = toChecksum(accounts[0]!);
|
||||
|
||||
let caip2: string | undefined;
|
||||
try {
|
||||
const chainIdHex = (await provider.request({ method: 'eth_chainId' })) as string;
|
||||
const chainId = Number.parseInt(chainIdHex, 16);
|
||||
if (Number.isFinite(chainId)) caip2 = `eip155:${chainId}`;
|
||||
} catch {
|
||||
// chainId is best-effort; signing does not require it.
|
||||
}
|
||||
|
||||
this.#provider = provider;
|
||||
this.#client = createWalletClient({ account: address, transport: custom(provider) });
|
||||
|
||||
return { chain: this.chain, address, walletId: chosen.id, caip2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CAIP-122 message and have the wallet `personal_sign` it.
|
||||
* Produces a `secp256k1-eip191` proof: 65-byte hex signature over the EIP-191
|
||||
* digest, with the signer recoverable from the signature.
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
if (!this.#client || !this.#provider) {
|
||||
throw new Error('evm: not connected — call connect() first');
|
||||
}
|
||||
const chainId = account.caip2 ?? undefined;
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
chainId,
|
||||
});
|
||||
|
||||
// personal_sign returns a 0x-prefixed 65-byte signature (r‖s‖v).
|
||||
const signature = await this.#client.signMessage({
|
||||
account: account.address as `0x${string}`,
|
||||
message,
|
||||
});
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: 'secp256k1-eip191',
|
||||
address: account.address,
|
||||
message,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.#provider = null;
|
||||
this.#client = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* EVM verifier — EIP-191 `personal_sign` over the CAIP-122 message.
|
||||
*
|
||||
* Recovers the secp256k1 public key from the signature, derives the Ethereum
|
||||
* address (keccak256 of the uncompressed pubkey, last 20 bytes), and compares
|
||||
* it case-insensitively to the claimed address. No viem dependency — pure
|
||||
* @noble so this mirrors 1:1 in the Go port.
|
||||
*/
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { keccak_256 } from '@noble/hashes/sha3';
|
||||
import { utf8ToBytes, concatBytes, hexToBytes, bytesToHex } from '../bytes.js';
|
||||
|
||||
/** keccak256(\x19Ethereum Signed Message:\n<len><msg>). */
|
||||
export function eip191Digest(message: string): Uint8Array {
|
||||
const msg = utf8ToBytes(message);
|
||||
const prefix = utf8ToBytes(`\x19Ethereum Signed Message:\n${msg.length}`);
|
||||
return keccak_256(concatBytes(prefix, msg));
|
||||
}
|
||||
|
||||
/** Lowercased 0x-address derived from an uncompressed (65-byte) public key. */
|
||||
export function addressFromPublicKey(pubUncompressed: Uint8Array): string {
|
||||
// Drop the 0x04 prefix → 64 bytes, hash, take last 20.
|
||||
const body = pubUncompressed.length === 65 ? pubUncompressed.slice(1) : pubUncompressed;
|
||||
const hash = keccak_256(body);
|
||||
return '0x' + bytesToHex(hash.slice(-20));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an EIP-191 signature. Returns the recovered lowercased address, or
|
||||
* null if the signature is malformed / unrecoverable.
|
||||
*/
|
||||
export function recoverEvmAddress(message: string, signature: string): string | null {
|
||||
try {
|
||||
const sig = hexToBytes(signature);
|
||||
if (sig.length !== 65) return null;
|
||||
const compact = sig.slice(0, 64);
|
||||
let v = sig[64]!;
|
||||
// Accept 27/28 (Ethereum) and raw 0/1 recovery ids.
|
||||
if (v >= 27) v -= 27;
|
||||
if (v !== 0 && v !== 1) return null;
|
||||
const digest = eip191Digest(message);
|
||||
const recovered = secp256k1.Signature.fromCompact(compact)
|
||||
.addRecoveryBit(v)
|
||||
.recoverPublicKey(digest);
|
||||
return addressFromPublicKey(recovered.toRawBytes(false));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff `signature` over `message` was produced by `address`. */
|
||||
export function verifyEvm(message: string, signature: string, address: string): boolean {
|
||||
const recovered = recoverEvmAddress(message, signature);
|
||||
if (recovered == null) return false;
|
||||
return recovered.toLowerCase() === address.trim().toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @luxwallet/connect — multi-chain wallet connect + Sign-In-With-X.
|
||||
*
|
||||
* Public surface. One vocabulary ({@link Chain}, {@link SignedProof}), one
|
||||
* canonical login message (CAIP-122), one verifier ({@link verifyProof}).
|
||||
*
|
||||
* MIT licensed, zero GPL — clean of the Uniswap-derived bones that stay
|
||||
* quarantined in luxfi/exchange.
|
||||
*/
|
||||
export type {
|
||||
Chain,
|
||||
SignatureScheme,
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
VerifyExpectation,
|
||||
VerifyResult,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from './types.js';
|
||||
export { CHAINS } from './types.js';
|
||||
|
||||
export { buildSiwxMessage, parseSiwxMessage } from './caip122.js';
|
||||
export type { ParsedSiwx, BuildParams } from './caip122.js';
|
||||
|
||||
export { generateNonce, newChallenge } from './nonce.js';
|
||||
|
||||
export { verifyProof } from './verify.js';
|
||||
|
||||
// Per-chain primitives (useful standalone; the connectors build on them).
|
||||
export { verifyEvm, recoverEvmAddress, eip191Digest } from './evm/verify.js';
|
||||
export { verifySolana } from './solana/verify.js';
|
||||
export { verifyTon } from './ton/verify.js';
|
||||
export { verifyBitcoin } from './bitcoin/verify.js';
|
||||
export { verifyXrp } from './xrp/verify.js';
|
||||
|
||||
// Browser wallet connectors + the high-level login flow. These import the
|
||||
// wallet libraries (viem, sats-connect, @tonconnect/sdk, @crossmarkio/sdk);
|
||||
// the server verify path above pulls NONE of them.
|
||||
export {
|
||||
getConnector,
|
||||
allConnectors,
|
||||
EvmConnector,
|
||||
SolanaConnector,
|
||||
BitcoinConnector,
|
||||
TonConnector,
|
||||
XrpConnector,
|
||||
} from './connectors.js';
|
||||
export type { ConnectorOptions, TonConnectorOptions } from './connectors.js';
|
||||
export { loginWithWallet } from './login.js';
|
||||
export type { LoginWithWalletParams, LoginResult } from './login.js';
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* loginWithWallet — the one client-side login flow.
|
||||
*
|
||||
* Ties a connector's connect → signLogin into a single call: pick the chain,
|
||||
* connect the wallet, sign the server-issued {@link LoginChallenge}, and return
|
||||
* the {@link SignedProof}. The caller (or its server) mints the challenge and
|
||||
* later verifies the proof with {@link import('./verify.js').verifyProof}.
|
||||
*
|
||||
* server: newChallenge() ─► client: loginWithWallet({chain, challenge})
|
||||
* ─► SignedProof ─► server: verifyProof(proof, {domain, nonce})
|
||||
*
|
||||
* This module imports connectors, so it carries the wallet libs. Keep it out of
|
||||
* the server verify path.
|
||||
*/
|
||||
import type { Account, Chain, LoginChallenge, SignedProof } from './types.js';
|
||||
import { getConnector, type ConnectorOptions } from './connectors.js';
|
||||
|
||||
export interface LoginWithWalletParams {
|
||||
/** Which chain's wallet to authenticate. */
|
||||
chain: Chain;
|
||||
/** Server-minted challenge to sign (domain, nonce, uri, times). */
|
||||
challenge: LoginChallenge;
|
||||
/** Specific wallet id to target (else the connector's default). */
|
||||
walletId?: string;
|
||||
/** Per-chain connector options (e.g. TON manifest URL). */
|
||||
options?: ConnectorOptions;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
account: Account;
|
||||
proof: SignedProof;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a wallet on `chain` and sign `challenge`, returning the connected
|
||||
* account and the {@link SignedProof}. Throws if no wallet is available or the
|
||||
* user rejects; the connector is disconnected on failure to avoid a dangling
|
||||
* session.
|
||||
*/
|
||||
export async function loginWithWallet(params: LoginWithWalletParams): Promise<LoginResult> {
|
||||
const connector = getConnector(params.chain, params.options);
|
||||
try {
|
||||
const account = await connector.connect(params.walletId);
|
||||
const proof = await connector.signLogin(account, params.challenge);
|
||||
return { account, proof };
|
||||
} catch (err) {
|
||||
await connector.disconnect().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Single-use login nonces. The server mints one per challenge, stores it, and
|
||||
* burns it on verify so a captured proof cannot be replayed.
|
||||
*/
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
/** Cryptographically-random alphanumeric nonce (default 16 chars, ~95 bits). */
|
||||
export function generateNonce(length = 16): string {
|
||||
if (length < 8) {
|
||||
throw new Error('nonce: length must be >= 8 (CAIP-122 minimum)');
|
||||
}
|
||||
const bytes = new Uint8Array(length);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
let out = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += ALPHABET[bytes[i]! % ALPHABET.length];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a {@link import('./types.js').LoginChallenge} with sane defaults. */
|
||||
export function newChallenge(opts: {
|
||||
domain: string;
|
||||
uri: string;
|
||||
statement?: string;
|
||||
nonce?: string;
|
||||
/** TTL in seconds for the expirationTime field. Default 600 (10 min). */
|
||||
ttlSeconds?: number;
|
||||
/** Epoch ms for "now"; injectable for tests. */
|
||||
now?: number;
|
||||
requestId?: string;
|
||||
resources?: string[];
|
||||
}) {
|
||||
const nowMs = opts.now ?? Date.now();
|
||||
const issuedAt = new Date(nowMs).toISOString();
|
||||
const ttl = opts.ttlSeconds ?? 600;
|
||||
const expirationTime = new Date(nowMs + ttl * 1000).toISOString();
|
||||
return {
|
||||
domain: opts.domain,
|
||||
uri: opts.uri,
|
||||
statement: opts.statement,
|
||||
nonce: opts.nonce ?? generateNonce(),
|
||||
issuedAt,
|
||||
expirationTime,
|
||||
version: '1',
|
||||
requestId: opts.requestId,
|
||||
resources: opts.resources,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Solana wallet connector — ed25519 `signMessage` over the CAIP-122 message.
|
||||
*
|
||||
* Uses the injected provider directly (Phantom `window.solana`, Solflare
|
||||
* `window.solflare`) — no adapter library needed; the Wallet Standard surface
|
||||
* these expose is a thin `connect()` / `signMessage()` pair. The account
|
||||
* address IS the base58 ed25519 public key, which is exactly what
|
||||
* {@link verifySolana} needs (it decodes the address as the verifying key).
|
||||
*
|
||||
* Produced {@link SignedProof}: scheme `ed25519`, base64 signature over the
|
||||
* raw UTF-8 message bytes, address = base58 public key.
|
||||
*/
|
||||
import bs58 from 'bs58';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { utf8ToBytes, bytesToBase64 } from '../bytes.js';
|
||||
|
||||
/** Minimal shape of an injected Solana provider (Phantom / Solflare / Backpack). */
|
||||
interface SolanaProvider {
|
||||
isPhantom?: boolean;
|
||||
isSolflare?: boolean;
|
||||
isBackpack?: boolean;
|
||||
publicKey?: { toBytes(): Uint8Array; toString(): string } | null;
|
||||
connect(opts?: { onlyIfTrusted?: boolean }): Promise<{ publicKey: { toBytes(): Uint8Array; toString(): string } }>;
|
||||
disconnect?(): Promise<void>;
|
||||
signMessage(message: Uint8Array, encoding?: 'utf8' | 'hex'): Promise<{ signature: Uint8Array } | Uint8Array>;
|
||||
}
|
||||
|
||||
interface SolanaWindow {
|
||||
solana?: SolanaProvider;
|
||||
solflare?: SolanaProvider;
|
||||
backpack?: SolanaProvider;
|
||||
}
|
||||
|
||||
function getWindow(): SolanaWindow | undefined {
|
||||
return typeof window === 'undefined' ? undefined : (window as unknown as SolanaWindow);
|
||||
}
|
||||
|
||||
interface ProviderEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: SolanaProvider;
|
||||
}
|
||||
|
||||
/** Enumerate the injected providers we know how to drive. */
|
||||
function discover(win: SolanaWindow): ProviderEntry[] {
|
||||
const out: ProviderEntry[] = [];
|
||||
if (win.solana) {
|
||||
out.push({ id: win.solana.isPhantom ? 'phantom' : 'solana', name: win.solana.isPhantom ? 'Phantom' : 'Solana', provider: win.solana });
|
||||
}
|
||||
if (win.solflare && win.solflare !== win.solana) {
|
||||
out.push({ id: 'solflare', name: 'Solflare', provider: win.solflare });
|
||||
}
|
||||
if (win.backpack && win.backpack !== win.solana) {
|
||||
out.push({ id: 'backpack', name: 'Backpack', provider: win.backpack });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Normalize the two shapes signMessage can return into raw signature bytes. */
|
||||
function extractSignature(res: { signature: Uint8Array } | Uint8Array): Uint8Array {
|
||||
if (res instanceof Uint8Array) return res;
|
||||
if (res && res.signature instanceof Uint8Array) return res.signature;
|
||||
throw new Error('solana: wallet returned an unrecognized signMessage result');
|
||||
}
|
||||
|
||||
export class SolanaConnector implements WalletConnector {
|
||||
readonly chain = 'solana' as const;
|
||||
|
||||
#provider: SolanaProvider | null = null;
|
||||
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
const win = getWindow();
|
||||
if (!win) return [];
|
||||
return discover(win).map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
chain: this.chain,
|
||||
installed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Connect to an injected wallet (Phantom by default) and return the account. */
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
const win = getWindow();
|
||||
if (!win) throw new Error('solana: no window — connectors are browser-only');
|
||||
|
||||
const entries = discover(win);
|
||||
if (entries.length === 0) throw new Error('solana: no injected Solana wallet found');
|
||||
|
||||
const chosen = walletId != null ? entries.find((e) => e.id === walletId) : entries[0];
|
||||
if (!chosen) throw new Error(`solana: wallet '${walletId}' not found`);
|
||||
|
||||
const { publicKey } = await chosen.provider.connect();
|
||||
const address = bs58.encode(publicKey.toBytes());
|
||||
|
||||
this.#provider = chosen.provider;
|
||||
// For Solana the address IS the base58 ed25519 public key — one value.
|
||||
return { chain: this.chain, address, publicKey: address, walletId: chosen.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CAIP-122 message and have the wallet sign its UTF-8 bytes.
|
||||
* Produces an `ed25519` proof whose signature {@link verifySolana} checks
|
||||
* against the base58 address (the public key).
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
if (!this.#provider) throw new Error('solana: not connected — call connect() first');
|
||||
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
});
|
||||
const res = await this.#provider.signMessage(utf8ToBytes(message), 'utf8');
|
||||
const signature = bytesToBase64(extractSignature(res));
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: 'ed25519',
|
||||
address: account.address,
|
||||
message,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
try {
|
||||
await this.#provider?.disconnect?.();
|
||||
} finally {
|
||||
this.#provider = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Solana verifier — ed25519 over the raw UTF-8 CAIP-122 message
|
||||
* (the bytes a wallet's `signMessage` returns). The account address IS the
|
||||
* base58-encoded ed25519 public key, so the key needed to verify is the
|
||||
* address itself.
|
||||
*/
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import bs58 from 'bs58';
|
||||
import { utf8ToBytes, decodeSignature } from '../bytes.js';
|
||||
|
||||
/** True iff `signature` over `message` was produced by the key behind `address`. */
|
||||
export function verifySolana(message: string, signature: string, address: string): boolean {
|
||||
try {
|
||||
const pub = bs58.decode(address.trim());
|
||||
if (pub.length !== 32) return false;
|
||||
const sig = decodeSignature(signature);
|
||||
if (sig.length !== 64) return false;
|
||||
return ed25519.verify(sig, utf8ToBytes(message), pub);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* TON wallet connector — TON Connect `ton_proof` (ed25519).
|
||||
*
|
||||
* TON differs from text-signing chains: the wallet does not sign the CAIP-122
|
||||
* string. It signs a structured `ton_proof` envelope whose `payload` we pin to
|
||||
* the server nonce, and returns the ed25519 signature plus the domain /
|
||||
* timestamp it bound in. {@link verifyTon} reconstructs that envelope and
|
||||
* checks the signature, then binds it back to the CAIP-122 message
|
||||
* (nonce === payload, address === signer).
|
||||
*
|
||||
* Because TON Connect binds the proof payload at connect time, `signLogin`
|
||||
* re-runs the connect handshake with `tonProof: challenge.nonce` and waits for
|
||||
* the wallet's `ton_proof` reply. The produced {@link SignedProof} carries:
|
||||
* - scheme `ton-proof`
|
||||
* - publicKey: ed25519 key, hex
|
||||
* - signature: base64
|
||||
* - extra: { timestamp, domain, payload, workchain, addressHashHex }
|
||||
*/
|
||||
import TonConnect, {
|
||||
isWalletInfoCurrentlyInjected,
|
||||
type Wallet,
|
||||
type WalletInfo as TonWalletInfo,
|
||||
type TonProofItemReply,
|
||||
} from '@tonconnect/sdk';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
|
||||
/** Default manifest URL used when the host page does not supply one. */
|
||||
const DEFAULT_MANIFEST = 'https://hanzo.id/tonconnect-manifest.json';
|
||||
|
||||
export interface TonConnectorOptions {
|
||||
/** TON Connect dApp manifest URL (defaults to hanzo.id's). */
|
||||
manifestUrl?: string;
|
||||
}
|
||||
|
||||
/** A successful ton_proof reply (the only variant we accept). */
|
||||
function readProof(reply: TonProofItemReply | undefined): {
|
||||
timestamp: number;
|
||||
domain: string;
|
||||
payload: string;
|
||||
signature: string;
|
||||
} | null {
|
||||
if (!reply || reply.name !== 'ton_proof' || !('proof' in reply)) return null;
|
||||
const p = reply.proof;
|
||||
return { timestamp: p.timestamp, domain: p.domain.value, payload: p.payload, signature: p.signature };
|
||||
}
|
||||
|
||||
/** Split a raw TON address `"<workchain>:<hex>"` into its parts. */
|
||||
function parseRawAddress(address: string): { workchain: number; addressHashHex: string } | null {
|
||||
const i = address.indexOf(':');
|
||||
if (i < 0) return null;
|
||||
const workchain = Number.parseInt(address.slice(0, i), 10);
|
||||
const addressHashHex = address.slice(i + 1);
|
||||
if (!Number.isInteger(workchain)) return null;
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(addressHashHex)) return null;
|
||||
return { workchain, addressHashHex };
|
||||
}
|
||||
|
||||
export class TonConnector implements WalletConnector {
|
||||
readonly chain = 'ton' as const;
|
||||
|
||||
readonly #manifestUrl: string;
|
||||
#connector: TonConnect | null = null;
|
||||
|
||||
constructor(options: TonConnectorOptions = {}) {
|
||||
// Pure construction: TonConnect's constructor touches localStorage, so it is
|
||||
// created lazily on first use (in a browser) rather than here.
|
||||
this.#manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST;
|
||||
}
|
||||
|
||||
/** Lazily build the underlying TonConnect (requires a browser w/ localStorage). */
|
||||
#sdk(): TonConnect {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('ton: no window — connectors are browser-only');
|
||||
}
|
||||
if (!this.#connector) {
|
||||
this.#connector = new TonConnect({ manifestUrl: this.#manifestUrl });
|
||||
}
|
||||
return this.#connector;
|
||||
}
|
||||
|
||||
/** List injected TON wallets (Tonkeeper, MyTonWallet, …) detected on the page. */
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const wallets = await this.#sdk().getWallets();
|
||||
return wallets.filter(isWalletInfoCurrentlyInjected).map((w: TonWalletInfo) => ({
|
||||
id: (w as { jsBridgeKey: string }).jsBridgeKey,
|
||||
name: w.name,
|
||||
chain: this.chain,
|
||||
icon: w.imageUrl,
|
||||
installed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish a session (no proof yet) and return the account. `walletId` is the
|
||||
* wallet's `jsBridgeKey`; omit it to use the first injected wallet.
|
||||
*/
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
const wallet = await this.#handshake(walletId);
|
||||
return this.#toAccount(wallet, walletId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the handshake with `tonProof: nonce`, wait for the wallet's signed
|
||||
* envelope, and assemble the {@link SignedProof} {@link verifyTon} accepts.
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
const parsed = parseRawAddress(account.address);
|
||||
if (!parsed) throw new Error(`ton: address '${account.address}' is not raw '<wc>:<hex>' form`);
|
||||
|
||||
// Bind the ton_proof payload to the server nonce.
|
||||
const wallet = await this.#handshake(account.walletId, challenge.nonce);
|
||||
|
||||
const publicKey = wallet.account.publicKey;
|
||||
if (!publicKey) throw new Error('ton: wallet did not return a public key');
|
||||
|
||||
const proof = readProof(wallet.connectItems?.tonProof);
|
||||
if (!proof) throw new Error('ton: wallet did not return a ton_proof');
|
||||
if (proof.payload !== challenge.nonce) {
|
||||
throw new Error('ton: wallet signed a different payload than the requested nonce');
|
||||
}
|
||||
|
||||
// CAIP-122 message: address line is the raw TON address; its Nonce equals
|
||||
// the ton_proof payload (the verifier enforces both bindings).
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
});
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: 'ton-proof',
|
||||
address: account.address,
|
||||
publicKey,
|
||||
message,
|
||||
signature: proof.signature,
|
||||
extra: {
|
||||
timestamp: proof.timestamp,
|
||||
domain: proof.domain,
|
||||
payload: proof.payload,
|
||||
workchain: parsed.workchain,
|
||||
addressHashHex: parsed.addressHashHex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
// Only act if the SDK was ever created (avoids touching localStorage in SSR).
|
||||
if (this.#connector?.connected) await this.#connector.disconnect();
|
||||
}
|
||||
|
||||
/** Resolve the chosen injected wallet's jsBridgeKey. */
|
||||
async #resolveBridgeKey(walletId?: string): Promise<string> {
|
||||
if (walletId != null) return walletId;
|
||||
const wallets = (await this.#sdk().getWallets()).filter(isWalletInfoCurrentlyInjected);
|
||||
const first = wallets[0] as { jsBridgeKey?: string } | undefined;
|
||||
if (!first?.jsBridgeKey) throw new Error('ton: no injected TON wallet found');
|
||||
return first.jsBridgeKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive one connect handshake and resolve with the resulting Wallet. When
|
||||
* `proofPayload` is given, the wallet returns a ton_proof bound to it.
|
||||
*/
|
||||
async #handshake(walletId?: string, proofPayload?: string): Promise<Wallet> {
|
||||
const sdk = this.#sdk(); // throws outside a browser
|
||||
const jsBridgeKey = await this.#resolveBridgeKey(walletId);
|
||||
|
||||
return new Promise<Wallet>((resolve, reject) => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
const done = (fn: () => void): void => {
|
||||
unsubscribe?.();
|
||||
fn();
|
||||
};
|
||||
unsubscribe = sdk.onStatusChange(
|
||||
(wallet) => {
|
||||
if (wallet) done(() => resolve(wallet));
|
||||
},
|
||||
(err) => done(() => reject(err)),
|
||||
);
|
||||
try {
|
||||
// Injected connect returns void; the reply arrives via onStatusChange.
|
||||
sdk.connect(
|
||||
{ jsBridgeKey },
|
||||
proofPayload != null ? { tonProof: proofPayload } : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
done(() => reject(err instanceof Error ? err : new Error(String(err))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#toAccount(wallet: Wallet, walletId?: string): Account {
|
||||
return {
|
||||
chain: this.chain,
|
||||
address: wallet.account.address,
|
||||
publicKey: wallet.account.publicKey,
|
||||
walletId: walletId ?? wallet.device.appName,
|
||||
caip2: `ton:${wallet.account.chain}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* TON verifier — TON Connect `ton_proof` (ed25519).
|
||||
*
|
||||
* Unlike text-signing chains, TON signs a structured `ton_proof` envelope, not
|
||||
* the CAIP-122 string. The connector carries the envelope in `proof.extra` and
|
||||
* the wallet public key in `proof.publicKey`; this verifier reconstructs the
|
||||
* ton_proof signing message exactly as the TON Connect spec defines it, checks
|
||||
* the ed25519 signature over the double-SHA-256 digest, and binds the envelope
|
||||
* to the CAIP-122 login message (nonce == payload, address == signer).
|
||||
*
|
||||
* Reference (TON Connect ton_proof):
|
||||
* message = "ton-proof-item-v2/"
|
||||
* ‖ int32BE(workchain)
|
||||
* ‖ addressHash(32)
|
||||
* ‖ uint32LE(len(domain))
|
||||
* ‖ domain
|
||||
* ‖ uint64LE(timestamp)
|
||||
* ‖ payload
|
||||
* signed = sha256( 0xffff ‖ "ton-connect" ‖ sha256(message) )
|
||||
* ok = ed25519.verify(signature, signed, publicKey)
|
||||
*
|
||||
* Pure: no I/O, no network, no clock. Fails closed — any malformed or missing
|
||||
* field returns false; never throws.
|
||||
*/
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import type { SignedProof } from '../types.js';
|
||||
import { parseSiwxMessage } from '../caip122.js';
|
||||
import { hexToBytes, base64ToBytes, utf8ToBytes, concatBytes } from '../bytes.js';
|
||||
|
||||
/** ton_proof static prefixes (TON Connect v2). */
|
||||
const PROOF_PREFIX = utf8ToBytes('ton-proof-item-v2/');
|
||||
const CONNECT_PREFIX = utf8ToBytes('ton-connect');
|
||||
|
||||
/** ed25519 public keys are 32 bytes; signatures are 64 bytes; addr hash 32. */
|
||||
const ED25519_PUBKEY_LEN = 32;
|
||||
const ED25519_SIG_LEN = 64;
|
||||
const ADDR_HASH_LEN = 32;
|
||||
|
||||
/** The `extra` envelope a TON connector attaches to a ton_proof. */
|
||||
interface TonProofExtra {
|
||||
timestamp: number;
|
||||
domain: string;
|
||||
payload: string;
|
||||
workchain: number;
|
||||
addressHashHex: string;
|
||||
}
|
||||
|
||||
/** Narrow `proof.extra` to the ton_proof envelope, validating field shapes. */
|
||||
function readExtra(extra: unknown): TonProofExtra | null {
|
||||
if (extra == null || typeof extra !== 'object') return null;
|
||||
const e = extra as Record<string, unknown>;
|
||||
const { timestamp, domain, payload, workchain, addressHashHex } = e;
|
||||
// timestamp: a finite, non-negative integer number of unix seconds.
|
||||
if (typeof timestamp !== 'number' || !Number.isInteger(timestamp) || timestamp < 0) return null;
|
||||
// workchain: a finite integer (0 = basechain, -1 = masterchain typically).
|
||||
if (typeof workchain !== 'number' || !Number.isInteger(workchain)) return null;
|
||||
if (typeof domain !== 'string') return null;
|
||||
if (typeof payload !== 'string') return null;
|
||||
if (typeof addressHashHex !== 'string') return null;
|
||||
return { timestamp, domain, payload, workchain, addressHashHex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the ton_proof message body that the wallet hashed:
|
||||
* "ton-proof-item-v2/" ‖ int32BE(wc) ‖ addrHash ‖ uint32LE(|domain|) ‖ domain
|
||||
* ‖ uint64LE(ts) ‖ payload
|
||||
*
|
||||
* Integer widths/endianness are spec-exact:
|
||||
* - workchain: 4 bytes, big-endian, SIGNED (so -1 → 0xFFFFFFFF).
|
||||
* - domain length: 4 bytes, little-endian, the UTF-8 BYTE length.
|
||||
* - timestamp: 8 bytes, little-endian (BigInt to span > 2^53 safely).
|
||||
*/
|
||||
function buildProofMessage(
|
||||
workchain: number,
|
||||
addressHash: Uint8Array,
|
||||
domainBytes: Uint8Array,
|
||||
timestamp: number,
|
||||
payloadBytes: Uint8Array,
|
||||
): Uint8Array {
|
||||
// workchain — int32 big-endian (signed two's-complement via setInt32).
|
||||
const wc = new Uint8Array(4);
|
||||
new DataView(wc.buffer).setInt32(0, workchain, /* littleEndian */ false);
|
||||
|
||||
// domain length — uint32 little-endian over the UTF-8 byte length.
|
||||
const dlen = new Uint8Array(4);
|
||||
new DataView(dlen.buffer).setUint32(0, domainBytes.length, /* littleEndian */ true);
|
||||
|
||||
// timestamp — uint64 little-endian.
|
||||
const ts = new Uint8Array(8);
|
||||
new DataView(ts.buffer).setBigUint64(0, BigInt(timestamp), /* littleEndian */ true);
|
||||
|
||||
return concatBytes(PROOF_PREFIX, wc, addressHash, dlen, domainBytes, ts, payloadBytes);
|
||||
}
|
||||
|
||||
/** TON Connect's full pre-image and the double hash that ed25519 actually signs. */
|
||||
function proofDigest(message: Uint8Array): Uint8Array {
|
||||
// fullMsg = 0xff 0xff ‖ "ton-connect" ‖ sha256(message)
|
||||
const fullMsg = concatBytes(Uint8Array.of(0xff, 0xff), CONNECT_PREFIX, sha256(message));
|
||||
return sha256(fullMsg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a TON Connect `ton_proof` login proof.
|
||||
*
|
||||
* @returns true iff the ed25519 signature is valid over the reconstructed
|
||||
* ton_proof digest AND the envelope is bound to the CAIP-122 message
|
||||
* (nonce == payload, address == signer). Any other condition → false.
|
||||
*/
|
||||
export function verifyTon(proof: SignedProof): boolean {
|
||||
try {
|
||||
// --- 0. Structural presence: scheme, public key, signature, envelope. ---
|
||||
if (proof.scheme !== 'ton-proof') return false;
|
||||
if (typeof proof.publicKey !== 'string' || proof.publicKey.length === 0) return false;
|
||||
if (typeof proof.signature !== 'string' || proof.signature.length === 0) return false;
|
||||
if (typeof proof.message !== 'string' || proof.message.length === 0) return false;
|
||||
if (typeof proof.address !== 'string' || proof.address.length === 0) return false;
|
||||
|
||||
const extra = readExtra(proof.extra);
|
||||
if (extra === null) return false;
|
||||
|
||||
// --- 1. Binding to the CAIP-122 login message (anti-replay, anti-phishing).
|
||||
// parseSiwxMessage throws on malformed input; the try/catch fails closed.
|
||||
const parsed = parseSiwxMessage(proof.message);
|
||||
// The signed payload MUST be the server-minted nonce carried in the SIWx msg.
|
||||
if (parsed.nonce !== extra.payload) return false;
|
||||
// The signer MUST be the address embedded in the message.
|
||||
if (parsed.address !== proof.address) return false;
|
||||
|
||||
// --- 2. Decode + length-check the fixed-width cryptographic material. ---
|
||||
const publicKey = hexToBytes(proof.publicKey);
|
||||
if (publicKey.length !== ED25519_PUBKEY_LEN) return false;
|
||||
|
||||
const signature = base64ToBytes(proof.signature);
|
||||
if (signature.length !== ED25519_SIG_LEN) return false;
|
||||
|
||||
const addressHash = hexToBytes(extra.addressHashHex);
|
||||
if (addressHash.length !== ADDR_HASH_LEN) return false;
|
||||
|
||||
// --- 3. Reconstruct the ton_proof message and the digest the wallet signed.
|
||||
const domainBytes = utf8ToBytes(extra.domain);
|
||||
const payloadBytes = utf8ToBytes(extra.payload);
|
||||
const message = buildProofMessage(
|
||||
extra.workchain,
|
||||
addressHash,
|
||||
domainBytes,
|
||||
extra.timestamp,
|
||||
payloadBytes,
|
||||
);
|
||||
const digest = proofDigest(message);
|
||||
|
||||
// --- 4. ed25519 signature check over the 32-byte digest. ---
|
||||
return ed25519.verify(signature, digest, publicKey);
|
||||
} catch {
|
||||
// Bad hex/base64, malformed SIWx, etc. — fail closed.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @luxwallet/connect — core types.
|
||||
*
|
||||
* One vocabulary across every chain. A wallet on any chain produces a
|
||||
* {@link SignedProof}; the server verifies it with one {@link verifyProof}
|
||||
* call. The login message itself is chain-agnostic (CAIP-122 "Sign-In-With-X").
|
||||
*/
|
||||
|
||||
/** Supported chain families. Values, not places — namespaced by this union. */
|
||||
export type Chain = 'evm' | 'solana' | 'bitcoin' | 'ton' | 'xrp';
|
||||
|
||||
export const CHAINS: readonly Chain[] = ['evm', 'solana', 'bitcoin', 'ton', 'xrp'];
|
||||
|
||||
/**
|
||||
* Signature scheme used to produce a proof. The verifier dispatches on this,
|
||||
* not on {@link Chain}, so a chain could in principle offer more than one.
|
||||
*/
|
||||
export type SignatureScheme =
|
||||
| 'secp256k1-eip191' // EVM personal_sign (EIP-191)
|
||||
| 'ed25519' // Solana, TON
|
||||
| 'bip322' // Bitcoin message signing (BIP-322)
|
||||
| 'ton-proof' // TON Connect ton_proof envelope (ed25519 inside)
|
||||
| 'secp256k1-xrpl' // XRPL signMessage
|
||||
| 'ed25519-xrpl'; // XRPL ed25519 keypair
|
||||
|
||||
/** A connected wallet account. `publicKey` is required where the address is not recoverable from the signature (Solana, TON, XRP). */
|
||||
export interface Account {
|
||||
chain: Chain;
|
||||
/** Canonical address string for the chain (checksum EVM, base58 Solana, etc.). */
|
||||
address: string;
|
||||
/** Raw public key, hex (no 0x) or base64 — needed by ed25519/XRPL verifiers. */
|
||||
publicKey?: string;
|
||||
/** Identifier of the wallet that produced it (e.g. 'metamask', 'phantom'). */
|
||||
walletId: string;
|
||||
/** CAIP-2 chain id of the specific network, when known (e.g. 'eip155:1'). */
|
||||
caip2?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The login challenge a server asks a wallet to sign. Mirrors EIP-4361 /
|
||||
* CAIP-122 fields. The server mints `nonce` and stores it until verification.
|
||||
*/
|
||||
export interface LoginChallenge {
|
||||
/** RFC 4501 dnsauthority that is requesting the signing (e.g. 'hanzo.id'). */
|
||||
domain: string;
|
||||
/** RFC 3986 URI referring to the resource that is the subject of the signing. */
|
||||
uri: string;
|
||||
/** Human-readable assertion the user signs (one line, no newlines). */
|
||||
statement?: string;
|
||||
/** Server-minted single-use nonce (>= 8 alphanumerics). */
|
||||
nonce: string;
|
||||
/** ISO-8601 issuance time. */
|
||||
issuedAt: string;
|
||||
/** ISO-8601 expiry; after this the proof is rejected. */
|
||||
expirationTime?: string;
|
||||
/** ISO-8601 not-before; before this the proof is rejected. */
|
||||
notBefore?: string;
|
||||
/** Opaque request correlation id. */
|
||||
requestId?: string;
|
||||
/** Version of the message spec; '1' for CAIP-122/EIP-4361. */
|
||||
version?: string;
|
||||
/** Resource URIs the sign-in grants access to. */
|
||||
resources?: string[];
|
||||
}
|
||||
|
||||
/** What a wallet hands back after signing — everything a server needs to verify. */
|
||||
export interface SignedProof {
|
||||
chain: Chain;
|
||||
scheme: SignatureScheme;
|
||||
/** Address that signed (must match the address embedded in `message`). */
|
||||
address: string;
|
||||
/** Public key (hex/base64) when required by the scheme. */
|
||||
publicKey?: string;
|
||||
/** The exact UTF-8 string that was signed (the rendered CAIP-122 message). */
|
||||
message: string;
|
||||
/** Signature bytes, hex (0x-prefixed allowed) or base64 per scheme. */
|
||||
signature: string;
|
||||
/** Scheme-specific extra fields (e.g. TON proof envelope, BTC address type). */
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Server-side expectations checked against the parsed message during verify. */
|
||||
export interface VerifyExpectation {
|
||||
/** Must equal the message `domain`. */
|
||||
domain: string;
|
||||
/** Must equal the message `nonce` (single-use; server also burns it). */
|
||||
nonce: string;
|
||||
/** Optional: require an exact address (case-insensitive for EVM). */
|
||||
address?: string;
|
||||
/** Override "now" for deterministic tests (epoch ms). */
|
||||
now?: number;
|
||||
/** Max clock skew tolerated on issuedAt/notBefore, ms. Default 5 min. */
|
||||
clockSkewMs?: number;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
ok: boolean;
|
||||
/** Present when ok=false: machine-readable reason. */
|
||||
reason?:
|
||||
| 'bad-signature'
|
||||
| 'address-mismatch'
|
||||
| 'domain-mismatch'
|
||||
| 'nonce-mismatch'
|
||||
| 'expired'
|
||||
| 'not-yet-valid'
|
||||
| 'malformed-message'
|
||||
| 'unsupported-scheme'
|
||||
| 'missing-public-key';
|
||||
/** The verified address (canonicalized) when ok=true. */
|
||||
address?: string;
|
||||
chain?: Chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-chain connector. Browser/runtime side only — the verifier never needs
|
||||
* it. Implementations live under src/<chain>/.
|
||||
*/
|
||||
export interface WalletConnector {
|
||||
readonly chain: Chain;
|
||||
/** Wallets this connector can discover/offer in the current runtime. */
|
||||
available(): Promise<WalletInfo[]>;
|
||||
/** Connect (optionally to a specific wallet) and return the active account. */
|
||||
connect(walletId?: string): Promise<Account>;
|
||||
/** Render the challenge to the canonical message and have the wallet sign it. */
|
||||
signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof>;
|
||||
/** Disconnect / forget the session. */
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WalletInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
chain: Chain;
|
||||
/** Data URI or URL to the wallet icon. */
|
||||
icon?: string;
|
||||
/** True if detected/installed in the current runtime. */
|
||||
installed: boolean;
|
||||
/** Where to get it if not installed. */
|
||||
downloadUrl?: string;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* verifyProof — the one server-side entry point. Chain-agnostic: parse the
|
||||
* CAIP-122 message, enforce domain/nonce/time, then dispatch to the per-chain
|
||||
* cryptographic verifier. Fails closed: any unknown scheme or malformed input
|
||||
* returns `{ ok: false, reason }`, never throws.
|
||||
*
|
||||
* This pure function is mirrored by the Go port in go/walletconnect so IAM
|
||||
* verifies identically.
|
||||
*/
|
||||
import type { SignedProof, VerifyExpectation, VerifyResult, Chain } from './types.js';
|
||||
import { parseSiwxMessage } from './caip122.js';
|
||||
import { verifyEvm } from './evm/verify.js';
|
||||
import { verifySolana } from './solana/verify.js';
|
||||
import { verifyTon } from './ton/verify.js';
|
||||
import { verifyBitcoin } from './bitcoin/verify.js';
|
||||
import { verifyXrp } from './xrp/verify.js';
|
||||
|
||||
const DEFAULT_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
function fail(reason: NonNullable<VerifyResult['reason']>): VerifyResult {
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
/** Case-insensitive only for EVM (checksummed hex); all others are exact. */
|
||||
function addressesEqual(chain: Chain, a: string, b: string): boolean {
|
||||
const x = a.trim();
|
||||
const y = b.trim();
|
||||
return chain === 'evm' ? x.toLowerCase() === y.toLowerCase() : x === y;
|
||||
}
|
||||
|
||||
function parseTime(s: string | undefined): number | null {
|
||||
if (s == null) return null;
|
||||
const t = Date.parse(s);
|
||||
return Number.isNaN(t) ? null : t;
|
||||
}
|
||||
|
||||
/** Cryptographic dispatch. Returns null for not-yet-supported schemes. */
|
||||
function verifyCrypto(proof: SignedProof): boolean | null {
|
||||
switch (proof.scheme) {
|
||||
case 'secp256k1-eip191':
|
||||
return verifyEvm(proof.message, proof.signature, proof.address);
|
||||
case 'ed25519':
|
||||
// ed25519-over-message is Solana today; TON uses 'ton-proof'.
|
||||
return verifySolana(proof.message, proof.signature, proof.address);
|
||||
case 'ton-proof':
|
||||
return verifyTon(proof);
|
||||
case 'bip322':
|
||||
return verifyBitcoin(proof);
|
||||
case 'secp256k1-xrpl':
|
||||
case 'ed25519-xrpl':
|
||||
return verifyXrp(proof);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyProof(proof: SignedProof, expected: VerifyExpectation): VerifyResult {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseSiwxMessage(proof.message);
|
||||
} catch {
|
||||
return fail('malformed-message');
|
||||
}
|
||||
|
||||
// 1. Binding: the signer in the message must match the proof's address.
|
||||
if (!addressesEqual(proof.chain, parsed.address, proof.address)) {
|
||||
return fail('address-mismatch');
|
||||
}
|
||||
if (expected.address != null && !addressesEqual(proof.chain, proof.address, expected.address)) {
|
||||
return fail('address-mismatch');
|
||||
}
|
||||
|
||||
// 2. Domain + nonce binding (anti-phishing, anti-replay).
|
||||
if (parsed.domain !== expected.domain) {
|
||||
return fail('domain-mismatch');
|
||||
}
|
||||
if (parsed.nonce !== expected.nonce) {
|
||||
return fail('nonce-mismatch');
|
||||
}
|
||||
|
||||
// 3. Time window.
|
||||
const now = expected.now ?? Date.now();
|
||||
const skew = expected.clockSkewMs ?? DEFAULT_SKEW_MS;
|
||||
const exp = parseTime(parsed.expirationTime);
|
||||
if (exp != null && now > exp + skew) {
|
||||
return fail('expired');
|
||||
}
|
||||
const nbf = parseTime(parsed.notBefore);
|
||||
if (nbf != null && now + skew < nbf) {
|
||||
return fail('not-yet-valid');
|
||||
}
|
||||
const iat = parseTime(parsed.issuedAt);
|
||||
if (iat != null && iat - skew > now) {
|
||||
return fail('not-yet-valid');
|
||||
}
|
||||
|
||||
// 4. Cryptographic signature.
|
||||
const crypto = verifyCrypto(proof);
|
||||
if (crypto == null) {
|
||||
return fail('unsupported-scheme');
|
||||
}
|
||||
if (!crypto) {
|
||||
return fail('bad-signature');
|
||||
}
|
||||
|
||||
return { ok: true, address: proof.address, chain: proof.chain };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* XRP (XRP Ledger) wallet connector — Crossmark (`@crossmarkio/sdk`, MIT).
|
||||
*
|
||||
* Crossmark's `signInAndWait(hex)` performs a sign-in that also signs the hex
|
||||
* bytes we pass, returning the account's r-address, its 33-byte public key
|
||||
* (hex, with the XRPL family tag — `0xED` for ed25519, `0x02/0x03` for
|
||||
* secp256k1), and the signature. {@link verifyXrp} digests the CAIP-122 message
|
||||
* the same way XRPL does and checks the signature under that key, then binds
|
||||
* the key to the r-address.
|
||||
*
|
||||
* The signing input must be the CAIP-122 message bytes: we pass
|
||||
* `hex(utf8(message))` to the wallet and set `proof.message` to the same
|
||||
* string, so the verifier's `utf8ToBytes(proof.message)` digest matches what
|
||||
* the wallet signed. The scheme is chosen from the public key's family tag.
|
||||
*
|
||||
* GemWallet is intentionally NOT wired: its only client (`@gemwallet/api`) ships
|
||||
* under a custom dual license that requires GemWallet's permission for
|
||||
* public/commercial use — incompatible with this package's MIT/Apache/ISC-only
|
||||
* rule. Crossmark covers both XRPL key types, so the XRP path stays complete.
|
||||
*/
|
||||
import sdk from '@crossmarkio/sdk';
|
||||
import type {
|
||||
Account,
|
||||
LoginChallenge,
|
||||
SignedProof,
|
||||
SignatureScheme,
|
||||
WalletConnector,
|
||||
WalletInfo,
|
||||
} from '../types.js';
|
||||
import { buildSiwxMessage } from '../caip122.js';
|
||||
import { utf8ToBytes, bytesToHex } from '../bytes.js';
|
||||
|
||||
/** XRPL public-key family tag → signature scheme. */
|
||||
function schemeForPublicKey(publicKeyHex: string): SignatureScheme {
|
||||
const tag = publicKeyHex.slice(0, 2).toLowerCase();
|
||||
return tag === 'ed' ? 'ed25519-xrpl' : 'secp256k1-xrpl';
|
||||
}
|
||||
|
||||
export class XrpConnector implements WalletConnector {
|
||||
readonly chain = 'xrp' as const;
|
||||
|
||||
#publicKey: string | null = null;
|
||||
|
||||
/** Crossmark is the supported XRP wallet; report it when installed. */
|
||||
async available(): Promise<WalletInfo[]> {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const installed = sdk.sync.isInstalled() === true;
|
||||
return [
|
||||
{
|
||||
id: 'crossmark',
|
||||
name: 'Crossmark',
|
||||
chain: this.chain,
|
||||
installed,
|
||||
downloadUrl: installed ? undefined : 'https://crossmark.io',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect via Crossmark sign-in. We do a bare sign-in here to capture the
|
||||
* address + public key; the actual login signature is produced in signLogin.
|
||||
*/
|
||||
async connect(walletId?: string): Promise<Account> {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('xrp: no window — connectors are browser-only');
|
||||
}
|
||||
if (walletId != null && walletId !== 'crossmark') {
|
||||
throw new Error(`xrp: unsupported wallet '${walletId}' (only 'crossmark')`);
|
||||
}
|
||||
|
||||
const res = await sdk.async.signInAndWait();
|
||||
const data = res?.response?.data;
|
||||
if (!data?.address || !data?.publicKey) {
|
||||
throw new Error('xrp: Crossmark sign-in returned no address/public key');
|
||||
}
|
||||
|
||||
this.#publicKey = data.publicKey;
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
address: data.address,
|
||||
publicKey: data.publicKey,
|
||||
walletId: 'crossmark',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CAIP-122 message, have Crossmark sign its UTF-8 bytes (passed as
|
||||
* hex), and assemble a proof under the key's scheme. The signature lands in
|
||||
* the sign-in response's `signature` field when a hex challenge is supplied.
|
||||
*/
|
||||
async signLogin(account: Account, challenge: LoginChallenge): Promise<SignedProof> {
|
||||
if (!this.#publicKey) throw new Error('xrp: not connected — call connect() first');
|
||||
|
||||
const message = buildSiwxMessage({
|
||||
challenge,
|
||||
address: account.address,
|
||||
chain: this.chain,
|
||||
});
|
||||
const hex = bytesToHex(utf8ToBytes(message));
|
||||
|
||||
const res = await sdk.async.signInAndWait(hex);
|
||||
const data = res?.response?.data;
|
||||
if (!data?.signature) {
|
||||
throw new Error('xrp: Crossmark did not return a signature');
|
||||
}
|
||||
// The public key may refine on the signing response; prefer it if present.
|
||||
const publicKey = data.publicKey ?? this.#publicKey;
|
||||
|
||||
return {
|
||||
chain: this.chain,
|
||||
scheme: schemeForPublicKey(publicKey),
|
||||
address: account.address,
|
||||
publicKey,
|
||||
message,
|
||||
signature: data.signature,
|
||||
};
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.#publicKey = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* XRP (XRP Ledger) verifier — wallet login-message signatures.
|
||||
*
|
||||
* XRPL accounts use either a secp256k1 or an ed25519 keypair. The connector
|
||||
* carries the public key in `proof.publicKey` (33 bytes in XRPL's canonical
|
||||
* form). This verifier does two independent checks, both of which must hold:
|
||||
*
|
||||
* 1. Signature: the signature is valid over the CAIP-122 message under the
|
||||
* declared key, using XRPL's signing convention for the scheme.
|
||||
* - ed25519-xrpl : raw EdDSA over the UTF-8 message bytes.
|
||||
* - secp256k1-xrpl : ECDSA over the "sha512half" digest
|
||||
* (first 32 bytes of SHA-512 of the message), DER-encoded.
|
||||
* 2. Address binding: the public key derives the claimed r-address via the
|
||||
* standard XRPL AccountID derivation (RIPEMD160(SHA256(pubkey)) under the
|
||||
* 0x00 account prefix, base58check with the XRPL alphabet).
|
||||
*
|
||||
* Decomplected: signature verification and address binding are separate,
|
||||
* each complete on its own. Fails closed — every error path returns false,
|
||||
* nothing throws. Pure: no I/O, no clock. Mirrors 1:1 in the Go port.
|
||||
*
|
||||
* Refs:
|
||||
* - https://xrpl.org/cryptographic-keys.html (key prefixes, AccountID)
|
||||
* - https://xrpl.org/base58-encodings.html (XRPL base58 alphabet, type prefix)
|
||||
* - https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md
|
||||
*/
|
||||
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 type { SignedProof } from '../types.js';
|
||||
import { hexToBytes, decodeSignature, utf8ToBytes, concatBytes } from '../bytes.js';
|
||||
|
||||
/** XRPL's base58 alphabet (NOT the Bitcoin/IPFS alphabet — different order). */
|
||||
const XRPL_ALPHABET = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz';
|
||||
|
||||
/** Account address type prefix byte (the leading 'r' once base58-encoded). */
|
||||
const ACCOUNT_ID_PREFIX = 0x00;
|
||||
|
||||
/** XRPL public keys are always 33 bytes: a 1-byte family tag + 32-byte key. */
|
||||
const PUBKEY_LEN = 33;
|
||||
const ED25519_PREFIX = 0xed;
|
||||
const ED25519_SIG_LEN = 64;
|
||||
|
||||
/** XRPL "sha512half": the first half (32 bytes) of SHA-512 over the input. */
|
||||
function sha512Half(data: Uint8Array): Uint8Array {
|
||||
return sha512(data).slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base58Check encode using the XRPL alphabet. `payload` is the version-prefixed
|
||||
* data; a 4-byte double-SHA256 checksum is appended before encoding. Pure
|
||||
* big-integer base conversion so it matches the Go port byte-for-byte.
|
||||
*/
|
||||
function base58CheckXrpl(payload: Uint8Array): string {
|
||||
const checksum = sha256(sha256(payload)).slice(0, 4);
|
||||
const full = concatBytes(payload, checksum);
|
||||
|
||||
// Big-endian base-256 → base-58 via repeated division.
|
||||
let acc = 0n;
|
||||
for (const b of full) acc = (acc << 8n) | BigInt(b);
|
||||
|
||||
let out = '';
|
||||
while (acc > 0n) {
|
||||
const rem = Number(acc % 58n);
|
||||
acc = acc / 58n;
|
||||
out = XRPL_ALPHABET[rem] + out;
|
||||
}
|
||||
// Each leading zero byte encodes as the alphabet's zeroth character.
|
||||
for (let i = 0; i < full.length && full[i] === 0; i++) {
|
||||
out = XRPL_ALPHABET[0] + out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the canonical r-address from a 33-byte XRPL public key:
|
||||
* accountID = ripemd160(sha256(pubkey))
|
||||
* address = base58check( 0x00 || accountID )
|
||||
* The FULL 33-byte key (with its 0xED / 0x02 / 0x03 family tag) is hashed —
|
||||
* this matches rippled's AccountID derivation for both key types.
|
||||
*/
|
||||
function deriveAddress(publicKey33: Uint8Array): string {
|
||||
const accountId = ripemd160(sha256(publicKey33));
|
||||
const versioned = concatBytes(Uint8Array.of(ACCOUNT_ID_PREFIX), accountId);
|
||||
return base58CheckXrpl(versioned);
|
||||
}
|
||||
|
||||
export function verifyXrp(proof: SignedProof): boolean {
|
||||
try {
|
||||
if (proof.publicKey == null || proof.publicKey.length === 0) return false;
|
||||
|
||||
const publicKey = hexToBytes(proof.publicKey);
|
||||
if (publicKey.length !== PUBKEY_LEN) return false;
|
||||
|
||||
const messageBytes = utf8ToBytes(proof.message);
|
||||
const sigBytes = decodeSignature(proof.signature);
|
||||
|
||||
// 1. Cryptographic signature check, per scheme.
|
||||
let sigOk: boolean;
|
||||
if (proof.scheme === 'ed25519-xrpl') {
|
||||
// Family tag must be 0xED; verify over the bare 32-byte Edwards key.
|
||||
if (publicKey[0] !== ED25519_PREFIX) return false;
|
||||
if (sigBytes.length !== ED25519_SIG_LEN) return false;
|
||||
const pub32 = publicKey.slice(1);
|
||||
sigOk = ed25519.verify(sigBytes, messageBytes, pub32);
|
||||
} else if (proof.scheme === 'secp256k1-xrpl') {
|
||||
// Compressed point: family tag is 0x02 or 0x03.
|
||||
if (publicKey[0] !== 0x02 && publicKey[0] !== 0x03) return false;
|
||||
const digest = sha512Half(messageBytes);
|
||||
// DER signature over the prehashed digest. lowS:false — rippled does not
|
||||
// require low-S of wallet signatures, and malleability is irrelevant for
|
||||
// a login proof already bound to a server nonce.
|
||||
sigOk = secp256k1.verify(sigBytes, digest, publicKey, {
|
||||
prehash: false,
|
||||
lowS: false,
|
||||
format: 'der',
|
||||
});
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (!sigOk) return false;
|
||||
|
||||
// 2. Address binding: the key must derive exactly the claimed r-address.
|
||||
const derived = deriveAddress(publicKey);
|
||||
return derived === proof.address.trim();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -15,12 +15,11 @@
|
||||
"files": ["src", "!src/**/*.test.ts"],
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"tc": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
"tc": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.11.0"
|
||||
"@hanzo/iam": "^0.13.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
|
||||
@@ -57,7 +57,7 @@ export const STEPS: readonly StepDesc[] = [
|
||||
|
||||
/** A minimal org reference the UI lists in the "choose org" step. */
|
||||
export interface OrgRef {
|
||||
/** Casdoor org slug (the `<org>` in `<org>-<app>`). */
|
||||
/** IAM org slug (the `<org>` in `<org>-<app>`). */
|
||||
readonly name: string
|
||||
/** Human-facing name; falls back to `name` when unset. */
|
||||
readonly displayName: string
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* Covers the React-free surface: the domain step machine and the service's
|
||||
* request shaping + IAM response translation (with an injected fake fetch).
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { STEPS, stepById, nextStep, prevStep } from './domain/types.ts'
|
||||
import { createOnboardingService } from './service/onboarding.ts'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* wallet flow.
|
||||
*
|
||||
* One way: every write goes through the canonical IAM REST surface under
|
||||
* `/v1/iam/*` (the same Casdoor-compat paths the auth client uses), carrying
|
||||
* `/v1/iam/*` (the same IAM paths the auth client uses), carrying
|
||||
* the user's bearer token. There is no separate onboarding backend — the org
|
||||
* and project records live in IAM, which is the identity registry.
|
||||
*
|
||||
@@ -123,14 +123,14 @@ export function createOnboardingService(opts: OnboardingServiceOptions): Onboard
|
||||
const url = new URL('/v1/iam/update-user', base)
|
||||
url.searchParams.set('id', `${account.owner}/${account.name}`)
|
||||
// Scope the write to the single `web3onboard` column so the rest of the
|
||||
// user row is untouched (Casdoor replaces unscoped writes wholesale).
|
||||
// user row is untouched (IAM replaces unscoped writes wholesale).
|
||||
url.searchParams.set('columns', 'web3onboard')
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
// Casdoor's User JSON tag is lowercase `web3onboard`; send the full
|
||||
// IAM's User JSON tag is lowercase `web3onboard`; send the full
|
||||
// owner/name so the row identity is unambiguous on the server.
|
||||
body: JSON.stringify({ owner: account.owner, name: account.name, web3onboard: trimmed }),
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
prevStep,
|
||||
stepById,
|
||||
type OnboardingState,
|
||||
type OrgRef,
|
||||
type StepId,
|
||||
} from '../domain/types'
|
||||
import type { OnboardingService } from '../service/onboarding'
|
||||
@@ -132,29 +131,10 @@ function OrgStep({
|
||||
service: OnboardingService
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [orgs, setOrgs] = useState<OrgRef[] | null>(null)
|
||||
const [mode, setMode] = useState<'pick' | 'create'>('pick')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
service.listOrgs().then((list) => {
|
||||
if (cancelled) return
|
||||
setOrgs(list)
|
||||
// No existing memberships → drop straight into create mode.
|
||||
if (list.length === 0) setMode('create')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [service])
|
||||
|
||||
async function pick(org: OrgRef) {
|
||||
onNext({ orgName: org.name, orgCreated: false })
|
||||
}
|
||||
|
||||
async function create(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const name = slugify(displayName)
|
||||
@@ -173,60 +153,35 @@ function OrgStep({
|
||||
onNext({ orgName: res.value.name, orgCreated: true })
|
||||
}
|
||||
|
||||
if (orgs === null) return <p className="hanzo-id-info">Loading your organizations…</p>
|
||||
|
||||
// Onboarding never lists other tenants' organizations — a brand-new user only
|
||||
// ever creates their own org or skips. Listing the org directory would leak
|
||||
// every tenant's name to anyone who signs up. Joining an existing org happens
|
||||
// by invitation, handled outside this flow.
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
{mode === 'pick' && orgs.length > 0 ? (
|
||||
<>
|
||||
<ul className="hanzo-id-org-list">
|
||||
{orgs.map((o) => (
|
||||
<li key={o.name}>
|
||||
<button type="button" className="hanzo-id-org-row" onClick={() => pick(o)}>
|
||||
<span>{o.displayName}</span>
|
||||
<span className="hanzo-id-org-slug">{o.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" className="hanzo-id-linkbtn" onClick={() => setMode('create')}>
|
||||
+ Create a new organization
|
||||
<form onSubmit={create} aria-busy={busy}>
|
||||
<label>
|
||||
<span>Organization name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Acme Inc"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{displayName ? <p className="hanzo-id-slug-preview">slug: {slugify(displayName) || '—'}</p> : null}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip for now
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={create} aria-busy={busy}>
|
||||
<label>
|
||||
<span>Organization name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Acme Inc"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{displayName ? <p className="hanzo-id-slug-preview">slug: {slugify(displayName) || '—'}</p> : null}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{orgs.length > 0 ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => setMode('pick')}>
|
||||
Back
|
||||
</button>
|
||||
) : (
|
||||
// No org to fall back to and creation may be denied — let the
|
||||
// user proceed rather than dead-end. They land org-less; an
|
||||
// admin can add them to an org later.
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip for now
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="hanzo-id-btn primary" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create organization'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<button type="submit" className="hanzo-id-btn primary" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create organization'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,13 +12,17 @@
|
||||
"./brand": "./src/brand.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["src"],
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit",
|
||||
"build": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
"build": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,24 @@ export interface BrandRuntime {
|
||||
readonly accentColor?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutral identity-portal label — always "<Brand> ID". `BrandContract.name`
|
||||
* is meant to be the bare org display ("Lux"), but some brand packages ship
|
||||
* the product name ("Lux Exchange" / "Zoo Exchange"), which leaks a sibling
|
||||
* surface into the IAM portal heading + tab title. Prefer the tenant orgId
|
||||
* ("lux" → "Lux"); otherwise strip a trailing product word from the brand
|
||||
* name. So id.lux.network reads "Lux ID", never "Lux Exchange".
|
||||
*/
|
||||
export function idBrandLabel(brand: { name: string }, orgId?: string): string {
|
||||
const cap = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : '')
|
||||
const short =
|
||||
cap((orgId ?? '').trim()) ||
|
||||
(brand.name ?? '').replace(/\s+(Exchange|Network|Labs|Foundation|DAO|Wallet)\b.*$/i, '').trim() ||
|
||||
brand.name ||
|
||||
'Account'
|
||||
return `${short} ID`
|
||||
}
|
||||
|
||||
export function toBrandRuntime(b: BrandContract): BrandRuntime {
|
||||
return {
|
||||
name: b.name,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* osage.id brand-leak regression: the catalog carries `brandUrl`, and the
|
||||
* resolver must map it to `brandPackage` and derive issuer/origin from the host.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { resolveTenant, parseCatalog } from './tenant.ts'
|
||||
|
||||
@@ -26,6 +26,13 @@ const CATALOG = {
|
||||
appName: 'osage-id',
|
||||
brandUrl: 'https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json',
|
||||
},
|
||||
// No brandUrl on purpose — no bootnode brand package is published. This is
|
||||
// the shape that must NOT fall back to Hanzo.
|
||||
'id.bootno.de': {
|
||||
orgId: 'bootnode',
|
||||
clientId: 'bootnode-platform',
|
||||
appName: 'bootnode-platform',
|
||||
},
|
||||
}
|
||||
|
||||
test('a built-in host resolves to its own brand with no catalog', () => {
|
||||
@@ -57,6 +64,27 @@ test('a catalog-ONLY host does NOT leak the Hanzo brand (osage.id regression)',
|
||||
assert.equal(t.publicOrigin, 'https://osage.id')
|
||||
})
|
||||
|
||||
test('a catalog host with NO brandUrl still does not leak Hanzo (id.bootno.de)', () => {
|
||||
const t = resolveTenant('id.bootno.de', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'bootnode')
|
||||
assert.equal(t.clientId, 'bootnode-platform')
|
||||
// The interesting case: no brandUrl at all. It must resolve EMPTY so the
|
||||
// loader shows a neutral wordmark — never another brand's mark. Before this
|
||||
// host was in the catalog it fell through to the `hanzo` default and the page
|
||||
// read "Sign in to Hanzo ID" on a Bootnode surface.
|
||||
assert.equal(t.brandPackage, '')
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand')
|
||||
// issuer + origin derive from the host itself, never hanzo.id.
|
||||
assert.equal(t.iamUrl, 'https://id.bootno.de')
|
||||
assert.equal(t.iamIssuer, 'https://id.bootno.de')
|
||||
assert.equal(t.publicOrigin, 'https://id.bootno.de')
|
||||
})
|
||||
|
||||
test('an unknown host still falls back to hanzo (the intended default)', () => {
|
||||
const t = resolveTenant('totally-unregistered.example', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'hanzo')
|
||||
})
|
||||
|
||||
test('pars built-in uses the working pars-console portal app (not the missing pars-id)', () => {
|
||||
const t = resolveTenant('pars.id')
|
||||
assert.equal(t.clientId, 'pars-console')
|
||||
|
||||
@@ -158,7 +158,7 @@ function hostSkeleton(host: string): TenantConfig {
|
||||
function fromCatalog(entry: CatalogEntry | undefined): Partial<TenantConfig> {
|
||||
if (!entry) return {}
|
||||
const out: Record<string, string> = {}
|
||||
for (const k of ['orgId', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage'] as const) {
|
||||
for (const k of ['orgId', 'loginOrg', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage'] as const) {
|
||||
const v = entry[k]
|
||||
if (typeof v === 'string' && v.length > 0) out[k] = v
|
||||
}
|
||||
|
||||
+20
-22
@@ -8,6 +8,18 @@
|
||||
export interface TenantConfig {
|
||||
/** Tenant org slug (matches the JWT `owner` claim and the IAM `<org>-<app>` namespace). */
|
||||
readonly orgId: string
|
||||
/**
|
||||
* OPTIONAL org-resolution anchor for PASSWORD LOGIN only. Unset (the default)
|
||||
* = org-agnostic: the SPA posts NO `organization`, IAM resolves the user
|
||||
* cross-org by credentials, and the session encodes the user's REAL owner-org
|
||||
* (a global admin → the `admin` org / full multi-org session; a brand user →
|
||||
* their own org). Pinning `orgId` here would resolve a colliding brand-org row
|
||||
* and truncate a global admin to a single org — so the portal leaves this
|
||||
* unset. Set it ONLY for a brand that deliberately scopes its portal login to
|
||||
* one tenant. Does NOT affect signup (which always targets `orgId`) or the
|
||||
* apps launcher (which is brand-scoped by `orgId`).
|
||||
*/
|
||||
readonly loginOrg?: string
|
||||
/** IAM (OIDC) backend origin, no trailing slash. */
|
||||
readonly iamUrl: string
|
||||
/** Pinned OIDC issuer claim. Defaults to iamUrl. */
|
||||
@@ -36,26 +48,12 @@ export interface TenantConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand contract that all per-org brand packages MUST satisfy.
|
||||
* Matches the consumer contract in `@hanzo/brand` / `@luxfi/brand` /
|
||||
* `@zooai/brand` / `@parsdao/brand`. Read from each pkg's `brand.json`.
|
||||
* Brand contract that all per-org brand packages MUST satisfy — the shape the
|
||||
* portal reads from each pkg's `brand.json` (`@hanzo/brand` / `@luxfi/brand` /
|
||||
* `@zooai/brand` / `@parsdao/brand`).
|
||||
*
|
||||
* DRY: this type is NO LONGER defined here. `@hanzo/brand` is the canonical
|
||||
* home (`toBrandContract` projects the registry onto exactly this shape); we
|
||||
* re-export it so there is one contract, not two that can drift.
|
||||
*/
|
||||
export interface BrandContract {
|
||||
/** Org display name shown in headings ("Hanzo", "Lux", "Zoo", "Pars"). */
|
||||
readonly name: string
|
||||
/** Browser tab title prefix. */
|
||||
readonly title: string
|
||||
/** Short tagline rendered on the portal hero. */
|
||||
readonly description: string
|
||||
/** Marketing site (footer link target). */
|
||||
readonly appDomain: string
|
||||
/** Logo + favicon URLs (CDN or data URI). */
|
||||
readonly logoUrl: string
|
||||
readonly faviconUrl: string
|
||||
/** Primary accent (CSS color string, e.g. "#ff6b35" or "var(--brand)"). */
|
||||
readonly accentColor?: string
|
||||
/** Optional social links rendered in the footer. */
|
||||
readonly twitter?: string
|
||||
readonly github?: string
|
||||
readonly discord?: string
|
||||
}
|
||||
export type { BrandContract } from '@hanzo/brand/registry'
|
||||
|
||||
Generated
+1674
-9
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Single test runner for the whole monorepo. Every `*.test.ts` under any
|
||||
// package's `src/` runs here — the connect crypto/connector suites (vitest
|
||||
// describe/it/expect) and the auth / shared / onboarding suites (bare
|
||||
// `test()` + node:assert). One config, one `pnpm test`, one way.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['pkgs/**/src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user