Compare commits

..
115 Commits
Author SHA1 Message Date
zeekay 48acbaaf92 test(tenant): a catalog host with NO brandUrl must not fall back to Hanzo
id.bootno.de had no catalog entry at all, so resolveTenant took the
`defaultOrg` path and a Bootnode customer was asked to "Sign in to Hanzo ID"
under the Hanzo mark — the white-label invariant broken at the highest-intent
moment in the product.

The existing osage.id case covers a catalog-only host that HAS a brandUrl.
id.bootno.de is the other shape: no bootnode brand package is published
(@bootnode/brand, @bootno/brand — all 404 on npm), so the entry carries no
brandUrl at all. That must resolve to an EMPTY brandPackage — the loader then
shows a neutral wordmark rather than another brand's mark — while idBrandLabel
still reads `orgId` first and renders "Sign in to Bootnode ID".

Also pins the intended default explicitly: a genuinely unknown host still
resolves to hanzo. That is the behaviour we want to keep; the bug was that a
KNOWN brand host was reaching it.

9 tests pass.
2026-07-26 11:02:37 -07:00
zeekayandClaude Opus 5 6b12efdde5 ci: pull from GitHub fast-forward-only — the other half of the loop
The push-mirror carries native -> GitHub. This is the return leg, so the two
forges converge from either side.

They compose rather than fight: a native commit reaches GitHub via the mirror,
so this job then sees LOCAL == REMOTE and exits "in sync"; a GitHub commit
fast-forwards native here and the resulting mirror push is a no-op. No echo,
no loop.

Fast-forward ONLY -- a divergence fails loudly instead of force-pushing either
side and destroying whichever history lost the race. Same shape hanzoai/app has
run green 299 times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:15:45 -07:00
zeekayandClaude Opus 5 818a7d9079 build: do not build on docs-only commits
The previous commit touched only LLM.md and went RED: the version guard
correctly refused to rebuild an existing 0.2.11, but a docs edit cannot produce
a release, so it should never have started a build. A red run for a change that
could not possibly ship is how people learn to ignore red.

paths-ignore for markdown, workflow files and docs -- same shape hanzoai/app
uses. Real source changes still build; workflow_dispatch forces one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:14:25 -07:00
zeekayandClaude Opus 5 ad3e6daf48 docs: record that truth flows git.hanzo.ai -> GitHub
build / build (push) Failing after 23s
This repo is canonical on git.hanzo.ai and GitHub is now a push-mirror of it
(sync_on_commit). It used to be the reverse, and as a pull-mirror it could run
no CI at all -- four commits shipped zero images with nothing reporting a fault.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 08:11:55 -07:00
zeekayandClaude Opus 5 1c1cd9a803 build: publish to oci.hanzo.ai as well as ghcr, and 0.2.11
build / build (push) Successful in 56s
oci.hanzo.ai is our own registry and where images belong. 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). Verified hanzo-registry can
obtain a repository:id:pull,push token before wiring this.

Publishing to BOTH rather than cutting over, deliberately: Kubernetes matches
imagePullSecrets by HOSTNAME, and the KMS-synced registry-credentials secret
carries an auths entry only for registry.hanzo.ai. A CR pointed at
oci.hanzo.ai today would ImagePullBackOff every replica. Adding that entry
changes a shared credential every registry client holds, so it is not a change
to make blind. With both published, the CR flip is one line the moment it lands.

Creds are ORG-level Actions secrets (OCI_USER / OCI_TOKEN) sourced from the
KMS-synced credential already in-cluster -- KMS stays the source of truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:58:02 -07:00
zeekayandClaude Opus 5 55c7d907de build: tag images semver from package.json, never a sha
build / build (push) Successful in 47s
Deployments pin semver. 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 -- no separate tagging step to forget and no sha in a CR.

Refuses to rebuild a version that already exists, so a tag other environments
may be pinning is never silently overwritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:42:55 -07:00
zeekayandClaude Opus 5 d05a53d3cb build: log in to ghcr before pushing
build / build (push) Successful in 52s
Every run died on 'https://ghcr.io/v2/: denied: denied'. The raw buildctl
invocation pushed with no registry login at all -- there was no auth step in the
job. Replaced with the same trio hanzoai/app and hanzoai/cloud use:
setup-buildx -> login-action (GH_PAT, write:packages, user hanzo-dev, an
ORG-level secret already inherited here) -> build-push-action.

Adds a manifest-inspect gate: build-push-action can exit 0 while the manifest is
not yet resolvable, and a CR bumped to a phantom tag becomes ImagePullBackOff.
Prove it pulls here instead.

Tag is sha-<full sha> so it is immutable and never floats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:37:14 -07:00
zeekayandClaude Opus 5 8aee66d029 build: target the runner pool that exists
build / build (push) Failing after 4s
runs-on was hanzo-linux-amd64. The fleet advertises hanzo-build-linux-amd64
(plus ubuntu-* aliases) and nothing else, so no runner could match and Gitea
created NO RUN AT ALL -- not a queued one, not a failed one. Four commits landed
on main between 2026-07-23 and today and produced zero images, with nothing
anywhere reporting a problem.

Same label hanzoai/app uses, where it has 299 green runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:35:29 -07:00
zeekayandClaude Opus 5 a7e917f348 build: separate build from deploy, and 0.2.10
build / build (push) Canceled after 0s
The pipeline ended with 'kubectl patch app id', but App/id is CD-governed --
id.yaml is listed in universe crs/kustomization.yaml and the live object carries
apps.hanzo.ai/tracking-id -- so that patch is reverted on the next sync (~90s)
to whatever git says. It could never stick, and it FAILED SILENTLY: the patch
applies, the pod rolls, and CD quietly restores the old tag a minute later.

Build and deploy are separate concerns. This emits an immutable image; git
declares desired state; CD applies it. Shipping a build is a tag bump in
universe crs/id.yaml -- the one way a tag reaches the cluster.

0.2.9 -> 0.2.10 carries the apex-login fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:32:52 -07:00
zeekayandClaude Opus 5 bc890fd0d0 login: the apex form posts an organization — it was dead on every brand
deploy / deploy (push) Canceled after 0s
Typing a password at hanzo.id, lux.id, iam.hanzo.ai or pars.id did nothing. The
bare portal deliberately posted NO organization, and iam2 refuses that:

    HTTP 200  {"status":"error","msg":"organization, username and password are required"}

The 200 is why nobody caught it. The form renders that message as though the
USER's credentials were wrong, and every status-code monitor reads the response
as healthy. Four brand front doors, dead, green on the dashboard.

The omission was correct once. It let IAM resolve 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 and calls the collision a
defect -- internal/registry/registry.go: "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. The server is not
regressed; this client was.

LoginForm now resolves the app's own org through get-app-login and posts it on
BOTH entry points, which is exactly what 0.2.2 already established for the
downstream-app path -- the apex path simply never got it. A global admin is no
longer reached by omission: they sign into an admin-org app (hanzo-admin-guard),
the explicit path 0.2.2/0.2.3 describe.

client.login() stays a pure passthrough -- it never invents an org. The two
tests pinning that are unchanged and still pass; only their now-dead rationale
is corrected, because a stale WHY is how this gets reverted. CLAUDE.md's 0.1.23
section is marked SUPERSEDED for the same reason: read alone, it argues for
putting the outage back.

130/130 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 17:17:01 -07:00
z 8d2ad1891a chore(spa): pin the ONE canonical hanzoai/spa 1.4.8
hanzoai/spa is the SPA-friendly server (index.html/.html fallthrough + a CSP
that does not blank a client-routed app); hanzoai/static is the strict one.
Consumers had drifted to two untagged pins (implicit :latest — banned, and
non-reproducible) and three at 1.2.0, thirteen patches behind. One image,
one version. Published spelling is 1.4.8 (v1.4.8 does not exist).
2026-07-25 10:46:58 -07:00
hanzo-dev 2cd1979cb0 ci(docker): neutralize to sync-notice — native build is .hanzo/workflows/deploy.yml 2026-07-24 15:15:38 -07:00
hanzo-dev 7bbfb77cd7 ci(deploy): native Hanzo pipeline — BuildKit → ghcr.io/hanzoai/id → operator reconcile 2026-07-24 15:15:17 -07:00
Hanzo AI 4226ef57a7 chore(release): 0.2.9 — one chain-agnostic Connect Wallet button 2026-07-23 01:38:36 -07:00
Hanzo AI 403ff90b10 feat(id/auth): merge EVM + Solana wallet buttons into one chain-agnostic Connect Wallet
SocialButtons rendered one wallet button per ENABLED chain (Continue with Ethereum / EVM + Continue with Solana). Merge the ENTRY into a single monochrome 'Connect Wallet' button that keeps BOTH flows.

- web3.ts: add detectWalletChains() — a pure window sniff (EVM=window.ethereum, Solana=window.solana/solflare/backpack) derived from ENABLED_WALLET_CHAINS; injectable window, no DOM/connect/I-O.
- SocialButtons.tsx: one Connect Wallet button (data-wallet-connect). onConnectWallet -> detect: exactly one injected chain connects straight; zero or many reveals an inline chooser (.hanzo-id-wallet-chains, data-chain=evm|solana) so either chain stays reachable. Per-chain startWallet -> loginWithWalletChain unchanged. Sits beside the GitLab provider (0.2.8).
- app.css: indented monochrome chooser matching the GitHub/GitLab/Google buttons.
- 3 new detectWalletChains unit tests (130 vitest pass). Frontend-only; IAM/casdoor untouched.
2026-07-23 01:38:36 -07:00
hanzo-dev 33cc55e837 merge(feat/brandcontract-from-hanzo-brand): consolidate onto main 2026-07-21 17:18:35 -07:00
hanzo-dev cc7885c680 refactor(brand): re-export BrandContract from @hanzo/brand
id-shared's BrandContract already documented that it 'matches the consumer
contract in @hanzo/brand' — now it IS that contract, re-exported, not a second
copy that can drift. @hanzo/brand is the canonical home (its toBrandContract
projects the registry onto exactly this shape). Byte-identical fields, so the
8 auth-page consumers (Login/Signup/Callback/DeviceApproval/Portal/Onboarding/
Forgot/BrandHeader) see the same type — zero behavior change.

Verified: id-shared tc (tsc --noEmit) + 7 tenant tests green with @hanzo/brand
linked. App-level build gates on the id workspace install + @hanzo/brand
publish (deploy-gate).
2026-07-21 09:41:03 -07:00
zeekayandClaude Opus 4.8 f2596c4788 test(id): unify on vitest — one runner, 12/12 files, 127 tests green (0.2.8)
The connect crypto/connector suites (bitcoin, ton, xrp, caip122, verify,
connectors — 6 files, 71 tests) imported `vitest` but vitest was never
installed and connect had no `test` script: they had never run. The auth /
shared / onboarding suites ran green under three separate per-package
`node --test --experimental-strip-types` scripts — a divergent second runner.

Decomplect to ONE runner:
- add vitest 3.2.7 (built for the repo's vite 7) as the sole test dep + root
  `vitest.config.ts` (include pkgs/**/src/**/*.test.ts, node env)
- root `test` = `vitest run`; drop the three per-package node:test scripts
- swap `import { test } from 'node:test'` -> `'vitest'` in the 6 node:test
  files; node:assert/strict assertions kept verbatim (real assertions, no
  rewrites)
- add pkgs/connect/tsconfig.json (matching siblings) so `pnpm tc` is green —
  connect had no tsconfig, so its typecheck errored out entirely

Result: `pnpm test` 12 files / 127 tests pass; `pnpm tc` green across all 6
projects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:53:40 -07:00
zeekayandClaude 2b3f9330e7 fix(id): center auth card — pin .hanzo-id-page min-height:100vh so main can vertically center
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 19:23:47 -07:00
zeekayandClaude 9bfbb8add2 fix(id): neutral "<Brand> ID" heading + title, WCAG-3:1 input border, centered auth card
- Login <h1> + document.title now use idBrandLabel(brand, orgId) → "Lux ID" /
  "Zoo ID" / "Hanzo ID" instead of the brand package's product name
  ("Lux Exchange" / "Zoo Exchange") leaking into the IAM portal heading + tab
  title. New helper in @hanzo/id-shared strips a trailing product word and
  appends " ID", preferring the tenant orgId.
- --border #262626 → #71717a: 1.25:1 → ~3.9:1 against the #111 input fill,
  clearing the WCAG 3:1 non-text-contrast threshold (inputs, social buttons,
  dividers, org rows).
- .hanzo-id-page main: justify-content center → the auth card is vertically
  centered instead of top-anchored (~15% → centered).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-19 19:10:55 -07:00
hanzo-dev da19c5f208 feat(id/social): render GitLab as a first-class sign-in provider (0.2.7)
hanzo.id showed only GitHub/Google/wallet because the portal gated the
dynamic get-app-login set on a hardcoded renderable-provider allowlist that
omitted GitLab, and the social hop had no GitLab authorize endpoint — so the
configured provider-gitlab (real clientId in IAM) never appeared.

- social.ts AUTH_INFO: GitLab -> https://gitlab.com/oauth/authorize (read_user).
- SocialButtons: gitlab in PROVIDER_META + ORDER (+ GitLabIcon). It still
  renders ONLY when get-app-login says the app has it configured + canSignIn —
  dynamic, one source of truth; the allowlist is just per-type presentation.
- Tests: GitLab hop URL + isHoppableProvider(GitLab) locked in social.test.ts.

Verified: 9/9 auth tests pass; built bundle contains GitLab. Same provider-gitlab
backs the cloud /v1/integrations/gitlab connector (one app, KMS hanzo/prod/gitlab-oauth).
2026-07-15 10:17:41 -07:00
Hanzo AI a87b7500e5 docs(id): document provider_hint auto-federation (0.2.6) [skip ci] 2026-07-14 06:30:45 -07:00
Hanzo AI 23d0c0e7a8 chore(release): 0.2.6 — provider_hint social login auto-federation 2026-07-14 06:18:41 -07:00
Hanzo AI 436931bb53 feat(id/social): honor provider_hint so console social login lands straight in the provider
Clicking "Continue with GitHub/Google" on console.hanzo.ai now goes directly
into the provider OAuth flow instead of bouncing the user to the hanzo.id login
form. Three fixes, all reusing the EXISTING hop (no duplicated IdP config):

- Login.tsx: a `federate` phase honors `?provider_hint=provider-github` (the
  value the console already sends). After a silent-SSO miss it auto-launches the
  hinted provider via a headless SocialButtons instead of showing the form,
  falling back to the form only when the hint matches no configured provider.
- SocialButtons: `autoStart` runs the SAME startProviderLogin hop the button
  runs, matched by a new pure `matchProviderHint` (accepts provider-github /
  github, case-insensitive). getAppLogin now validates against the DOWNSTREAM
  app's own redirect_uri (read from the query), not the portal /callback — the
  latter is not registered for a cross-app clientId (hanzo-cloud), so IAM
  rejected the read (status:error) and no providers resolved.
- hop uses method=signup (IAM find-or-create-login) for interactive social
  login; method=signin is the account-LINK branch and errors "user doesn't
  exist" on a fresh sign-in with no session.

Contracts locked: matchProviderHint + getAppLogin redirect_uri. 39 tests green,
tsc + vite build clean.
2026-07-14 06:18:41 -07:00
hanzo-dev 2ceecf49a5 chore(release): 0.2.5 — forced TOTP MFA enforcement (RequiredMfa/NextMfa)
Docker / docker (push) Failing after 11s
Ships the portal-side forced-MFA fix: parseLoginResponse now branches on
IAM's string data signal (RequiredMfa/NextMfa) instead of the never-set
mfa_required boolean, so org-forced 2FA can no longer be silently bypassed
into /onboarding.

Claude-Session: https://claude.ai/code/session_01XsNmNwzHUSKXfN7gYruUsM
2026-07-08 07:02:33 -07:00
zeekayandhanzo-dev f5d2f13499 feat(auth): enforce forced TOTP MFA in the portal (RequiredMfa / NextMfa)
IAM signals MFA with a STRING in the login response `data`
("RequiredMfa" = org forces MFA, user not enrolled; "NextMfa" = user has
MFA, needs a challenge) — never the `mfa_required` boolean the portal was
checking. So every first login fell through to /onboarding, silently
bypassing 2FA.

parseLoginResponse now branches on `data` BEFORE the /onboarding return:
  - RequiredMfa -> { mfaRequired, mfaStage: 'enroll' }
  - NextMfa     -> { mfaRequired, mfaStage: 'challenge', mfaTypes }

New AuthClient methods drive the flow against the canonical /v1/iam surface:
  getAccount, mfaInitiate, mfaVerify, mfaEnable, mfaChallenge.

Wire contract (verified live vs iam.hanzo.ai): the /v1/iam/mfa/setup/*
calls put EVERY param on the query string with an EMPTY body — the only
shape that satisfies both IAM's authz self-match (objOwner/objName are
read from the query only when the body is empty; a urlencoded body fails
the JSON-unmarshal and yields "Unauthorized operation") and the MFA
controller. owner/name ride the query on every call, incl. verify.

UI: MfaEnrollForm renders forced TOTP enrollment — QR from the otpauth://
URI via @paulmillr/qr (secret never leaves the browser), manual-key
fallback, recovery code, and the existing OTPForm for code entry. No skip
control. Login.tsx routes onMfaRequired by stage; the existing OTPForm is
reused for the NextMfa challenge.

Tests: 8 new MFA wire-contract tests (pure, mocked fetch). 14/14 green.
(cherry picked from commit 3df982b9fc)
2026-07-08 07:01:57 -07:00
zeekayandClaude Opus 4.8 f8640af7bc fix(id/login): >=44px touch targets on sign-in + social buttons (cosmetic)
Sign-in button and the social/Web3 buttons rendered at 42px on mobile,
2px under the 44px minimum touch target. Bump sign-in vertical padding to
13px (44px) and add min-height:44px to the flex-centered social buttons.
CSS-only; no auth/OAuth logic touched. Branched off the deployed 0.2.3
(origin/main) so nothing but this cosmetic change ships. Version -> 0.2.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 07:29:01 -07:00
zandClaude Opus 4.8 f4e7c0d145 fix(auth): org-scope silent SSO so admin operators reach god-mode
admin.hanzo.ai is gated by admin-guard (ForwardAuth: owner==admin only).
Login rides client_id=hanzo-admin-guard (org=admin) and the 0.2.2 form fix
correctly posts organization=admin -> IAM resolves admin/z (owner=admin).
But Login.tsx's silentLogin SSO fast-path minted a code from the ambient
iam_session_id session regardless of its org; operators carry a hanzo/*
session, so it minted owner=hanzo and the guard bounced them to console —
the org-scoped form was never shown. Silent SSO shadowed the fix.

silentLogin now resolves the app org (get-app-login) + session owner
(get-account) and mints ONLY when they match; 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 (hanzo->hanzo)
still mints silently — no UX change. Cross-org (hanzo->admin-guard) now
falls to the form -> org=admin -> owner=admin -> god-mode.

Guard still validates owner==admin server-side, so this is availability
(admins get IN), not a privilege boundary — a non-admin can never reach
god-mode. IAM (v1.31.14) unchanged; SSO-ATO exact-match fix untouched.

Tests: 4 silentLogin cases (same-org mint, cross-org no-mint form-fallback,
same admin-org mint, no-session no-mint) + fix a stale providerLogin
redirect-url expectation. tc + 29 tests green; web build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:30:17 -07:00
zeekayandClaude Opus 4.8 0a97192506 chore(release): 0.2.2 — admin-org login resolution fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:49:47 -07:00
zeekayandClaude Opus 4.8 d928ecdfe3 fix(auth): resolve login org from the app being signed into, not the brand
A downstream app that initiates login passes its own client_id
(props.clientIdOverride). LoginForm ignored it for org/app resolution and
always pinned application=tenant.appName + organization=tenant.loginOrg, so
EVERY login authenticated inside the brand portal's own org (hanzo). The
admin-guard (client_id=hanzo-admin-guard) lives in the `admin` org: its
operators resolved to hanzo/* (owner=hanzo) instead of admin/* (owner=admin),
so the admin.hanzo.ai forward-auth gate (predicate owner==admin) could never
be satisfied — god-mode was unreachable.

Fix: when a client_id override is present, resolve {application, organization}
from that app's get-app-login (the canonical clientId -> app/org map) and post
BOTH, so IAM scopes the credential check to the app's org. The bare brand-portal
sign-in (no override) stays org-agnostic (loginOrg unset) so a global admin
still resolves cross-org into the full multi-org session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:42:40 -07:00
87d2da9c74 Debrand: replace Casdoor name with Hanzo IAM in comments/docs/aliases (#19)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-06-30 14:57:54 -07:00
z 218528569e docs(brand): add hero banner 2026-06-28 20:06:13 -07:00
z 6df3f160b8 chore(brand): dynamic hero banner 2026-06-28 20:06:11 -07:00
648cbe8e65 feat: RFC 8628 device-authorization approval page (#16)
Docker / docker (push) Failing after 13s
* feat(web): RFC 8628 device-authorization approval page

Add the /login/oauth/device approval page — the final gap in device login
(dev login --device-auth) on hanzo.id/lux.id/zoolabs.id.

- pkgs/auth: AuthClient.approveDevice(userCode) POSTs /v1/iam/login
  {type:device, userCode, application, organization} over the issuer session
  cookie (no credentials in body), mapping the consent-required and error
  branches. userCode normalized to IAM's [0-9a-z] alphabet.
- LoginForm: optional onAuthenticated to keep the caller on-page after sign-in.
- apps/web: DeviceApproval page — sign-in (reused) then anti-phishing confirm
  of app + code, success/error states. White-label via tenant. URL scrubbed of
  tokens. Route wired in App.tsx before the /login catch.
- Tests: 5 approveDevice cases (posting/normalization/empty/error/consent).

* fix(device): require explicit human affirmation before approval (H2)

H2 (HIGH): the ?user_code= prefill is now DISPLAY-ONLY. A signed-in victim
arriving from a crafted verification_uri_complete link can no longer one-click
approve an attacker's device — the Approve button stays disabled until the user
ticks an explicit checkbox affirming the code matches the one their OWN device
shows. Anti-phishing copy retained.

Aligns the client with the iam M1 change: user_codes are now UPPERCASE over an
unambiguous alphabet, so normalizeUserCode uppercases (was lowercase) for an
exact-match send, the code field renders uppercase, and the placeholder/tests
use a realistic code. Adds the .hanzo-id-device-confirm style.

tc + build green; device client tests green.

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-06-28 08:12:34 -07:00
Hanzo AI db509e068a fix(social): URL-safe base64 state + method=signup — fixes 'code_verifier does not match code_challenge' on Google login
Two root causes of broken social login across apps (billing, console2, console):
1. state was standard btoa() (emits +,/,=). The provider reflects state on a URL
   query; URLSearchParams turns '+' into space → atob corrupts the encoded OIDC
   request INCLUDING code_challenge → app's token exchange fails invalid_grant
   'code_verifier does not match code_challenge'. Fix: URL-safe base64
   (encodeState/decodeState), byte-exact round-trip.
2. method defaulted to 'signin' (account-LINK branch, needs existing session →
   400 on a fresh 'Continue with Google'). Canonical Casdoor default is 'signup'
   (find-or-create-LOGIN). Fixed.
Completes the lane that was interrupted by the session limit.
2026-06-26 19:22:37 -07:00
Hanzo AI b865303473 Standardize favicon to monochrome Hanzo H (transparent bg) 2026-06-26 14:33:05 -07:00
Hanzo AI f483ee6791 chore(iam): unify @hanzo/iam to ^0.13.1 (localStorage PKCE login fix) 2026-06-26 12:59:40 -07:00
Hanzo AI 7757aa1c74 docs(sso): document the silent single sign-on mechanism (0.1.26) 2026-06-25 18:02:51 -07:00
Hanzo AI f0b9750325 feat(sso): silent single sign-on — auto-continue authorize from existing issuer session
Login.tsx now attempts a credential-less silentLogin when an app sends the user
to the authorize page (client_id + redirect_uri present). IAM's Login handler
mints an auth code from the existing iam_session_id cookie (its already-signed-in
branch), so the 2nd/3rd app logs in seamlessly with no form. Falls back to the
interactive form when there is no live session. Backend already supports this;
this wires the SPA leg. Contract locked in client.test.ts (no creds posted,
redirect built from minted code; { error } -> form fallback).
2026-06-25 17:55:40 -07:00
Hanzo AI 064ea5666b social login: dedup provider= in state (RC#1), match exchange redirect_uri to hop (RC#2 hardening)
- buildProviderAuthUrl strips any pre-existing provider= before appending the
  real social provider, so the base64 state carries exactly ONE provider=.
  Callback.tsx reads URLSearchParams.get (FIRST match) — two providers made it
  post the upstream hint (hanzo-iam) instead of provider-google.
- providerLogin redirect_uri now derives from tenant.oauthCallbackOrigin (the
  same source the hop uses), never publicOrigin — IAM forwards it verbatim to
  the provider token endpoint; a mismatch is invalid_grant.
- tests: social.test.ts dedup lock + client.test.ts redirect_uri lock (20 pass).
- RC#2 backend proven live: junk-code probe returns invalid_grant not
  invalid_client → Google clientId/secret + iam.hanzo.ai/callback all valid.
- bump id 0.1.25, @hanzo/id-auth 0.1.2.
2026-06-25 16:04:26 -07:00
Hanzo AI 9d906eda2a docs(LLM): provider-name resolution fix + org unified-login mechanism 2026-06-24 17:02:24 -07:00
Hanzo AI 140a000794 fix(auth): resolve social provider name from nested record, not outer link label
parseAppLogin read the outer Casdoor app-provider LINK name as the
provider identity. Some seeds label that link <org>-iam, so the social
hop POSTed provider=<org>-iam and the IAM backend rejected it ('The
provider: hanzo-iam does not exist'). The provider's real identity is the
nested provider record name (provider-github). Prefer the inner record
name; fall back to the outer label only when no nested record exists.

Regression: getAppLogin uses the nested name + falls back. 18/18 pass.
2026-06-24 16:50:57 -07:00
Hanzo 1c1d6873af fix(docker): COPY pkgs/connect/package.json before pnpm install
pkgs/auth now depends on @hanzo/id-connect (workspace:*) for the
multi-chain wallet login. The build stage COPYs each workspace
package.json before `pnpm install` so pnpm can resolve the graph, but
pkgs/connect/package.json was missing -> install failed:
'@hanzo/id-connect' unresolved workspace dependency. Add the COPY line.
2026-06-23 21:55:53 -07:00
Hanzo AI 5de04a2b46 feat(web): native multi-chain wallet SIWX login (EVM+Solana), drop OAuth-redirect web3 fallback
Replace the web3 button's @hanzo/iam OAuth-redirect fallback in SocialButtons
with native Sign-In-With-X via the vendored @hanzo/id-connect connectors. The
connect->nonce->sign->verify orchestration is ONE function (loginWithWalletChain
in pkgs/auth/src/web3.ts): GET /v1/iam/web3/nonce -> connector.signLogin(challenge)
-> POST /v1/iam/web3/verify, returning the SAME LoginResponse/redirect the
password flow uses. Decomplected: connect+sign = browser (lazy-loaded wallet
libs, code-split), verify = server.

Enabled chains gated on the verifier-readiness matrix via one exported constant
ENABLED_WALLET_CHAINS=['evm','solana'] (TON/XRP/Bitcoin disabled: Go verifiers
are stubs, would fail closed). The wallet provider expands into one connect
button per enabled chain. NO WalletConnect, NO projectId, NO web3-onboard.

Tests: pkgs/auth/src/web3.test.ts (mock fetch + fake signer) asserts nonce fetch,
signLogin gets the challenge, proof POSTed, SSO code redirect, disabled chains
fail closed without network/signer, wallet-rejection -> {error}.
2026-06-23 21:47:38 -07:00
Hanzo AI 7360334996 feat(id): wire @luxwallet/connect multi-wallet web3 (EVM/SOL/BTC/TON/XRP)
- vendor luxwallet/connect (MIT, not on npm) as workspace pkg
  @hanzo/id-connect (pkgs/connect) — exports map points at ./src/*.ts,
  Vite compiles it directly like the other id pkgs.
- replace the placeholder window.ethereum eth_requestAccounts connector
  in Onboarding.tsx with getConnector('evm').connect() (EIP-6963
  multi-injection via viem). connectWallet keeps its string|null contract
  so @hanzo/id-onboarding stays wallet-lib-agnostic.
- add wallet peerDeps to @hanzo/id-web: viem, @tonconnect/sdk,
  sats-connect, @crossmarkio/sdk (connectors.ts eagerly imports all 5
  chains). No WalletConnect projectId needed — connect uses pure injected
  discovery, not the WC bridge protocol.

IAM provider-web3 already exists and is attached to the hanzo-id app.
vite build green (607 modules); secp256k1 chunk bundled.
2026-06-23 21:39:53 -07:00
Antje Worring b116ea118d fix(auth): thread OIDC nonce through the password-login path
Confidential OIDC clients that validate strictly (LibreChat openid-client with
OPENID_REUSE_TOKENS) send a nonce on authorize and require the id_token to echo
it. The portal's password-login path read code_challenge from the authorize URL
but DROPPED nonce, so the IAM minted a code (and id_token) with no nonce ->
openid-client 'unexpected JWT claim value encountered' -> hanzo.chat callback
HTTP 500. Thread nonce: Login.tsx reads ?nonce, LoginForm forwards it, and
client.login() puts it on the /v1/iam/login query (next to code_challenge).
Additive + forward-only (never defaulted); social login already rode authorize()
which carried nonce. Verified: id SPA builds green.
2026-06-23 18:17:30 -07:00
Antje Worring 80ced70133 ci(docker): compute version without node (ARC runner has no node)
The ARC runner image ships git+docker but not node, so the Compute-tags step's
`node -p "require('./package.json').version"` died with exit 127 (node: command
not found), skipping the build. Parse the version from package.json with
portable sed instead. Verified locally -> 0.1.23.
2026-06-23 15:42:29 -07:00
Antje Worring 1c060e6886 ci(docker): target ARC scale set by name + mode=min cache
runs-on was [self-hosted, linux, amd64], which GitHub matched to the OFFLINE
classic evo-* org runners instead of the live ephemeral ARC scale set, so every
Docker run since the self-contained-build switch queued forever (never built).
Use runs-on: hanzo-build-linux-amd64 (the exact scaleSetName), matching the
hanzoai/iam convention, so jobs route to scale set 31.

Also switch buildx gha cache to mode=min: the runner builds via DinD on a 32G
node and mode=max exported every layer, ballooning the DinD store past the
kubelet eviction threshold (NodeHasDiskPressure -> build killed 'no space').
2026-06-23 15:40:29 -07:00
Antje Worring 0b144a53e5 fix(auth): org-agnostic password login — resolve real owner-org, not pinned brand
LoginForm no longer pins organization=<brand> on POST /v1/iam/login. It passes
the new (normally-unset) TenantConfig.loginOrg, and client.login() OMITS the
organization field when empty so IAM runs cross-org resolution and the session
encodes the user's REAL owner-org (GetOrganizationByUser), never the hint.

Fixes: global admins (z@/a@hanzo.ai, woo@lux.network — owner=admin) were
truncated to a 1-org 'hanzo' session via the UI because the pinned org made
GetUserByFields hit the colliding hanzo/<name> row first, so the cross-org
fallback to admin/<name> never ran. IAM IsGlobalAdmin()==(Owner=='admin') and
get-organizations returns all orgs only for a global admin. Verified live on
hanzo.id: z@hanzo.ai -> owner=admin, 45 orgs; brand-only user -> 1 org.

Boundaries preserved: signup still sends a concrete org; per-app SSO
(client_id+redirect_uri, type=code) still mints a code bound to the resolved
user; a brand can force single-org login via loginOrg in the catalog ConfigMap.
Contract locked in pkgs/auth/src/client.test.ts.

Bump 0.1.22 -> 0.1.23.
2026-06-23 15:24:32 -07:00
616a1131a6 fix(onboarding): never list other tenants' orgs; create-or-skip only (#15)
The org step fetched service.listOrgs() and rendered a pick-list of every
existing organization to a brand-new user — leaking the tenant directory to
anyone who signs up, and not the intended UX. A new user should only create
their own organization or skip; joining an existing org is invitation-based
and handled outside onboarding.

Drop the listOrgs() fetch and the pick-list entirely; the org step now always
shows the create form with a 'Skip for now' action.

Co-authored-by: Darkhorse7stars <z@hanzo.ai>
2026-06-23 12:56:31 -07:00
zeekay a6b37b02d7 Merge branch 'feat/sms-consent-disclosure' 2026-06-23 11:32:36 -07:00
zeekay 4a8d73b0fb feat(auth): A2P SMS consent disclosure on the SMS verification surface
Twilio A2P 10DLC requires the SMS consent disclosure wherever a user enters
or uses a phone number to receive messages. Add one shared, verbatim consent
component (matches hanzo.ai/sms-opt-in SMS_CONSENT_TEXT and the IAM phone-login
UI) and render it on the portal's SMS surface, gated to the SMS channel only.

- New pkgs/auth/src/ui/SmsConsent.tsx: single source of SMS_CONSENT_TEXT plus
  <SmsConsentNotice/> (disclosure + Terms/Privacy links). Exported from the ui
  barrel for reuse by any future phone-collection surface.
- OTPForm: render SmsConsentNotice beneath the code input only when
  channel === 'sms' (not for totp/email).
- app.css: muted styling for .hanzo-id-sms-consent, matching footer-links.

Note: this portal does not render its own phone-number field — phone-number
COLLECTION happens in the IAM-hosted UI (covered separately), where signup uses
a required, submit-gating consent checkbox. The portal's only SMS-facing step
today is the verification-code entry, so the disclosure-only notice applies
here. Typecheck + tests green (pnpm -r tc, @hanzo/id-auth test).
2026-06-23 09:38:27 -07:00
Hanzo 369b0702b9 ci(docker): self-contained build on self-hosted runners
The shared reusable workflow (hanzoai/.github docker-build.yml@main) fails graph
validation for EVERY caller right now (org-wide startup_failure, 0 jobs created),
so id never built via CI (0.1.x images were pushed manually). Build the amd64
image directly on our self-hosted runners instead — no shared-infra dependency.
Tags: sha-<short> + package.json version.
2026-06-22 23:30:19 -07:00
Hanzo 52bb873f87 fix(social): real Google endpoint + registered redirect_uri (iam.hanzo.ai/callback)
The provider hop sent redirect_uri=${origin}/callback (= hanzo.id/callback),
which the shared Google/GitHub OAuth client does NOT accept — verified live the
Google client accepts ONLY https://iam.hanzo.ai/callback (every other URI →
redirect_uri_mismatch). It also used the invalid authorize endpoint
accounts.google.com/signin/oauth.

- social.ts: Google endpoint → https://accounts.google.com/o/oauth2/v2/auth
  (canonical/stable); add callbackOrigin param to buildProviderAuthUrl/
  startProviderLogin (defaults to origin — local/single-host unchanged).
- TenantConfig.oauthCallbackOrigin (catalog-driven; defaults to publicOrigin);
  SocialButtons passes it so the hop returns to the provider's REGISTERED
  /callback. iam.hanzo.ai serves the same @hanzo/id SPA: the headless Callback
  completes the exchange and forwards to the originating app.
- tests: lock the registered-callback override + canonical Google endpoint.

bump id 0.1.1→0.1.22, id-auth/id-shared 0.1.0→0.1.1
2026-06-22 23:26:35 -07:00
Antje Worring af8819ba60 fix(portal): root IS the login form (logged-out) / apps launcher (logged-in)
- '/' rendered a static 'Welcome' marketing hero; for an identity portal the
  root should BE the login form when signed out, and the org's apps launcher
  when signed in. Portal now reads /v1/iam/get-account (same-origin, first-party
  cookie) and renders <Login> (anon) or the apps grid (authed).
- Onboarding completion redirected to '/' → the hero, which read as 'looped back
  to the beginning' after the last (wallet) step's Skip. It now lands on
  '/?signed_in=1' → the authed apps launcher. (The step machine was already
  correct: nextStep('wallet')==='done'.)
- Port marketing.ts (appsFor/billingFor) from the restore-design line.
2026-06-22 22:05:14 -07:00
zeekay 04b2a0324d fix(auth): use feat's clean LoginForm/SignupForm (getAppLogin via SocialButtons; password+PKCE) — auto-merge had mixed main's appLogin/sendLoginCode API with feat's client 2026-06-21 19:43:15 -07:00
zeekay 7c67359f6f fix(docker): COPY pkgs/onboarding/package.json — merged workspace needs it for pnpm install 2026-06-21 19:37:00 -07:00
zeekay 03217a97be merge(id): integrate feat/full-auth-onboarding — PKCE password-login fix + composable auth UI + social hop into main
# Conflicts:
#	Dockerfile
#	apps/web/src/App.tsx
#	apps/web/src/app.css
#	pkgs/auth/src/client.ts
#	pkgs/auth/src/index.ts
#	pkgs/auth/src/ui/index.ts
#	pkgs/shared/src/brand.ts
#	pkgs/shared/src/tenant.ts
2026-06-21 19:35:05 -07:00
zeekay 324c698dd5 feat(social): wire the provider hop + return exchange (gated until creds seeded)
Prep social login end-to-end so only OAuth-app registration + KMS creds remain.

- The hop (social.ts/startProviderLogin): redirects straight to GitHub/Google with a base64 state that round-trips the original authorize request — the Casdoor getAuthUrl contract. Replaces the @hanzo/iam signinRedirect that looped back to the login page. Pure URL builder is unit-tested (social.test.ts, 5 cases).

- SocialButtons now keeps the full provider records (type/clientId/scopes surfaced from get-app-login) and routes OAuth providers through the hop, wallet through the SDK.

- Callback gained a GATED provider-return branch: a provider state (base64 with provider+application) is exchanged via client.providerLogin at the IAM backend, then follows the continue-URL back into the normal OIDC path. The existing OIDC/password path is the untouched else-branch — and the social branch is unreachable until a configured provider exists, so zero impact today.

- Cred-sync already exists (init_data.json ${IAM_GITHUB_CLIENT_ID} <- iam-kms-sync KMSSecret, project hanzo-iam/prod). Runbook in LLM.md. End-to-end needs live verification once real creds are seeded.
2026-06-20 10:46:26 -07:00
zeekay ba9363cf7a fix(login): deliver tenant catalog via /config.json; correct pars/osage/lux client wiring
Root cause osage.id still showed Hanzo: the SPA read the catalog from window.__ID_CATALOG__, which the runtime never injects — so every catalog-only host silently fell back to the bundled Hanzo default. Read the catalog from /config.json (what the static server actually templates from SPA_IAM_TENANT_CONFIG_JSON), with the global as a fallback.

Also corrected verified-broken client wiring: built-in pars-id does not exist (use pars-console, which carries the /callback redirect); added an osage built-in so a catalog-load failure can never leak the Hanzo brand. lux uses lux-id (lux-cloud lacks the portal redirect) — fixed in the ConfigMap. Tests: 7/7.
2026-06-20 02:51:08 -07:00
zeekay 584e6cd679 fix(tenant): catalog-only hosts resolve their OWN brand; drop orphan divider
osage.id (and zoolabs.id) rendered as Hanzo — a white-label leak. Root cause: the runtime catalog entries carry brandUrl (+orgId/clientId), but a host with NO built-in DEFAULT_TENANTS entry used DEFAULT_TENANTS['hanzo.id'] as its merge base, so brandPackage/iamUrl silently inherited Hanzo. Now a catalog-only host derives iamUrl/iamIssuer/publicOrigin from the host itself and maps brandUrl -> brandPackage; it can never inherit another brand. Unknown brand asset -> neutral wordmark, not the wrong brand. Regression test added (pkgs/shared now has a test runner).

Drop the orphan 'or' divider: it lived between the social block and the password form, but with unconfigured social hidden it dangled above the form on every brand. Moved the divider INTO SocialButtons so it renders only alongside actual buttons; removed the standalone one from Login + Signup.
2026-06-20 02:38:31 -07:00
zeekay fe30228bdc fix(login): only show configured social providers; never show a broken brand logo
Two things made login feel broken across every brand on the portal (hanzo.id/lux.id/pars.id/osage.id):

1) Social SSO dead-ended. The GitHub/Google/Apple/Web3 providers are seeded with PLACEHOLDER credentials (clientId=GITHUB_CLIENT_ID_PLACEHOLDER, etc.), so the OAuth redirect can never complete — clicking 'Continue with GitHub' just looped back to the login form. Render ONLY providers IAM actually holds a real clientId for (AppProvider.configured, computed from the nested provider record). With placeholders that hides all social buttons, leaving the working methods (email/password + email/SMS code) — and the buttons reappear automatically once real OAuth creds are seeded, no code change. When get-app-login is unreadable, render none rather than risk a dead-end.

2) Broken logo. brand.json points logo/favicon at jsdelivr @hanzo/brand assets, but the published brand packages ship no assets/ dir (404). BrandHeader now falls back to the brand NAME as a text wordmark on image error/absence, so a missing logo never renders a broken-image icon.

Enabling real social login later needs: (a) register OAuth apps (GitHub/Google/Apple) with callback https://<brand>/v1/iam/callback, (b) put their client_id/secret in KMS → IAM provider records, (c) the SPA provider-redirect hop. (a)+(b) are external provisioning; until then social is correctly hidden.
2026-06-20 02:21:35 -07:00
zeekay d0863532d4 fix(auth): forward PKCE code_challenge on password login
login() built /v1/iam/login with clientId/responseType/redirectUri/state
but dropped the PKCE code_challenge/code_challenge_method that authorize()
already forwards. A downstream public SPA client (e.g. hanzo-platform)
sending a user to password sign-in got an auth code minted with an empty
stored challenge; the downstream /auth/callback token exchange then failed
PKCE validation and fell back to a client_secret check the public client
can't satisfy (token.CodeChallenge: empty -> invalid_client).

IAM's Login handler (controllers/account.go) reads code_challenge from the
query first, body fallback, so forward it on the query mirroring authorize().
Plumb it from the OAuth params already on the /login URL through Login page
-> LoginForm -> client.login(). Social login was unaffected (rides authorize).
2026-06-20 01:36:59 -07:00
zeekay 68c33527b9 fix(docker): bake SPA-appropriate CSP into the image (HANZO_STATIC_CSP)
hanzoai/static defaults CSP to `default-src 'none'` which blocks the
SPA's own bundle (no script-src) -> blank page. Bake a widened-but-locked
CSP that permits: self scripts + CF beacon, same-origin + jsDelivr brand
fetches, https/data images (brand logos), inline styles (@hanzo/gui).
Baking it (vs a deploy-time env) makes the image render correctly
standalone and survives universe reconcile.
2026-06-20 00:17:30 -07:00
zeekay 6af816ac9c fix(brand): emit + fetch brand.json at flat encoding-safe /brand/<scope>.json
Two coupled bugs broke runtime brand loading on the hanzoai/static
(scratch) image:

1. vite.config.ts is ESM ("type":"module") so the brandJsonPlugin's
   require.resolve('@scope/brand/brand.json') threw ReferenceError
   (no global require), was swallowed by the silent catch, and NO
   brand.json was ever emitted into dist/. Fixed with
   createRequire(import.meta.url).

2. The brand fetch path /brand/@hanzo/brand/brand.json carries a literal
   @ and an encoded %2F that the production static server cannot map to
   the on-disk file, so it falls through to the SPA catch-all and returns
   index.html — the SPA then parses HTML as JSON. Emit and fetch at a
   FLAT slug path instead: @hanzo/brand -> /brand/hanzo.json. The plugin
   and loadBrand derive the slug identically (npm scope).

With defensive loadBrand (prev commit) a miss degrades to a neutral
brand; this restores real per-brand branding.
2026-06-20 00:07:06 -07:00
zeekay 84a529cd0a fix(brand): never blank the login form on a transient brand.json failure
The browser brand loader threw on any non-ok /brand/<pkg>/brand.json fetch, so a single transient 502 on the cosmetic brand asset (intermittent behind Cloudflare) left the IAM login SPA blank — no form, just 'brand.json fetch failed: 502'. Retry the flaky asset up to 3x with backoff, then fall back to a neutral per-tenant brand so the form always renders. brand.json stays the source of truth on the happy path.
2026-06-19 23:54:11 -07:00
zeekay bfbca725f2 fix(docker): serve SPA from /public with --spa for hanzoai/static contract
hanzoai/static:0.4.1 is FROM scratch + ENTRYPOINT ["/static"]; the
binary defaults to -root /public -port 3000 and writes
/public/config.json from SPA_* env at boot. The previous final stage
copied the bundle to /spa and set dead ENV ROOT/PORT (the binary reads
flags, not env), so /public never existed and the runtime-config write
crashed (open /public/config.json.tmp: no such file or directory).

Copy the dist to /public (the default root + config.json target) and
pass --spa so client-routed paths (/auth/*, /callback) fall back to
index.html. Port 3000 matches the id Service and probes.
2026-06-19 23:48:00 -07:00
zeekay 6265944602 fix(docker): COPY pkgs/onboarding/package.json into install stage
apps/web depends on @hanzo/id-onboarding (workspace:*) since the
onboarding flow landed, but the Dockerfile's per-package manifest
COPY list (the layer feeding the dependency-resolution
`pnpm install` before the full `COPY pkgs pkgs`) was never
updated. In-cluster builds therefore failed at install with
ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for @hanzo/id-onboarding.

Local builds masked this because node_modules was already
populated from a prior full install.
2026-06-19 23:40:16 -07:00
zeekay 3e31fe7373 test(onboarding): unit tests for step machine + IAM wire contracts; docs
9 tests via the Node built-in runner (--experimental-strip-types, no
test-framework dependency): step machine (org→project→wallet→done),
listOrgs mapping + error-resilience, createOrg request shape + error
surfacing, linkWallet address validation + get-account→update-user
(web3onboard, column-scoped) + fail-closed. tc excludes *.test.ts so the
build gate stays type-only.

LLM.md: corrected issuer to per-brand *.id host (not iam.hanzo.ai),
documented the full method set + get-app-login source-of-truth, the
onboarding pkg + its IAM routes + admin-gating reality, and the /v1/iam
OIDC paths.
2026-06-19 15:34:37 -07:00
zeekay 8163c3c2a8 fix(auth): social/Web3 sign-in honors downstream redirect_uri
Social + Web3 always return to the portal's own /callback (the SDK's fixed
redirectUri), so a downstream app's redirect_uri would be lost. SocialButtons
now stashes it in post_login_redirect before signinRedirect; Callback reads it
back and forwards the tokens there, else lands on /onboarding. Login + Signup
pass redirect_uri through. Password path already forwards directly via the
auth-code response — both methods now reach the same downstream target.
2026-06-19 15:31:21 -07:00
zeekay 8cc5428e97 fix(onboarding): correct IAM contracts — get-account+web3onboard wallet, authz-aware org/project
linkWallet now resolves the signed-in user via /v1/iam/get-account (owner/name)
and writes the lowercase `web3onboard` column scoped via ?columns= so the rest
of the user row is untouched — IAM's update-user is keyed by owner/name, not a
self alias.

Org/project creation is admin-gated in IAM authz (add-organization needs the
admin role; add-project default-denies non-admins). The common path — pick the
org you signed up into, listed via the *-allowed get-organizations — works for
everyone; create-new surfaces a plain permission message and the step stays
skippable so onboarding never hard-blocks. ProjectStep tolerates an org-less
flow (continue-only).
2026-06-19 15:29:42 -07:00
zeekay d402732ebf feat(onboarding): post-login org → project → wallet flow + serverUrl=hanzo.id
@hanzo/id-onboarding: domain (serializable step machine + types) / service
(IAM-backed writes via /v1/iam/{get-organizations,add-organization,add-project,
update-user}, cookie+bearer) / UI (self-contained 3-step OnboardingFlow, no
router lib). Web app mounts it at /onboarding; both password (cookie) and
social/Web3 (SDK token) sign-in land there for a bare portal login.

Callback now completes via the @hanzo/iam SDK handleCallback (matches
SocialButtons' signinRedirect) and forwards tokens to a downstream app only
when one initiated the flow.

tenant.ts: iamUrl is the per-brand OIDC issuer host (hanzo.id / lux.id /
zoo.id / pars.id), never iam.hanzo.ai — HIP-0111 host-relative discovery.
clientId is the brand -id app (hanzo-id), matching init_data.json.
2026-06-19 15:25:04 -07:00
zeekay 9590dc319d feat(auth): full method set — email/password + GitHub + Google + Web3
SocialButtons reads live enabled providers from /v1/iam/get-app-login and
drives the @hanzo/iam PKCE redirect (provider param) per method. Login/Signup
render the social row + divider above the email form. AppLogin/AppProvider
types model the get-app-login view; createIam() centralizes the one PKCE
client both SocialButtons and Callback share.
2026-06-19 15:19:50 -07:00
475d0291b4 fix(brand): resilient brand loader — never blank the login page (#14)
loadBrand did res.json() on the SPA-fallback HTML (the spa server answers
unknown paths with index.html + HTTP 200 for client routing), so a missing
brand.json threw 'Unexpected token <' and crashed App boot -> blank login
page. brand.json is not bundled and the path also wrongly encoded the '/'
in '@hanzo/brand' (%2F), so it never resolved.

Fix:
- Prefer the tenant's brandUrl (the working jsDelivr brand.json from
  config.json), then the app-local path; reject non-JSON (SPA-fallback HTML)
  responses by content-type; on total failure return a minimal fallback brand
  derived from the package scope. Branding is cosmetic and must never block login.
- Add TenantConfig.brandUrl (already supplied by config.json) and pass it from App.
- Drop encodeURIComponent on the whole package (it mangled the scope slash).

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 17:56:31 -07:00
ab4f666a2b fix(docker): pin hanzoai/spa:1.2.0 (1.3.0 image not published) (#13)
The v1.3.0 git tag exists but no 1.3.0 image was published to ghcr
(build pipeline blocked). 1.2.0 is the latest published spa image and the
version the canonical RECIPE.md uses.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 16:18:13 -07:00
5372a45fc7 fix(docker): serve SPA via hanzoai/spa, not hanzoai/static (#12)
hanzoai/static defaults to Content-Security-Policy: default-src 'none'
(no script-src) — built for static assets, not a SPA that loads its own
JS bundle. That CSP blocks index-*.js, so React never mounts and the
login page renders blank. hanzoai/spa is the purpose-built base: history-
API fallthrough for client-side routes + a SPA-safe CSP. Defaults
PORT=3000 / ROOT=/public, matching the id deploy probe.

Immediate prod was unblocked by setting HANZO_STATIC_CSP on the live
deployment; this is the durable, one-way fix so the override hack isn't
needed and every rebuild serves correctly.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 16:14:27 -07:00
bf6acb79d8 fix(docker): static needs -root/-spa flags, not ROOT/PORT env (#11)
hanzoai/static:0.4.1 reads the serve root from the -root flag (default
/public) and port from -port (default 3000) — the ENV ROOT=/spa PORT=8080
were no-ops, so the built image served /public (crash) on :8080 while the
deploy probes :3000. Set ENTRYPOINT [/static -root /spa -spa] (default
port 3000). Matches the hand-built id:0.1.5 now live.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 15:33:36 -07:00
60f914894d fix(id): appLogin uses /v1/iam/get-app-login (main dropped /api/*) (#10)
Merged IAM main serves the canonical /v1/iam/* only; /api/get-app-login
now returns the SPA shell. Point the providers/methods fetch at the
canonical path so social buttons + sign-in methods load.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 14:08:32 -07:00
7503329462 feat(id): render social logins + email + SMS code on login/signup (#9)
The Vite portal rendered only email/password. Add — driven by the live
IAM app config (new AuthClient.appLogin -> get-app-login), no hardcoded
lists:
- social provider buttons (GitHub/Google/Apple/Web3) via authorize?provider=
- passwordless email / SMS code sign-in (send-verification-code + OTPForm)
- new ProviderButtons component; LoginForm + SignupForm render providers
Email/password sign-in unchanged. tsc --noEmit clean; vite build clean.

Functional prerequisites (display works now; these make it WORK):
- real OAuth client IDs for github/google/apple (IAM currently has
  GITHUB_CLIENT_ID_PLACEHOLDER etc.)
- an SMS gateway provider (Twilio/etc.) in IAM for SMS codes
- redirectUris backfill so the /callback OAuth return is allowed (iam#51)

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 13:42:41 -07:00
4186888067 chore(deps): clear Dependabot alerts — drop legacy-nextjs, patch esbuild/uuid (#8)
Resolves all 35 open Dependabot alerts on hanzoai/id:

- Delete legacy-nextjs/ (frozen Next.js predecessor, not wired into any
  build/Dockerfile/workspace glob; docs slated it for deletion after
  v0.1.0 — v0.1.1 has shipped). Removes 31 alerts (next, undici, ws,
  picomatch, postcss, js-yaml, cookie, defu, ...).

- pnpm overrides for the 3 active-workspace transitives:
    esbuild ^0.28.1  (was 0.27.7) — GHSA-gv7w-rqvm-qjhr (high), GHSA-g7r4-m6w7-qqqr (low)
    uuid    ^11.1.1  (was 7.0.3/10/x via xcode@3.0.1) — GHSA-w5hq-g745-h8pq (medium)

Verified: pnpm install + typecheck (4/4) + build green (vite 7.3.5,
esbuild 0.28.1, 52 modules); 'pnpm audit' → no known vulnerabilities.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 12:23:57 -07:00
8fce0ef2f2 fix(tenant): use real IAM clientId (<org>-id, not <org>-id-portal) (#7)
The IAM apps are registered with clientId '<org>-id' (hanzo-id, lux-id,
zoo-id, pars-id) — there is no '-portal' variant. clientId 'hanzo-id-portal'
returns 'Invalid client_id' from IAM, breaking the login flow. Align all
four tenant clientIds with their appName and the IAM seed convention.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-18 11:34:50 -07:00
1e43cfe72a fix(id): pin @hanzo/iam to published ^0.9.4 (0.10.0 unpublished, breaks pnpm install) (#6)
Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 04:03:28 -07:00
dd9addbb94 chore(decomplect): deploy + per-host catalog live in universe, not the app repo (#5)
The id repo is brand-neutral (zero brand data bundled), but apps/web/k8s/
carried the per-host brand catalog + Deployment/Ingress — duplicated in
hanzoai/universe infra/k8s/id/ (the single deploy source-of-truth). Removed
the overlay so app (brand-neutral image) and deploy (per-host catalog +
manifests, in universe) are separated. One way, one place.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:05:31 -07:00
d4540a08f8 fix(auth): IAM wire contract (body params) + brand-app catalog (v0.1.1) (#4)
* fix(auth): IAM wire contract + brand-app catalog

- client.ts: send type/application/organization in the request BODY (IAM reads
  auth fields from body, OAuth params from query) so the branded login form
  authenticates; build the code-redirect from the response.
- canonical /v1/iam/oauth/{authorize,token,logout} paths.
- tenant-catalog: point each host at its brand's real IAM app
  (hanzo-console/lux-cloud/zoo-console/pars-console).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(release): 0.1.1

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:00:41 -07:00
hanzo-dev 99b619bcaa chore: update 2026-06-10 14:12:32 -07:00
hanzo-dev 9cc264b867 merge: id-vite-monorepo (-X theirs) 2026-06-01 18:09:45 -07:00
hanzo-dev 4a7eacdb64 merge: ci/canonical-docker-build-1776995843 (-X theirs) 2026-06-01 18:09:44 -07:00
hanzo-dev 7c00afb201 refactor: brand-neutral identity portal (zero brand-specific code)
Image carries no brand identity. Every per-tenant value comes from the
runtime catalog (K8s ConfigMap → /config.json → window.__ID_CATALOG__)
or is derived from the hostname.

Source changes:
- TenantConfig.brandPackage → brandUrl (absolute URL to brand.json).
  Brands self-host (npm + jsDelivr is convention; any URL works).
- DEFAULT_TENANTS = {} (deleted). Hostname-derivation is the new fallback:
    foo.id / www.foo.id / id.foo.net / iam.foo.net → orgId=foo,
    clientId=foo-id-portal, appName=foo-id,
    brandUrl=https://cdn.jsdelivr.net/npm/@foo/brand@latest/brand.json
  Works out-of-the-box when the npm scope matches the org. The catalog
  handles mismatches.
- brand.ts loadBrand(brandUrl) fetches the URL directly. localizeAssets()
  removed — brand.json's URLs are absolute, used as-is.
- vite.config.ts: removed brandJsonPlugin + BRAND_PACKAGES list. Vite
  bundles no brand assets. dist drops from ~250kB to 205kB (64kB gzip).
- apps/web/package.json: dropped @hanzo/brand, @luxfi/brand, @zooai/brand,
  @parsdao/brand deps. Zero brand packages in the image.
- main.tsx: fetches /config.json before mount; sets
  window.__ID_CATALOG__ from cfg.iamTenantConfigJson (templated by
  hanzoai/spa runtime from SPA_IAM_TENANT_CONFIG_JSON env var).
- App.tsx: loadBrand(t.brandUrl).
- LLM.md: documents the brand-neutral architecture and how to add a
  brand (publish brand.json + add to deploy's catalog ConfigMap; image
  never changes).

Deploy changes:
- apps/web/k8s/tenant-catalog.yaml: NEW ConfigMap carrying the Hanzo
  deployment's full host→tenant map for 11 hosts (hanzo / lux / zoo /
  pars / osage and their id.*.network / iam.*.network / www.* variants).
  Brand-specific knowledge lives ONLY here.
- apps/web/k8s/deployment.yaml: envFrom: configMapRef: id-tenant-catalog.
- apps/web/k8s/kustomization.yaml: includes tenant-catalog.yaml first.

Adding a brand from here on is:
  1. publish @<scope>/brand to npm with brand.json
  2. add the host(s) to the deployment's ConfigMap
  3. add the host to ingress.yaml (cert-manager auto-provisions TLS)
  4. DNS → cluster ingress IP

No source change. No image rebuild.
2026-05-29 11:37:05 -07:00
hanzo-dev 9f184bb20a feat(k8s): add www.zoolabs.id + osage.id + www.osage.id to id ingress
cert-manager.io/cluster-issuer=letsencrypt-prod-cf provisions per-host
TLS via DNS-01 (the Traefik file-route certResolver path is non-functional
in this cluster — CF env vars on the ingress deployment are empty, ACME
in /data/acme.json is empty). cert-manager owns TLS for identity hosts.

After apply + cert issuance (~90s): all 7 identity hosts return their
own OIDC issuer with valid LE certs:
  lux.id, hanzo.id, pars.id, zoolabs.id, www.zoolabs.id, osage.id, www.osage.id
2026-05-29 11:07:17 -07:00
hanzo-dev c4c2760374 feat(tenant): add osage.id + www.osage.id + www.zoolabs.id tenants
DEFAULT_TENANTS now resolves all 4 active identity hosts that didn't
have built-in entries:
- www.zoolabs.id → orgId=zoo, brand=@zooai/brand
- osage.id      → orgId=osage, brand=@osage/brand
- www.osage.id  → orgId=osage, brand=@osage/brand

zoolabs.id was already present. orgId, iamIssuer, clientId, appName,
publicOrigin, brandPackage all follow the existing per-host shape.

@osage/brand is unpublished (~/work/osage/brand v0.1.0 needs build +
publish). Until then, /brand/@osage/brand/brand.json 404s and the
SPA's loadBrand falls back to the @hanzo/brand default — adequate
until osage.id DNS flips off Cloudflare Pages.
2026-05-28 18:27:27 -07:00
hanzo-dev f118890485 chore: update 2026-05-25 15:14:35 -07:00
hanzo-dev ca8dd4ab74 feat: add zoolabs.id (canonical Zoo identity host; was zoo.id)
Per user clarification, the Zoo identity domain is zoolabs.id (zoo.id is
not owned). Add tenant entry + Ingress host + TLS secret. DNS A record
already repointed in CF to 129.212.164.5.
2026-05-24 14:10:33 -07:00
hanzo-dev bdb08d0d98 fix(tenant): hanzo.id clientId was mangled by previous sed (id-portal-portal → hanzo-id-portal) 2026-05-24 10:32:18 -07:00
hanzo-dev 70a14dc902 style: rename CSS classes hanzo-id-* → id-portal-*
Brand-neutral CSS class names match the public-facing identity of the
portal (id-portal). Cosmetic only; no rendered text changes. Per
playwright agent's source-view note.
2026-05-24 10:31:03 -07:00
hanzo-dev 1dbc79387b feat: extend id portal to .network identity hosts
Add iam.lux.network + id.lux.network (Lux brand) + id.zoo.network
(Zoo brand) to the id Ingress + tenant catalog. Same image, same code
path — only DEFAULT_TENANTS + Ingress hosts/tls grow.

Cutover plan (post-merge):
  1. kubectl apply -k infra/k8s/id  (cert-manager issues 3 new TLS certs via DNS-01)
  2. Repoint CF DNS A records → 129.212.164.5
  3. Delete the 3 hanzo-id Worker routes
  4. Archive hanzoai/hanzo.id-worker repo (no routes remaining)
2026-05-24 10:12:23 -07:00
hanzo-dev 74d41880b5 feat(brand): bundle brand logo SVGs as static assets
The published @hanzo/brand / @luxfi/brand / @zooai/brand / @parsdao/brand
npm tarballs don't ship the assets/ directory (only dist/ + brand.json
per the pkg's 'files' field). The Vite plugin can only emit what's
present on disk; result: 404 on /brand/<pkg>/assets/logo/logo.svg.

Workaround until the brand pkgs republish with assets included: commit
the SVGs into apps/web/public/brand/<pkg>/assets/logo/ so they ship in
the SPA bundle directly. Same path the localizeAssets rewrite produces,
so loadBrand() output is unchanged.

To update: re-copy from ~/work/<org>/brand/assets/logo/ and bump
@hanzo/id patch.
2026-05-24 09:39:59 -07:00
hanzo-dev 7853644811 fix(brand): regex now matches @scoped/name pkg URLs
The old regex [^@/]+ excluded the leading @ in @scope/name pkg paths
on jsdelivr, so the rewrite silently passed through to the upstream
CDN URL and the browser rendered a broken-image glyph. Add @? prefix.
2026-05-24 09:32:47 -07:00
hanzo-dev b1ed54303c fix(brand): serve logo + assets from /brand/<pkg>/, switch to DNS-01 issuer
playwright agent flagged: brand.json works but logo SVG 404s on
jsdelivr because the brand pkgs' npm tarballs don't ship assets/logo/.

Fix at this side instead of waiting on a brand-pkg republish:
  1. vite plugin now serves ALL files under <pkg>/assets/ (not just
     brand.json), at /brand/<pkg>/assets/...
  2. loadBrand() in pkgs/shared rewrites the brand.json logoUrl and
     faviconUrl from /npm/<pkg>@latest/<rest> → /brand/<pkg>/<rest>.
     Only the brand's own pkg URLs rewrite; third-party CDN refs pass
     through unchanged.

Also flip the cert-manager cluster-issuer for the id Ingress from
letsencrypt-prod (HTTP-01) to letsencrypt-prod-cf (DNS-01 via
Cloudflare). The Ingress unconditionally 308-redirects all HTTP→HTTPS
including /.well-known/acme-challenge/*, so HTTP-01 never solves.
DNS-01 via CF API works regardless of HTTP path routing.
2026-05-24 09:17:36 -07:00
hanzo-dev d3dbc1e132 fix(vite): brand-json plugin actually emits files at build time
The previous plugin used require.resolve in ESM context which failed
silently — generateBundle had no warning and no assets emitted. Result:
production image had no /brand/<pkg>/brand.json files, hanzoai/spa
fell back to index.html for those paths, and the browser tried to JSON-parse
HTML → 'Unexpected token <' on every host.

Fix: use createRequire(import.meta.url) + paths fallback walk through
node_modules. emitFile now writes dist/brand/<pkg>/brand.json.

Verified by: pnpm build outputs dist/brand/@hanzo/brand/brand.json etc.
playwright report flagged this as the root cause of the white-label
blank page across hanzo.id / lux.id / pars.id.
2026-05-24 08:43:02 -07:00
hanzo-dev f211991862 fix(docker): switch to hanzoai/spa (zero-config SPA server)
hanzoai/static is for traditional static-file serving (no SPA fallback,
needs --spa flag for client-side routing, /healthz only). hanzoai/spa is
purpose-built for SPAs: SPA mode always on, runtime config via SPA_* env
vars, /health endpoint. K8s probes flip /healthz → /health to match.
2026-05-24 07:46:08 -07:00
hanzo-dev 3e774999da fix(docker): static image serves /public on :3000 (not /spa:8080)
hanzoai/static:0.4.1 is hardcoded to read /public and listen on :3000.
PORT/ROOT env vars from the Dockerfile are ignored. Move dist there.

K8s deployment containerPort + probes flipped from 8080 to 3000 to match.
Service targetPort already :3000. Service port 80 stays the same.
2026-05-24 07:12:28 -07:00
hanzo-dev 8545a85d5d deps: pin @hanzo/iam to ^0.9.4 (latest published); drop @hanzo/gui (not published yet)
The 0.10.0 / 7.2.4 versions in source are unpublished WIP. Use the latest
public-npm versions so the Docker build can resolve. @hanzo/gui re-adds
once 7.2.x publishes to npm (per hanzoai/gui PR #2 dist/ emit fix).
2026-05-24 06:57:56 -07:00
hanzo-dev 396b0700f5 ci: amd64-only (arm64 ARC pool paused on DOKS) 2026-05-24 06:50:23 -07:00
hanzo-dev dbaf3079f5 ci: drop pre-build-command — Dockerfile self-contains pnpm build 2026-05-24 06:38:53 -07:00
hanzo-dev 293787a9ff ci: drop CF Pages deploy (was Next.js-only; Vite SPA ships via Docker) 2026-05-24 06:25:55 -07:00
hanzo-dev 0dd2a82d24 ci: add id-token permission + allow pnpm to generate lockfile
Tag-push of v0.1.0 hit startup_failure because the caller workflow lacked
id-token: write. The hanzoai/.github reusable docker-build.yml requires it
at the caller's top-level (see universe LLM.md, 2026-05-05 sprint notes).

Also drop --frozen-lockfile since this is a fresh rewrite without a
checked-in lockfile yet — let pnpm generate one.
2026-05-24 06:09:28 -07:00
hanzo-devandGitHub 6957e9a07d rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js) (#2)
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the a-monorepo/id pattern.

Layout:
  apps/web/        Vite + React 19 SPA, embeds @hanzo/gui shell
    k8s/           Deployment(2) + Service + Ingress (4 hosts, 4 TLS)
  pkgs/shared/     @hanzo/id-shared — TenantConfig, resolveTenant,
                                       loadBrand (browser + node)
  pkgs/auth/       @hanzo/id-auth   — AuthClient (wraps @hanzo/iam REST)
                                       + LoginForm/SignupForm/ForgotForm/OTPForm
  pkgs/idv/        @hanzo/id-idv    — pluggable IDV (stub, persona,
                                       onfido, veriff) behind one
                                       IDVProvider interface
  legacy-nextjs/   Frozen — Next.js predecessor. Delete after v0.1.0 ships.
  Dockerfile       Two-stage: pnpm build → hanzoai/static:0.4.1 serves /spa
  README.md
  LLM.md           Architecture, dev, deploy, cutover plan

Tenant resolution: hostname → TenantConfig (orgId, iamUrl, clientId,
appName, publicOrigin, brandPackage). Built-in defaults for
hanzo.id/lux.id/zoo.id/pars.id; runtime override via
IAM_TENANT_CONFIG_JSON env (served as /config.json at pod startup).

Brand resolution: each per-org brand pkg (@hanzo/brand, @luxfi/brand,
@zooai/brand, @parsdao/brand) ships brand.json. The Vite plugin
brandJsonPlugin emits /brand/<pkg>/brand.json verbatim; the browser
fetches the right one based on the resolved tenant. No bundle bloat.

IDV: stub (default for dev), persona, onfido, veriff. Each adapter
implements `IDVProvider` from pkgs/idv/src/provider.ts. Swap providers
with one registration call at boot — portal code unchanged.

Cutover (separate ops PR — not in this commit):
  1. Tag + push image ghcr.io/hanzoai/id:0.1.0
  2. Apply k8s manifests, cert-manager issues TLS
  3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
  4. CF A records → 129.212.164.5 (hanzo ingress LB)
  5. Archive hanzo.id-worker repo
2026-05-24 06:00:20 -07:00
hanzo-dev aaf43491ab rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js)
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the a-monorepo/id pattern.

Layout:
  apps/web/        Vite + React 19 SPA, embeds @hanzo/gui shell
    k8s/           Deployment(2) + Service + Ingress (4 hosts, 4 TLS)
  pkgs/shared/     @hanzo/id-shared — TenantConfig, resolveTenant,
                                       loadBrand (browser + node)
  pkgs/auth/       @hanzo/id-auth   — AuthClient (wraps @hanzo/iam REST)
                                       + LoginForm/SignupForm/ForgotForm/OTPForm
  pkgs/idv/        @hanzo/id-idv    — pluggable IDV (stub, persona,
                                       onfido, veriff) behind one
                                       IDVProvider interface
  legacy-nextjs/   Frozen — Next.js predecessor. Delete after v0.1.0 ships.
  Dockerfile       Two-stage: pnpm build → hanzoai/static:0.4.1 serves /spa
  README.md
  LLM.md           Architecture, dev, deploy, cutover plan

Tenant resolution: hostname → TenantConfig (orgId, iamUrl, clientId,
appName, publicOrigin, brandPackage). Built-in defaults for
hanzo.id/lux.id/zoo.id/pars.id; runtime override via
IAM_TENANT_CONFIG_JSON env (served as /config.json at pod startup).

Brand resolution: each per-org brand pkg (@hanzo/brand, @luxfi/brand,
@zooai/brand, @parsdao/brand) ships brand.json. The Vite plugin
brandJsonPlugin emits /brand/<pkg>/brand.json verbatim; the browser
fetches the right one based on the resolved tenant. No bundle bloat.

IDV: stub (default for dev), persona, onfido, veriff. Each adapter
implements `IDVProvider` from pkgs/idv/src/provider.ts. Swap providers
with one registration call at boot — portal code unchanged.

Cutover (separate ops PR — not in this commit):
  1. Tag + push image ghcr.io/hanzoai/id:0.1.0
  2. Apply k8s manifests, cert-manager issues TLS
  3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
  4. CF A records → 129.212.164.5 (hanzo ingress LB)
  5. Archive hanzo.id-worker repo
2026-05-24 01:50:39 -07:00
hanzo-dev 63ddef635d fix(middleware): proxy /v1/iam/* canonical IAM surface
Bug: hanzo.id returned 405 on POST /v1/iam/login because:

1. The matcher had no /v1/iam/:path* rule — the middleware never fired,
   the static SPA had no POST handler at that path, CF Pages returned
   405 directly.
2. IAM_PATH_PREFIXES carried '/api/' but not '/v1/iam/' — even if the
   matcher did fire, shouldProxyToIAM() returned false.
3. PATH_REWRITES mapped RFC OAuth paths (/oauth/token, /oauth/userinfo,
   …) onto legacy /api/* targets — wrong direction; IAM serves /v1/iam/*
   natively.

Fix: drop the entire /api/* hop. RFC aliases now collapse onto canonical
/v1/iam/* targets (one-way mapping, no legacy detour). The matcher lists
/v1/iam/:path*. SPA components, lib/oauth.ts, and the server-side logout
handler all call /v1/iam/* directly. The discovery rewriter strips the
canonical /v1/iam/* form back to RFC-public /oauth/* for OIDC clients.

Net: one canonical surface (/v1/iam/*), three RFC-spec public aliases
(/oauth/*, /login/oauth/*, /.well-known/*). No /api/* anywhere in the
caller path.
2026-05-15 14:35:21 -07:00
hanzo-devandGitHub 8ac9f1e2a4 ci: migrate to canonical hanzoai/.github/docker-build.yml reusable (#1) 2026-04-23 18:58:37 -07:00
hanzo-dev 2fae3831be ci: migrate to canonical hanzoai/.github/docker-build.yml reusable 2026-04-23 18:57:29 -07:00
hanzo-dev b8cba121a9 feat: add id.lux.cloud as Lux tenant (was defaulting to Hanzo) 2026-04-20 21:37:05 -07:00
hanzo-dev 1de63b9cc3 refactor: flatten id-{dev,test}.hanzo.ai so *.hanzo.ai Universal SSL covers them 2026-04-20 21:31:45 -07:00
hanzo-dev 3e85378bc2 feat: zoolabs.id as canonical Zoo tenant (replaces zoo.id)
zoolabs.id was just acquired to replace zoo.id which we no longer own.
Same Zoo branding + content + socialProviders as id.zoo.network.
2026-04-20 19:45:15 -07:00
hanzo-dev cbae8df738 feat(branding): TENANT_BRANDING_JSON env var for runtime white-labeling
Allows any deployment of hanzo-login image to add/override tenants without
white-label deployment to inject their own domain branding + auth providers.

Example:
  env:
    - name: TENANT_BRANDING_JSON
      value: |
        {
            "orgId": "liquidity",
            "orgName": "",
            "content": { "title": "Trade digital securities" },
            "auth": { "socialProviders": ["google", "apple"] }
          }
        }
2026-04-20 19:35:09 -07:00
73 changed files with 8457 additions and 312 deletions
+9
View File
@@ -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

+7 -48
View File
@@ -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"
+131
View File
@@ -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"
+60
View File
@@ -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
View File
@@ -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
+317 -3
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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>
+9 -4
View File
@@ -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

+8
View File
@@ -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

+7 -2
View File
@@ -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
View File
@@ -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;
+4 -4
View File
@@ -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') ?? '',
+223
View File
@@ -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
}
+91 -3
View File
@@ -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">
+11 -10
View File
@@ -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
View File
@@ -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"
}
}
+7 -5
View File
@@ -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": {
+1 -1
View File
@@ -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'
+436
View File
@@ -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
View File
@@ -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,
+11
View File
@@ -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'
+59 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+48 -2
View File
@@ -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 {
+224 -64
View File
@@ -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
+8
View File
@@ -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">
+234
View File
@@ -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)
})
+245
View File
@@ -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)
}
+99
View File
@@ -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.
+35
View File
@@ -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"
}
}
+434
View File
@@ -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/);
});
});
+222
View File
@@ -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);
});
});
+140
View File
@@ -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);
});
});
+231
View File
@@ -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);
});
});
+43
View File
@@ -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));
}
+91
View File
@@ -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;
}
+152
View File
@@ -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;
}
}
}
+555
View File
@@ -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;
}
}
+39
View File
@@ -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);
}
+162
View File
@@ -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;
}
+60
View File
@@ -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),
];
}
+211
View File
@@ -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;
}
}
+56
View File
@@ -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();
}
+51
View File
@@ -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';
+50
View File
@@ -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;
}
}
+49
View File
@@ -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,
};
}
+140
View File
@@ -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;
}
}
}
+22
View File
@@ -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;
}
}
+210
View File
@@ -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}`,
};
}
}
+158
View File
@@ -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;
}
}
+140
View File
@@ -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;
}
+107
View File
@@ -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 };
}
+123
View File
@@ -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;
}
}
+130
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noEmit": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+2 -3
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 -3
View File
@@ -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 }),
})
+26 -71
View File
@@ -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>
)
}
+7 -3
View File
@@ -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"
}
}
+18
View File
@@ -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,
+29 -1
View File
@@ -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')
+1 -1
View File
@@ -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
View File
@@ -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'
+1674 -9
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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',
},
})