mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
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:
@@ -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'])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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` }
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ export type {
|
||||
SignupResponse,
|
||||
CodeRequest,
|
||||
SetPasswordRequest,
|
||||
AcceptInvitationRequest,
|
||||
AcceptInvitationResult,
|
||||
OAuthAuthorizeRequest,
|
||||
TokenResponse,
|
||||
AppLogin,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user