A pinned invite asks the signed-in account for a code sent to its own address, then joins with it

acceptInvitation sends emailCode and reports IAM's reason code. On email_code_required /join sends a code to the account's email under the link's application and accepts again with it; a wrong code, a different address and too many attempts show IAM's sentence as written.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-09-27 09:04:52 -07:00
co-authored by Hanzo Dev
parent fe93d53eae
commit f47471cdef
7 changed files with 260 additions and 24 deletions
+36
View File
@@ -248,4 +248,40 @@ for (const h of HOSTS) {
await expect(page.getByRole('heading', { level: 1 })).toHaveText('This invite link is incomplete')
await page.screenshot({ path: info.outputPath(`${h.host}-join-incomplete-${page.viewportSize()!.width}.png`), fullPage: true })
})
test(`${h.host}: a pinned invite, signed in, proves the address with a code and joins`, async ({ context, page }, info) => {
await serve(context, h, true, true)
// IAM for an invitation pinned to Ada's address: accept wants the code sent
// there, and takes only a JSON request.
const accepts: { body: Record<string, unknown>; type: string | undefined }[] = []
await page.route('**/v1/iam/invitations/accept', (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>
accepts.push({ body, type: route.request().headers()['content-type'] })
return body.emailCode
? route.fulfill({ json: { status: 'ok', data: { org: 'acme', joined: true } } })
: route.fulfill({
status: 400,
json: { status: 'error', msg: 'enter the code sent to [email protected] to join', code: 'email_code_required' },
})
})
const sends: string[] = []
page.on('request', (r) => new URL(r.url()).pathname === '/v1/iam/verification-codes' && sends.push(r.postData() ?? ''))
await page.goto(`https://${h.host}/join?client_id=${h.app}&invite=K7QX2M9PLR&org=acme`)
await page.getByRole('button', { name: 'Join acme' }).click()
await expect(page.getByText('We sent a 6-digit code to [email protected].')).toBeVisible()
expect(sends).toHaveLength(1)
const sent = new URLSearchParams(sends[0])
expect([sent.get('dest'), sent.get('type'), sent.get('applicationId')]).toEqual(['[email protected]', 'email', `admin/${h.app}`])
await page.screenshot({ path: info.outputPath(`${h.host}-join-code-${page.viewportSize()!.width}.png`), fullPage: true })
await page.getByLabel('Code').fill('424242')
await page.getByRole('button', { name: 'Join acme' }).click()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('You joined acme')
expect(accepts.map((a) => a.body)).toEqual([
{ owner: 'acme', code: 'K7QX2M9PLR' },
{ owner: 'acme', code: 'K7QX2M9PLR', emailCode: '424242' },
])
expect(accepts.map((a) => a.type)).toEqual(['application/json', 'application/json'])
})
}
+79 -6
View File
@@ -27,21 +27,36 @@ const ADA = { owner: 'ada', name: 'ada', id: 'u-1', displayName: 'Ada Lovelace',
const JOINED = { status: 'ok', data: { org: 'acme', joined: true } }
/** IAM as this page meets it: who is signed in, the accept door, and registration. */
function iam(opts: { account?: object | null; accept?: object; acceptStatus?: number } = {}) {
const calls: { method: string; url: string; body: string }[] = []
const REQUIRED = { status: 'error', msg: 'enter the code sent to [email protected] to join', code: 'email_code_required' }
/**
* IAM as this page meets it: who is signed in, the accept door, the code send and
* registration. `pinned` is an invitation pinned to Ada's address: accept wants
* `emailCode`, and refuses 000000 as a wrong one.
*/
function iam(opts: { account?: object | null; accept?: object; acceptStatus?: number; pinned?: boolean } = {}) {
const calls: { method: string; url: string; body: string; type: string | null }[] = []
const json = (payload: unknown, status = 200) =>
new Response(JSON.stringify(payload), { status, headers: { 'Content-Type': 'application/json' } })
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString()
calls.push({ method: init?.method ?? 'GET', url, body: String(init?.body ?? '') })
calls.push({ method: init?.method ?? 'GET', url, body: String(init?.body ?? ''), type: new Headers(init?.headers).get('Content-Type') })
if (url.includes('/v1/iam/account')) {
return json(opts.account ? { status: 'ok', data: opts.account } : { status: 'error', msg: 'please sign in first' })
}
if (url.includes('/v1/iam/invitations/accept')) return json(opts.accept ?? JOINED, opts.acceptStatus ?? 200)
if (url.includes('/v1/iam/invitations/accept')) {
if (opts.pinned) {
const body = JSON.parse(String(init?.body)) as { emailCode?: string }
if (!body.emailCode) return json(REQUIRED, 400)
if (body.emailCode === '000000') return json({ status: 'error', msg: 'that code is incorrect or has expired' }, 400)
}
return json(opts.accept ?? JOINED, opts.acceptStatus ?? 200)
}
if (url.includes('/v1/iam/verification-codes')) return json({ status: 'ok' })
if (url.includes('/v1/iam/signup')) return json({ status: 'ok', data: { id: 'u-2', owner: 'acme', name: 'ada' } })
if (url.includes('/v1/iam/login')) return json({ status: 'ok', data: '' })
return json({ status: 'ok', data: { owner: 'admin', name: 'hanzo-console', organization: 'hanzo', enableSignUp: true } })
const app = new URL(url).searchParams.get('clientId') ?? 'hanzo-console'
return json({ status: 'ok', data: { owner: 'admin', name: app, organization: 'hanzo', enableSignUp: true } })
}) as unknown as typeof fetch
return { calls, fetchImpl }
}
@@ -130,3 +145,61 @@ test('an account made on the invitation is already in the org, and is not offere
assert.equal(document.querySelector('form'), null)
assert.ok(!calls.some((c) => c.url.includes('/accept')))
})
// A pinned invitation asks the signed-in account to prove its own address: IAM
// refuses the first accept with email_code_required, the page sends a code to the
// account's address under the link's application, and accepts again with it.
test('a pinned invitation sends a code to the account’s own address and joins with it', async () => {
at(LINK)
const { calls, fetchImpl } = iam({ account: ADA, pinned: true })
render(<Join client={createAuthClient({ org: ORG, fetchImpl })} brand={BRAND} org={ORG} />)
await waitFor(() => assert.ok(document.querySelector('form')))
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => assert.ok(text().includes('We sent a 6-digit code to [email protected].')))
const send = calls.find((c) => c.url.includes('/v1/iam/verification-codes'))!
assert.match(send.body, /dest=ada%40example\.com/)
assert.match(send.body, /type=email/)
assert.match(send.body, /applicationId=admin%2Fhanzo-app/)
fireEvent.change(document.querySelector('input[autocomplete="one-time-code"]')!, { target: { value: '424242' } })
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => assert.ok(text().includes('You joined acme')))
const accepts = calls.filter((c) => c.url.includes('/v1/iam/invitations/accept'))
assert.deepEqual(
accepts.map((c) => JSON.parse(c.body)),
[
{ owner: 'acme', code: 'K7QX2M9PLR' },
{ owner: 'acme', code: 'K7QX2M9PLR', emailCode: '424242' },
],
)
// The cookie door takes a same-origin JSON request and nothing else.
assert.deepEqual(
accepts.map((c) => c.type),
['application/json', 'application/json'],
)
})
test('a wrong code is IAM’s sentence, and the code step stays', async () => {
at(LINK)
const { fetchImpl } = iam({ account: ADA, pinned: true })
render(<Join client={createAuthClient({ org: ORG, fetchImpl })} brand={BRAND} org={ORG} />)
await waitFor(() => assert.ok(document.querySelector('form')))
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => assert.ok(document.querySelector('input[autocomplete="one-time-code"]')))
fireEvent.change(document.querySelector('input[autocomplete="one-time-code"]')!, { target: { value: '000000' } })
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => assert.ok(text().includes('that code is incorrect or has expired')))
assert.ok(document.querySelector('input[autocomplete="one-time-code"]'))
assert.ok(!text().includes('You joined'))
})
test('too many attempts is IAM’s own sentence', async () => {
at(LINK)
const msg = 'too many attempts to join an organization; try again in an hour'
const { fetchImpl } = iam({ account: ADA, accept: { status: 'error', msg }, acceptStatus: 429 })
render(<Join client={createAuthClient({ org: ORG, fetchImpl })} brand={BRAND} org={ORG} />)
await waitFor(() => assert.ok(document.querySelector('form')))
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => assert.ok(text().includes(msg)))
})
+89 -13
View File
@@ -19,7 +19,9 @@ import { clientIdFrom } from '../route'
* - signed out → create an account on the invitation (POST /v1/iam/signup with
* `organization` and `invitationCode`), or sign in and come back
* here (`/login?return=`).
* - signed in → join as that account (POST /v1/iam/invitations/accept).
* - signed in → join as that account (POST /v1/iam/invitations/accept). An
* invitation pinned to an address also wants a code IAM sends to
* that address, so the account proves it holds its own mailbox.
*
* The org is named from the link, never looked up: a read that answered "this
* code belongs to Acme" to anyone who asked would turn every guessed code into a
@@ -38,6 +40,10 @@ export function Join({ client, brand, org }: { client: AuthClient; brand: Brand;
const clientId = clientIdFrom(window.location.search, window.location.pathname)
const [seen, setSeen] = useState<Seen>({ s: 'loading' })
const [joined, setJoined] = useState<string | null>(null)
// The address a code went to, once IAM asked for one. The stage IS this state:
// null offers Join, an address asks for the code that proves it.
const [sentTo, setSentTo] = useState<string | null>(null)
const [emailCode, setEmailCode] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const errorId = useId()
@@ -67,15 +73,58 @@ export function Join({ client, brand, org }: { client: AuthClient; brand: Brand;
// Onward, to where this brand manages an organization; its own portal otherwise.
const onward = teamFor(org.orgId) ?? '/'
// A code to the signed-in account's own address, minted under the application
// the link names — the one IAM redeems it against.
async function sendCode(email: string): Promise<boolean> {
const app = await client.getAppLogin(clientId)
if (!app) {
setError('cannot read the sign-in configuration for this application')
return false
}
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 false
}
return true
}
async function accept(e: FormEvent) {
e.preventDefault()
if (busy) return
if (busy || seen.s !== 'authed') return
setBusy(true)
setError(null)
try {
const res = await client.acceptInvitation({ owner, code })
if (res.ok) setJoined(res.org ?? owner)
else setError(res.error ?? 'The invitation could not be accepted.')
const res = await client.acceptInvitation({ owner, code, ...(sentTo ? { emailCode } : {}) })
if (res.ok) {
setJoined(res.org ?? owner)
return
}
// A pinned invitation asks the account to prove its address first. Every
// other refusal — a different address, a wrong code, too many attempts — is
// IAM's sentence, shown as written.
const email = seen.account.email
if (res.reason === 'email_code_required' && !sentTo && email) {
if (await sendCode(email)) {
setEmailCode('')
setSentTo(email)
}
return
}
setError(res.error ?? 'The invitation could not be accepted.')
} catch (err) {
setError(String(err))
} finally {
setBusy(false)
}
}
async function resend() {
if (busy || !sentTo) return
setBusy(true)
setError(null)
try {
if (await sendCode(sentTo)) setEmailCode('')
} finally {
setBusy(false)
}
@@ -141,14 +190,41 @@ export function Join({ client, brand, org }: { client: AuthClient; brand: Brand;
return frame(
<>
<h1>Join {owner}</h1>
<form onSubmit={accept} className="hanzo-id-form" aria-busy={busy}>
<p className="hanzo-id-info">
Signed in as {who}
{seen.account.email ? ` · ${seen.account.email}` : ''}.
</p>
<Alert id={errorId} message={error} />
<Submit busy={busy} label={`Join ${owner}`} busyLabel="Joining…" />
</form>
{sentTo !== null ? (
<form onSubmit={accept} className="hanzo-id-form" aria-busy={busy}>
<p className="hanzo-id-info">We sent a 6-digit code to {sentTo}.</p>
<label className="hanzo-id-field">
<span>Code</span>
<input
className="hanzo-id-input"
type="text"
inputMode="numeric"
pattern="\d{6}"
maxLength={6}
autoComplete="one-time-code"
aria-invalid={error !== null || undefined}
aria-describedby={errorId}
value={emailCode}
onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
required
/>
</label>
<Alert id={errorId} message={error} />
<Submit busy={busy} ready={emailCode.length === 6} label={`Join ${owner}`} busyLabel="Joining…" />
<button type="button" className="hanzo-id-linkbtn" onClick={resend}>
Send a new code
</button>
</form>
) : (
<form onSubmit={accept} className="hanzo-id-form" aria-busy={busy}>
<p className="hanzo-id-info">
Signed in as {who}
{seen.account.email ? ` · ${seen.account.email}` : ''}.
</p>
<Alert id={errorId} message={error} />
<Submit busy={busy} label={`Join ${owner}`} busyLabel="Joining…" />
</form>
)}
<p className="hanzo-id-footer-links">
Not you? <a href={signin}>Sign in with another account</a>
</p>
+23
View File
@@ -887,3 +887,26 @@ test('acceptInvitation reads IAM\'s envelope and nothing looser', async () => {
assert.equal((await accept(answer(200, { status: 'ok', data: {} }))).ok, false)
assert.equal((await accept(answer(502, 'bad gateway'))).ok, false)
})
test('acceptInvitation sends emailCode as JSON and reports IAM\'s reason', async () => {
const seen: { body: string; type: string | null }[] = []
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
seen.push({ body: String(init?.body), type: new Headers(init?.headers).get('Content-Type') })
return new Response(
JSON.stringify({ status: 'error', msg: 'enter the code sent to [email protected] to join', code: 'email_code_required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
)
}) as unknown as typeof fetch
const client = createAuthClient({ org: org(), fetchImpl })
assert.deepEqual(await client.acceptInvitation({ owner: 'acme', code: 'K7QX2M9PLR' }), {
ok: false,
error: 'enter the code sent to [email protected] to join',
reason: 'email_code_required',
})
await client.acceptInvitation({ owner: 'acme', code: 'K7QX2M9PLR', emailCode: '424242' })
assert.deepEqual(JSON.parse(seen[1]!.body), { owner: 'acme', code: 'K7QX2M9PLR', emailCode: '424242' })
assert.deepEqual(
seen.map((s) => s.type),
['application/json', 'application/json'],
)
})
+13 -5
View File
@@ -3,6 +3,8 @@ import type { Chain } from '@hanzo/id-connect'
import { offeredWalletChains } from './web3'
import { accountOf, type Account } from './account'
import type {
AcceptInvitationRequest,
AcceptInvitationResult,
AppLogin,
AppProvider,
BrowserAccount,
@@ -123,9 +125,11 @@ export interface AuthClient {
*
* The account is the session's (the cookie rides along), never a field of the
* body, and IAM holds the invitation to its own terms: active, a seat left, and
* pinned to this account's address when it names one.
* pinned to this account's address when it names one. A pinned invitation also
* wants `emailCode`, a code IAM sent to that address; without one the answer's
* `reason` is `email_code_required`.
*/
acceptInvitation(req: { owner: string; code: string }): Promise<{ ok: boolean; org?: string; error?: string }>
acceptInvitation(req: AcceptInvitationRequest): Promise<AcceptInvitationResult>
authorize(req: OAuthAuthorizeRequest): string
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
@@ -507,15 +511,17 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
return { ok: true }
}
async function acceptInvitation(req: { owner: string; code: string }): Promise<{ ok: boolean; org?: string; error?: string }> {
async function acceptInvitation(req: AcceptInvitationRequest): Promise<AcceptInvitationResult> {
const url = new URL('/v1/iam/invitations/accept', org.iamUrl)
let res: Response
try {
// Exactly application/json: IAM takes the session cookie on this door only
// from a same-origin request of that type, which a cross-site form cannot send.
res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ owner: req.owner, code: req.code }),
body: JSON.stringify({ owner: req.owner, code: req.code, ...(req.emailCode ? { emailCode: req.emailCode } : {}) }),
})
} catch (e) {
return { ok: false, error: `Could not reach ${org.iamUrl}: ${message(e)}` }
@@ -529,7 +535,9 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
if (res.ok && body?.status === 'ok' && data.joined === true) {
return { ok: true, org: typeof data.org === 'string' && data.org ? data.org : req.owner }
}
if (body?.status === 'error' && typeof body.msg === 'string' && body.msg) return { ok: false, error: body.msg }
if (body?.status === 'error' && typeof body.msg === 'string' && body.msg) {
return { ok: false, error: body.msg, ...(typeof body.code === 'string' && body.code ? { reason: body.code } : {}) }
}
return { ok: false, error: `${url.pathname} answered HTTP ${res.status} without a result` }
}
+2
View File
@@ -41,6 +41,8 @@ export type {
SignupResponse,
CodeRequest,
SetPasswordRequest,
AcceptInvitationRequest,
AcceptInvitationResult,
OAuthAuthorizeRequest,
TokenResponse,
AppLogin,
+18
View File
@@ -378,3 +378,21 @@ export interface AppLogin {
/** Social providers enabled on the app, in display order. */
readonly providers: readonly AppProvider[]
}
/** Join an organization on an invitation, as the signed-in account. */
export interface AcceptInvitationRequest {
readonly owner: string
readonly code: string
/** The code IAM sent to the account's own address, for an invitation pinned to it. */
readonly emailCode?: string
}
export interface AcceptInvitationResult {
readonly ok: boolean
/** The org joined, as IAM names it. */
readonly org?: string
/** IAM's refusal, as written. */
readonly error?: string
/** IAM's machine-readable reason for a refusal — `email_code_required`, say. */
readonly reason?: string
}