Registration says an address already has an account before any code, and offers sign-in and a reset

The first submit carries no code, so IAM answers a taken address or a refused password there. A taken address shows Sign in and Reset password, both opening on that address for the same request. Recovery prefills from login_hint and returns to the same sign-in.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-09-26 10:50:58 -07:00
co-authored by Hanzo Dev
parent b82b25fe9f
commit f61bdf589d
9 changed files with 250 additions and 35 deletions
+40 -1
View File
@@ -22,9 +22,13 @@ const HOSTS = [
type Host = (typeof HOSTS)[number]
/** `/v1/iam/auth/application` and `/v1/iam/auth/methods` for an app offering everything. */
/**
* `/v1/iam/auth/application` and `/v1/iam/auth/methods` for an app offering
* everything, and a directory where every address already has an account.
*/
function iam(path: string, h: Host, enableSignUp: boolean): unknown {
if (path === '/v1/iam/auth/methods') return { status: 'ok', data: { web3Chains: ['evm'] } }
if (path === '/v1/iam/signup') return { status: 'error', msg: 'email already exists' }
if (path === '/v1/iam/auth/application') {
const provider = (key: string, type: string) => ({
name: `provider-${key}`,
@@ -151,4 +155,39 @@ for (const h of HOSTS) {
await expect(page.getByText('No account?')).toHaveCount(0)
await expect(page.locator('a[href^="/signup"]')).toHaveCount(0)
})
test(`${h.host}: an address that already has an account hears it before any code, with sign-in and a reset filled in`, async ({ context, page }, info) => {
await serve(context, h)
const sends: string[] = []
page.on('request', (r) => r.url().includes('/verification-codes') && sends.push(r.url()))
await page.goto(`https://${h.host}/signup?${REQUEST}`)
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password', { exact: true }).fill('correct horse battery staple')
await page.getByRole('button', { name: 'Create account' }).click()
await expect(page.getByRole('status')).toHaveText('[email protected] already has an account.')
await expect(page.getByLabel('Code')).toHaveCount(0)
expect(sends).toEqual([])
await page.screenshot({ path: info.outputPath(`${h.host}-taken-${page.viewportSize()!.width}.png`), fullPage: true })
const hinted = [...REQUEST, ['login_hint', '[email protected]']]
const reset = (await page.getByRole('link', { name: 'Reset password' }).getAttribute('href'))!
// Sign in opens on the address, for the same request.
await page.locator('.hanzo-id-form').getByRole('link', { name: 'Sign in' }).click()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Sign in')
await expect(page.getByLabel('Email or username')).toHaveValue('[email protected]')
let at = new URL(page.url())
expect(at.pathname).toBe('/login')
expect([...at.searchParams]).toEqual(hinted)
// Reset opens on the address too, and leads back to the same sign-in.
await page.goto(`https://${h.host}${reset}`)
await expect(page.getByRole('heading', { level: 1 })).toHaveText(/^Get back into your .+ account$/)
await expect(page.getByLabel('Email')).toHaveValue('[email protected]')
await page.getByRole('link', { name: 'Back to sign in' }).click()
await expect(page.getByLabel('Email or username')).toHaveValue('[email protected]')
at = new URL(page.url())
expect(at.pathname).toBe('/login')
expect([...at.searchParams]).toEqual(hinted)
})
}
+5 -2
View File
@@ -1,8 +1,11 @@
import type { Brand } from '@hanzo/id-shared'
import { ForgotForm, type AuthClient } from '@hanzo/id-auth'
import { BrandFooter } from '../components/BrandFooter'
import { hintFrom, signinHref } from '../route'
export function Forgot({ client, brand }: { client: AuthClient; brand: Brand }) {
// Sign-in for the request that led here, so recovery ends at the app that asked.
const signin = signinHref(window.location.pathname, window.location.search)
return (
<div className="hanzo-id-page hanzo-id-forgot">
<main>
@@ -10,9 +13,9 @@ export function Forgot({ client, brand }: { client: AuthClient; brand: Brand })
address on the account and send a code that signs you back in. The new
password comes after, from inside the account. */}
<h1>Get back into your {brand.name} account</h1>
<ForgotForm client={client} />
<ForgotForm client={client} identifier={hintFrom(window.location.search)} signinHref={signin} />
<p className="hanzo-id-footer-links">
<a href="/login">Back to sign in</a>
<a href={signin}>Back to sign in</a>
</p>
</main>
<BrandFooter brand={brand} org={client.org} />
+2 -4
View File
@@ -12,7 +12,7 @@ import {
type BrowserAccount,
type LoginResponse, attachParkedWallet } from '@hanzo/id-auth'
import { BrandFooter } from '../components/BrandFooter'
import { clientIdFrom, signupHref } from '../route'
import { clientIdFrom, hintFrom, signupHref } from '../route'
export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
const sp = new URLSearchParams(window.location.search)
@@ -66,9 +66,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
// here only after declining to answer from a session, and forwards login_hint
// with it: the account an application named, which starts the form filled in.
const choosing = Boolean(redirectUri) && (sp.get('prompt') ?? '').split(' ').includes('select_account')
const hint = sp.get('login_hint') ?? ''
// A subject (a UUID, or owner/name) names an account but is nothing a person types.
const loginHint = hint && !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(hint) && !hint.includes('/') ? hint : undefined
const loginHint = hintFrom(window.location.search)
const [phase, setPhase] = useState<'federate' | 'choose' | 'form' | 'register'>(
providerHint ? 'federate' : wantsSignup ? 'register' : choosing ? 'choose' : 'form',
)
+8 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { clientIdFrom, signinHref, signupHref } from './route'
import { clientIdFrom, hintFrom, signinHref, signupHref } from './route'
test('the OAuth query shape wins — it is the one carrying a redirect_uri', () => {
assert.equal(clientIdFrom('?client_id=hanzo-chat', '/signup'), 'hanzo-chat')
@@ -124,3 +124,10 @@ test('the two links are inverses: sign-in to registration and back is the same r
}
assert.equal(parts(signinHref('/signup/hanzo-chat', SEARCH)).path, '/login/hanzo-chat')
})
test('login_hint starts the field with an address, never with a subject', () => {
assert.equal(hintFrom('?login_hint=ada%40example.com&client_id=hanzo-app'), '[email protected]')
assert.equal(hintFrom('?login_hint=3f2504e0-4f89-11d3-9a0c-0305e82c3301'), undefined)
assert.equal(hintFrom('?login_hint=hanzo%2Fada'), undefined)
assert.equal(hintFrom('?client_id=hanzo-app'), undefined)
})
+12
View File
@@ -72,3 +72,15 @@ export function signinHref(pathname: string, search: string): string {
const rest = q.toString()
return `/login${seg ? `/${seg}` : ''}${rest ? `?${rest}` : ''}`
}
/**
* The address a page starts its identifier field with, from `login_hint`.
*
* IAM forwards the hint an application named, and registration sends the address
* it found already taken. A subject (a UUID, or owner/name) names an account but
* is nothing a person types, so it starts the field empty.
*/
export function hintFrom(search: string): string | undefined {
const hint = new URLSearchParams(search).get('login_hint') ?? ''
return hint && !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(hint) && !hint.includes('/') ? hint : undefined
}
+25
View File
@@ -151,3 +151,28 @@ test('a send that fails does not advance to the code step', async () => {
await waitFor(() => assert.match(document.body.textContent!, /cannot be delivered/))
assert.equal(field('Code'), null, 'the code step opened for a code nobody sent')
})
// Registration hands over the address it found taken, so recovery starts filled
// in and ends at sign-in for the same request.
test('the address arrives filled in, and the new password leads to the same sign-in', async () => {
const { fetchImpl } = iam()
render(
<ForgotForm
client={createAuthClient({ org: org(), fetchImpl })}
identifier="[email protected]"
signinHref="/login?client_id=hanzo-console&login_hint=ada%40example.com"
/>,
)
const email = document.querySelector('input[type="email"]') as HTMLInputElement
assert.equal(email.value, '[email protected]')
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => assert.match(document.body.textContent!, /6-digit code to ada@example\.com/))
fireEvent.change(document.querySelector('input[autocomplete="one-time-code"]')!, { target: { value: '424242' } })
fireEvent.change(document.querySelector('input[type="password"]')!, { target: { value: 'correct horse battery staple' } })
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => assert.match(document.body.textContent!, /new password is set/))
const signin = [...document.querySelectorAll('a')].find((a) => a.textContent?.includes('Sign in'))!
assert.equal(signin.getAttribute('href'), '/login?client_id=hanzo-console&login_hint=ada%40example.com')
})
+6 -2
View File
@@ -6,6 +6,10 @@ import { Submit } from './Submit'
export interface ForgotFormProps {
readonly client: AuthClient
/** What the address field starts with — the address registration found taken. */
readonly identifier?: string
/** Where the new password is used: sign-in for the request this page was handed. */
readonly signinHref?: string
/** Called once the code is on its way. */
readonly onSent?: () => void
/** Called once the new password is set — the person can now sign in with it. */
@@ -27,7 +31,7 @@ export interface ForgotFormProps {
*/
export function ForgotForm(props: ForgotFormProps) {
const { client } = props
const [identifier, setIdentifier] = useState('')
const [identifier, setIdentifier] = useState(props.identifier ?? '')
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
@@ -100,7 +104,7 @@ export function ForgotForm(props: ForgotFormProps) {
// rather than after the fifteen-minute window a run of wrong guesses opened.
return (
<p className="hanzo-id-info">
Your new password is set. <a href="/login">Sign in with it.</a>
Your new password is set. <a href={props.signinHref ?? '/login'}>Sign in with it.</a>
</p>
)
}
+85 -10
View File
@@ -29,10 +29,14 @@ function org(): Org {
/**
* An IAM double: the descriptor, the code send, the create and the sign-in after
* it. `code` is the descriptor's switch for "a code can be delivered here".
* it. `code` is the descriptor's switch for "a code can be delivered here", and
* the create asks for the code under the same switch, as IAM does. `taken` says
* when an address that already has an account is reported: on the first submit
* (`first`), or only once a code is brought (`code`, the order IAM used before).
*/
function iam(opts: { code: boolean; send?: unknown }) {
function iam(opts: { code: boolean; send?: unknown; taken?: 'first' | 'code' }) {
const calls: { method: string; url: string; body: string }[] = []
let created = 0
const json = (payload: unknown) =>
new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -40,7 +44,13 @@ function iam(opts: { code: boolean; send?: unknown }) {
const method = init?.method ?? 'GET'
if (method !== 'GET') calls.push({ method, url, body: String(init?.body ?? '') })
if (url.includes('/verification-codes')) return json(opts.send ?? { status: 'ok' })
if (url.includes('/v1/iam/signup')) return json({ status: 'ok', data: { id: 'sub-1', owner: 'ada', name: 'ada' } })
if (url.includes('/v1/iam/signup')) {
const code = 'code' in (JSON.parse(String(init?.body)) as Record<string, unknown>)
if (opts.taken === 'first' || (opts.taken === 'code' && code)) return json({ status: 'error', msg: 'email already exists' })
if (opts.code && !code) return json({ status: 'error', msg: 'the code sent to the email address is required' })
created++
return json({ status: 'ok', data: { id: 'sub-1', owner: 'ada', name: 'ada' } })
}
if (url.includes('/v1/iam/login')) return json({ status: 'ok', data: 'AUTHCODE' })
return json({
status: 'ok',
@@ -55,7 +65,7 @@ function iam(opts: { code: boolean; send?: unknown }) {
},
})
}) as unknown as typeof fetch
return { calls, fetchImpl }
return { calls, fetchImpl, created: () => created }
}
/** The input a visible label names — wrapped, or associated by `for`. */
@@ -80,7 +90,7 @@ function fill() {
// THE PATH. The code goes to the address first, and the account is created only
// with it — so it is created with the address proven.
test('the address receives a code, and the account is created with it', async () => {
const { calls, fetchImpl } = iam({ code: true })
const { calls, fetchImpl, created } = iam({ code: true })
render(<SignupForm client={createAuthClient({ org: org(), fetchImpl })} redirectUri="https://console.hanzo.ai/callback" />)
fill()
@@ -92,13 +102,13 @@ test('the address receives a code, and the account is created with it', async ()
assert.match(send.body, /dest=ada%40example\.com/)
assert.match(send.body, /type=email/)
assert.match(send.body, /applicationId=admin%2Fhanzo-console/)
assert.equal(calls.filter((c) => c.url.includes('/v1/iam/signup')).length, 0, 'nothing is created before the code')
assert.equal(created(), 0, 'nothing is created before the code')
fireEvent.change(field('Code')!, { target: { value: '424242' } })
submit()
await waitFor(() => assert.ok(calls.some((c) => c.url.includes('/v1/iam/signup'))))
const body = JSON.parse(calls.find((c) => c.url.includes('/v1/iam/signup'))!.body) as Record<string, unknown>
await waitFor(() => assert.equal(created(), 1))
const body = JSON.parse(calls.filter((c) => c.url.includes('/v1/iam/signup')).at(-1)!.body) as Record<string, unknown>
assert.equal(body.email, '[email protected]', 'the address the code went to')
assert.equal(body.code, '424242')
assert.equal(body.password, 'correct horse battery staple')
@@ -122,7 +132,7 @@ test('an application that cannot deliver a code registers without one', async ()
// A send IAM refused is its own sentence on screen, and nothing is created.
test('a refused send is shown and creates nothing', async () => {
const { calls, fetchImpl } = iam({ code: true, send: { status: 'error', msg: 'please wait before requesting another code' } })
const { fetchImpl, created } = iam({ code: true, send: { status: 'error', msg: 'please wait before requesting another code' } })
render(<SignupForm client={createAuthClient({ org: org(), fetchImpl })} />)
fill()
@@ -130,7 +140,7 @@ test('a refused send is shown and creates nothing', async () => {
await waitFor(() => assert.match(document.body.textContent!, /please wait before requesting another code/))
assert.equal(field('Code'), null)
assert.equal(calls.filter((c) => c.url.includes('/v1/iam/signup')).length, 0)
assert.equal(created(), 0)
})
// Back from the code step keeps what was typed, and a changed address gets its own
@@ -154,3 +164,68 @@ test('changing the address sends the new one its own code', async () => {
assert.equal(sends.length, 2)
assert.match(sends[1]!.body, /dest=grace%40example\.com/)
})
// An address that already has an account is a person who needs back in, not an
// error to read, and they hear it on the first submit: no code goes to an address
// that already has an account. Sign-in and reset both open on that address, for
// the same request.
test('an address that already has an account is answered before any code is sent', async () => {
const { calls, fetchImpl, created } = iam({ code: true, taken: 'first' })
render(
<SignupForm
client={createAuthClient({ org: org(), fetchImpl })}
signinHref="/login?client_id=hanzo-console&state=s1"
forgotHref="/forget?client_id=hanzo-console&state=s1"
/>,
)
fill()
submit()
await waitFor(() => assert.match(document.body.textContent!, /ada@example\.com already has an account/))
assert.equal(field('Code'), null, 'no code is asked for')
assert.equal(calls.filter((c) => c.url.includes('/verification-codes')).length, 0, 'no code is sent')
assert.equal(created(), 0)
assert.doesNotMatch(document.body.textContent!, /email already exists/)
const link = (text: string) => [...document.querySelectorAll('a')].find((a) => a.textContent?.trim() === text)!
assert.equal(link('Sign in').getAttribute('href'), '/login?client_id=hanzo-console&state=s1&login_hint=ada%40example.com')
assert.equal(link('Reset password').getAttribute('href'), '/forget?client_id=hanzo-console&state=s1&login_hint=ada%40example.com')
fireEvent.click([...document.querySelectorAll('button')].find((b) => b.textContent?.includes('different email'))!)
await waitFor(() => assert.ok(field('Email')))
assert.equal(field('Email')!.value, '[email protected]')
})
// An IAM that reports the address only once a code is brought still lands on the
// same ways back in, after the code.
test('an address reported taken after the code gets the same ways back in', async () => {
const { fetchImpl } = iam({ code: true, taken: 'code' })
render(<SignupForm client={createAuthClient({ org: org(), fetchImpl })} signinHref="/login" />)
fill()
submit()
await waitFor(() => assert.ok(field('Code')))
fireEvent.change(field('Code')!, { target: { value: '424242' } })
submit()
await waitFor(() => assert.match(document.body.textContent!, /ada@example\.com already has an account/))
const signin = [...document.querySelectorAll('a')].find((a) => a.textContent?.trim() === 'Sign in')!
assert.equal(signin.getAttribute('href'), '/login?login_hint=ada%40example.com')
})
// A password the org refuses is refused on the first submit too, before a code is
// spent on an account that could not be made with it.
test('a refused password is answered before any code is sent', async () => {
const { fetchImpl } = iam({ code: true })
const refusing = (async (input: RequestInfo | URL, init?: RequestInit) =>
input.toString().includes('/v1/iam/signup')
? new Response(JSON.stringify({ status: 'error', msg: 'the password must contain at least one digit' }), { status: 200 })
: fetchImpl(input, init)) as typeof fetch
render(<SignupForm client={createAuthClient({ org: org(), fetchImpl: refusing })} />)
fill()
submit()
await waitFor(() => assert.match(document.body.textContent!, /at least one digit/))
assert.equal(field('Code'), null)
})
+67 -15
View File
@@ -23,6 +23,11 @@ export interface SignupFormProps {
* page's query.
*/
readonly signinHref?: string
/**
* Where a person whose address already has an account resets its password.
* Defaults to `/forget` with this page's query.
*/
readonly forgotHref?: string
/**
* The moments a host may want to count, handed out rather than measured here:
* this package is the flow, and what watches it is the page's business.
@@ -41,6 +46,18 @@ export interface SignupFormProps {
readonly onCompleted?: (subject?: string) => void
}
/** IAM's refusals the form acts on (`internal/oidc/signup.go`). */
const TAKEN = 'email already exists'
const CODE_REQUIRED = 'the code sent to the email address is required'
/** `href` with `login_hint` set to the address, so the page it opens starts filled in. */
function hinted(href: string, address: string): string {
const i = href.indexOf('?')
const q = new URLSearchParams(i < 0 ? '' : href.slice(i + 1))
q.set('login_hint', address)
return `${i < 0 ? href : href.slice(0, i)}?${q}`
}
export function SignupForm(props: SignupFormProps) {
const { client } = props
const [email, setEmail] = useState('')
@@ -49,6 +66,9 @@ export function SignupForm(props: SignupFormProps) {
// The address a code went to. The stage IS this state: null asks for the
// account, an address asks for the code that proves it.
const [sentTo, setSentTo] = useState<string | null>(null)
// The address IAM refused because an account already has it. Set, the form
// gives way to the ways back into that account.
const [taken, setTaken] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const errorId = useId()
@@ -71,21 +91,9 @@ export function SignupForm(props: SignupFormProps) {
const application = app?.application ?? client.org.appName
const organization = app?.organization ?? client.org.orgId
// The address is proven before the account exists, where IAM can deliver a
// code at all: the person who receives it is then certainly the person
// choosing this password, and IAM records the address proven. Where it
// cannot, the account is still made and the address stays unproven.
if (sentTo === null && app?.enableCodeSignin) {
const sent = await client.sendCode({ dest: email, channel: 'email', application: app.id })
if (!sent.ok) {
setError(sent.error ?? 'the code could not be sent')
return
}
setCode('')
setSentTo(email)
return
}
// The first submit carries no code. IAM checks the password policy and
// whether the address is taken before it asks for one, so a taken address
// or a refused password is answered here, before any code is sent.
const session = await client.signup({
email: sentTo ?? email,
password,
@@ -99,6 +107,24 @@ export function SignupForm(props: SignupFormProps) {
codeChallengeMethod: props.codeChallengeMethod,
nonce: props.nonce,
})
// The address is proven before the account exists, where IAM can deliver a
// code at all: the person who receives it is then certainly the person
// choosing this password, and IAM records the address proven. Where it
// cannot, IAM asks for no code and the account is made unproven.
if (sentTo === null && app && session.error === CODE_REQUIRED) {
const sent = await client.sendCode({ dest: email, channel: 'email', application: app.id })
if (!sent.ok) {
setError(sent.error ?? 'the code could not be sent')
return
}
setCode('')
setSentTo(email)
return
}
if (session.error === TAKEN) {
setTaken(sentTo ?? email)
return
}
if (session.error) {
setError(session.error)
return
@@ -126,6 +152,32 @@ export function SignupForm(props: SignupFormProps) {
}
}
if (taken !== null) {
return (
<div className="hanzo-id-form">
<p className="hanzo-id-info" role="status">
{taken} already has an account.
</p>
<a className="hanzo-id-btn" href={hinted(props.signinHref ?? `/login${window.location.search}`, taken)}>
Sign in
</a>
<a className="hanzo-id-btn ghost" href={hinted(props.forgotHref ?? `/forget${window.location.search}`, taken)}>
Reset password
</a>
<button
type="button"
className="hanzo-id-linkbtn"
onClick={() => {
setTaken(null)
setSentTo(null)
}}
>
Use a different email
</button>
</div>
)
}
if (sentTo !== null) {
return (
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>