mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
A wallet with no account attaches to the account you sign in with
Pressing a wallet on the sign-in page sent IAM a login, and IAM's answer for a wallet nobody holds — "no account is linked to this wallet" — was drawn as a dead end beside a Create account button. Somebody who already had an account by email or a provider made a second one to get in. IAM attaches a verified wallet to an existing identity only when the verify arrives with that identity's own same-site session (hanzoai/iam internal/wallet/verify.go). So the refusal is now named (`unlinked`), the chain is parked in session storage, the page says to sign in below, and after any sign-in — the form, a second factor, a provider's return through /callback — the wallet flow runs once more with the session and IAM links it. From then on the wallet alone signs them in. A declined second signature leaves the sign-in as it was, wallet unattached. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Brand, Org } from '@hanzo/id-shared'
|
||||
import { createIam } from '@hanzo/id-auth'
|
||||
import { attachParkedWallet, createAuthClient, createIam } from '@hanzo/id-auth'
|
||||
import { BrandFooter } from '../components/BrandFooter'
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,10 @@ export function Callback({ org, brand }: { org: Org; brand: Brand }) {
|
||||
const iam = createIam(org)
|
||||
iam
|
||||
.handleCallback(window.location.href)
|
||||
.then((tok) => {
|
||||
.then(async (tok) => {
|
||||
// A wallet refused before this sign-in for having no account attaches to
|
||||
// the account that just signed in through a provider.
|
||||
await attachParkedWallet(createAuthClient({ org }))
|
||||
const target = sessionStorage.getItem('post_login_redirect')
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
if (target) {
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
SocialButtons,
|
||||
mfaChannelOf,
|
||||
type AuthClient,
|
||||
type LoginResponse,
|
||||
} from '@hanzo/id-auth'
|
||||
type LoginResponse, attachParkedWallet } from '@hanzo/id-auth'
|
||||
import { BrandFooter } from '../components/BrandFooter'
|
||||
import { clientIdFrom } from '../route'
|
||||
|
||||
@@ -86,7 +85,10 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
|
||||
// The credential check succeeded (or MFA was satisfied). For a downstream
|
||||
// OIDC request, re-enter authorize with the now-established IAM session so it
|
||||
// mints the code; for a bare portal sign-in, land on onboarding.
|
||||
function completeAfterAuth() {
|
||||
async function completeAfterAuth() {
|
||||
// A wallet refused earlier for having no account attaches to the account
|
||||
// that just satisfied its factor.
|
||||
await attachParkedWallet(client)
|
||||
if (redirectUri) {
|
||||
window.location.href = client.authorize({
|
||||
clientId,
|
||||
@@ -176,9 +178,10 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
|
||||
if (res.error) {
|
||||
setChallengeError(res.error)
|
||||
} else if (res.redirectUrl) {
|
||||
await attachParkedWallet(client)
|
||||
window.location.href = res.redirectUrl
|
||||
} else {
|
||||
completeAfterAuth()
|
||||
await completeAfterAuth()
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hanzo/id",
|
||||
"private": true,
|
||||
"version": "0.2.98",
|
||||
"version": "0.2.99",
|
||||
"description": "Hanzo ID \u2014 white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
|
||||
@@ -20,6 +20,9 @@ export { createIam } from './iam'
|
||||
export { authorizeRequest, matchProviderHint } from './social'
|
||||
export {
|
||||
loginWithWalletChain,
|
||||
attachParkedWallet,
|
||||
parkWallet,
|
||||
parkedWallet,
|
||||
detectWalletChains,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletLoginContext,
|
||||
|
||||
@@ -81,6 +81,13 @@ export interface LoginResponse {
|
||||
* the address actually bound — which is what a caller may show or record.
|
||||
*/
|
||||
readonly walletAddress?: string
|
||||
/**
|
||||
* On a wallet flow: the signature verified, and no account holds this wallet.
|
||||
* Not a dead end — a person who has an account by email or a provider can sign
|
||||
* in with it and the wallet attaches to that account, which is what the login
|
||||
* page offers when this is set. `error` carries IAM's own sentence as well.
|
||||
*/
|
||||
readonly unlinked?: boolean
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Alert } from './Alert'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { PasswordField } from './PasswordField'
|
||||
import { Submit } from './Submit'
|
||||
import { attachParkedWallet } from '../web3'
|
||||
|
||||
export interface LoginFormProps {
|
||||
readonly client: AuthClient
|
||||
@@ -205,9 +206,16 @@ export function LoginForm(props: LoginFormProps) {
|
||||
if (res.error) {
|
||||
setError(res.error)
|
||||
props.onRefused?.(res.error)
|
||||
} else if (res.mfaRequired) {
|
||||
return
|
||||
}
|
||||
if (res.mfaRequired) {
|
||||
props.onMfaRequired?.(res)
|
||||
} else if (props.onAuthenticated) {
|
||||
return
|
||||
}
|
||||
// Signed in. A wallet that was refused for having no account attaches to
|
||||
// this one now, before the page is left — see attachParkedWallet.
|
||||
await attachParkedWallet(client)
|
||||
if (props.onAuthenticated) {
|
||||
// Caller owns the next step (e.g. device approval) — suppress the
|
||||
// default navigation so we stay on-page.
|
||||
props.onAuthenticated(res)
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AuthClient } from '../client'
|
||||
import type { AppProvider } from '../types'
|
||||
import { authorizeRequest, matchProviderHint, PROVIDER_ORDER } from '../social'
|
||||
import { createIam } from '../iam'
|
||||
import { loginWithWalletChain, detectWalletChains, WALLET_CHAIN_LABELS } from '../web3'
|
||||
import { loginWithWalletChain, detectWalletChains, parkWallet, WALLET_CHAIN_LABELS } from '../web3'
|
||||
import { GitHubIcon, GitLabIcon, GoogleIcon, PhoneIcon, WalletIcon } from './icons'
|
||||
import { Alert } from './Alert'
|
||||
import { Divider } from './Divider'
|
||||
@@ -317,7 +317,17 @@ export function SocialButtons({
|
||||
codeChallenge: sp.get('code_challenge') ?? undefined,
|
||||
codeChallengeMethod: (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined,
|
||||
})
|
||||
if (res.error) {
|
||||
if (res.unlinked) {
|
||||
// A wallet nobody holds is not a dead end. Somebody who already has an
|
||||
// account — by email or a provider — was met with IAM's refusal and a
|
||||
// "Create account" button, and made a second account to get in. The
|
||||
// wallet is parked; the sign-in below attaches it, and from then on
|
||||
// the wallet alone signs them in.
|
||||
parkWallet(chain)
|
||||
setError(
|
||||
'No account has this wallet yet. Sign in below with your email or a provider and it attaches to that account; after that, the wallet signs you in. New here? Create an account.',
|
||||
)
|
||||
} else if (res.error) {
|
||||
setError(res.error)
|
||||
} else if (res.redirectUrl) {
|
||||
// Same post-login redirect the password flow performs.
|
||||
|
||||
@@ -11,9 +11,12 @@ import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import {
|
||||
attachParkedWallet,
|
||||
loginWithWalletChain,
|
||||
detectWalletChains,
|
||||
offeredWalletChains,
|
||||
parkWallet,
|
||||
parkedWallet,
|
||||
WALLET_CHAIN_LABELS,
|
||||
type WalletSigner,
|
||||
} from './web3.ts'
|
||||
@@ -253,3 +256,89 @@ test('an IAM verify error is returned as { error }', async () => {
|
||||
assert.equal(res.error, 'web3: bad signature')
|
||||
assert.equal(res.redirectUrl, undefined)
|
||||
})
|
||||
|
||||
/** A session store for tests: the Storage surface over a Map. */
|
||||
function memory(): Storage {
|
||||
const m = new Map<string, string>()
|
||||
return {
|
||||
get length() {
|
||||
return m.size
|
||||
},
|
||||
clear: () => m.clear(),
|
||||
getItem: (k) => m.get(k) ?? null,
|
||||
key: (i) => [...m.keys()][i] ?? null,
|
||||
removeItem: (k) => void m.delete(k),
|
||||
setItem: (k, v) => void m.set(k, v),
|
||||
}
|
||||
}
|
||||
|
||||
test('a wallet nobody holds is refused as unlinked, with the address, not as a dead end', async () => {
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
const url = input.toString()
|
||||
if (url.includes('/v1/iam/web3/nonce')) {
|
||||
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), { status: 200 })
|
||||
}
|
||||
return new Response(JSON.stringify({ status: 'error', msg: 'web3: no account is linked to this wallet' }), { status: 200 })
|
||||
}
|
||||
const { sign, proof } = fakeSigner()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await loginWithWalletChain(client, 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.unlinked, true)
|
||||
assert.equal(res.walletAddress, proof.address)
|
||||
assert.match(res.error ?? '', /no account is linked/)
|
||||
})
|
||||
|
||||
test('any other refusal is not unlinked', async () => {
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
const url = input.toString()
|
||||
if (url.includes('/v1/iam/web3/nonce')) {
|
||||
return new Response(JSON.stringify({ status: 'ok', data: CHALLENGE }), { status: 200 })
|
||||
}
|
||||
return new Response(JSON.stringify({ status: 'error', msg: 'web3: this wallet is already linked to another account' }), { status: 200 })
|
||||
}
|
||||
const { sign } = fakeSigner()
|
||||
const res = await loginWithWalletChain(createAuthClient({ org: org(), fetchImpl }), 'evm', {}, fetchImpl, sign)
|
||||
assert.equal(res.unlinked, false)
|
||||
})
|
||||
|
||||
test('a parked wallet is read once, and only a known chain comes back', () => {
|
||||
const store = memory()
|
||||
parkWallet('solana', store)
|
||||
assert.equal(parkedWallet(store), 'solana')
|
||||
assert.equal(parkedWallet(store), null)
|
||||
store.setItem('hanzo_id_wallet_to_attach', 'not-a-chain')
|
||||
assert.equal(parkedWallet(store), null)
|
||||
})
|
||||
|
||||
test('after a sign-in the parked wallet runs the flow once more, with a session, and the park is cleared', async () => {
|
||||
const store = memory()
|
||||
parkWallet('evm', store)
|
||||
const { calls, fetchImpl } = capturingFetch('AUTHCODE')
|
||||
const { sign, seen } = fakeSigner()
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const res = await attachParkedWallet(client, fetchImpl, sign, store)
|
||||
assert.equal(seen.length, 1, 'signed once')
|
||||
assert.equal(calls.filter((c) => c.url.includes('/v1/iam/web3/verify')).length, 1, 'verified once')
|
||||
assert.equal(res?.walletAddress, '0xabc0000000000000000000000000000000000def')
|
||||
assert.equal(parkedWallet(store), null, 'the park is cleared')
|
||||
})
|
||||
|
||||
test('with nothing parked, a sign-in attaches nothing and touches no wallet', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const { sign, seen } = fakeSigner()
|
||||
const res = await attachParkedWallet(createAuthClient({ org: org(), fetchImpl }), fetchImpl, sign, memory())
|
||||
assert.equal(res, null)
|
||||
assert.equal(seen.length, 0)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('a declined second signature does not fail the sign-in it follows', async () => {
|
||||
const store = memory()
|
||||
parkWallet('evm', store)
|
||||
const { fetchImpl } = capturingFetch()
|
||||
const sign: WalletSigner = async () => {
|
||||
throw new Error('User rejected the request')
|
||||
}
|
||||
const res = await attachParkedWallet(createAuthClient({ org: org(), fetchImpl }), fetchImpl, sign, store)
|
||||
assert.match(res?.error ?? '', /rejected/)
|
||||
})
|
||||
|
||||
+63
-1
@@ -257,7 +257,12 @@ async function parseVerifyResponse(
|
||||
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 error = typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}`
|
||||
// IAM's login branch refuses a wallet nobody holds with this one sentence
|
||||
// (hanzoai/iam internal/wallet/verify.go, errNoAccount). It is the one
|
||||
// refusal a person can do something about, so it is named rather than left
|
||||
// as text: the page parks the wallet and offers a sign-in that attaches it.
|
||||
return { error, unlinked: /no account is linked to this wallet/i.test(error), walletAddress: address }
|
||||
}
|
||||
const data = body.data
|
||||
|
||||
@@ -280,3 +285,60 @@ function errMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message
|
||||
return String(err)
|
||||
}
|
||||
|
||||
// -- A wallet that has no account yet, attached to the one signed in next ----------
|
||||
//
|
||||
// IAM attaches a verified wallet to an EXISTING identity only when the verify
|
||||
// arrives with that identity's own same-site session (verify.go: `in.Session`).
|
||||
// So the order is: the wallet is refused ("no account is linked"), the person
|
||||
// signs in the way they already can — email, password, a provider — and THEN the
|
||||
// same wallet flow runs once more, now with a session, and IAM links it. From
|
||||
// then on the wallet signs them in. The chain is parked in session storage
|
||||
// because a provider sign-in leaves the page and returns through /callback.
|
||||
|
||||
const PARKED = 'hanzo_id_wallet_to_attach'
|
||||
|
||||
const parking = (): Storage | undefined =>
|
||||
typeof window === 'undefined' ? undefined : window.sessionStorage
|
||||
|
||||
/** Remember that `chain`'s wallet was refused for having no account, to attach after the next sign-in. */
|
||||
export function parkWallet(chain: Chain, store: Storage | undefined = parking()): void {
|
||||
try {
|
||||
store?.setItem(PARKED, chain)
|
||||
} catch {
|
||||
/* no storage: the person can press the wallet again once signed in */
|
||||
}
|
||||
}
|
||||
|
||||
/** The parked chain, if any, cleared on read. */
|
||||
export function parkedWallet(store: Storage | undefined = parking()): Chain | null {
|
||||
try {
|
||||
const chain = store?.getItem(PARKED) ?? null
|
||||
store?.removeItem(PARKED)
|
||||
return chain && (CHAINS as readonly string[]).includes(chain) ? (chain as Chain) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a sign-in: attach the parked wallet, if one was parked, by running the
|
||||
* wallet flow again with the session now live. Resolves to the flow's answer, or
|
||||
* null when nothing was parked. A refusal here — the person declines the second
|
||||
* signature, say — is not a failure of the sign-in that just happened, so callers
|
||||
* carry on with it either way; the wallet simply stays unattached.
|
||||
*/
|
||||
export async function attachParkedWallet(
|
||||
client: AuthClient,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
sign: WalletSigner = defaultSigner,
|
||||
store: Storage | undefined = parking(),
|
||||
): Promise<LoginResponse | null> {
|
||||
const chain = parkedWallet(store)
|
||||
if (!chain) return null
|
||||
try {
|
||||
return await loginWithWalletChain(client, chain, {}, fetchImpl, sign)
|
||||
} catch (err) {
|
||||
return { error: errMessage(err) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user