Files
hanzo.ai/e2e/gates/wallet.spec.ts
zeekay a46eaec8fb sign-in: the wallet never holds the page, counts only once it signs
A wallet whose prompt never answers no longer locks the sign-in: the
other ways in stay open while it waits, any other press supersedes it,
and after a minute it is let go and said so. signup_submitted is sent
once the wallet has signed, not on the press. A second factor's header
names the way in that holds it (the wallet, the number), never an
address typed beside it. The gate's challenge names hanzo.ai, as IAM
v1.34.171 does for a page at one of the client's registered origins.

/login still sends no verify method: that needs @hanzo/iam 0.21.38
(loginWithWallet's `method`, hanzo-js/iam branch wallet-method).
2026-10-27 01:23:17 -07:00

505 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from 'node:crypto'
import { writeFileSync } from 'node:fs'
import { expect, test, type Page } from '@playwright/test'
import { secp256k1 } from '@noble/curves/secp256k1.js'
import { keccak_256 } from '@noble/hashes/sha3.js'
import { AUP_VERSION, TERMS_VERSION } from '@hanzo/app/lib/terms'
import { serveExport } from './export'
import { heard, stream } from './heard'
import { API, contract, cors, issuer, jwt, type Seen } from './issuer'
/**
* A WALLET SIGNS IN ON THIS SITE.
*
* /login and /signup offer "Continue with wallet", after the providers, where
* IAM says it signs a wallet in (`web3`, with evm among `web3Chains`) and the
* browser injects an EIP-1193 wallet (components/auth/panel, lib/auth/wallet).
* Pressed, the wallet is asked for its account and network, IAM at api.hanzo.ai
* for a challenge, the wallet signs the EIP-4361 text of it, and IAM's verify
* answers with a PKCE-bound code the callback exchanges as it does a password's
* (@hanzo/iam `loginWithWallet`).
*
* The wallet is a real one: an EIP-1193 provider injected before the page loads,
* whose `personal_sign` is a throwaway secp256k1 key held in this test (`Purse`).
* IAM is answered here (./issuer), and its verify does with the proof what IAM's
* does (hanzoai/iam internal/wallet/verify.go): the nonce in the signed text must
* be one it minted and not yet spent, the text must be that challenge rendered
* for the address, and the signature must recover to the address. Nothing leaves
* this machine, and no account is made anywhere.
*/
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
/** The code IAM's verify mints here. */
const CODE = 'w4ll3t'
/** What IAM states it verifies a wallet signature for, as /v1/iam/auth/methods says it live. */
const CHAINS = ['evm', 'solana', 'bitcoin', 'ton', 'xrp', 'polkadot', 'cardano']
/** The challenge IAM mints, as IAM names its fields (GET /v1/iam/web3/nonce). */
type Challenge = { domain: string; uri: string; statement: string; nonce: string; issuedAt: string; expirationTime: string; version: string }
const hex = (b: Uint8Array) => Buffer.from(b).toString('hex')
const unhex = (s: string) => Uint8Array.from(Buffer.from(s.replace(/^0x/, ''), 'hex'))
/** An address as EIP-55 writes it. */
function checksum(address: string): string {
const low = address.toLowerCase().replace(/^0x/, '')
const hash = hex(keccak_256(new TextEncoder().encode(low)))
return `0x${[...low].map((c, i) => (parseInt(hash[i], 16) >= 8 ? c.toUpperCase() : c)).join('')}`
}
/** The address of a public key: the last 20 bytes of the keccak of its uncompressed point. */
const addressOf = (pub: Uint8Array) => checksum(hex(keccak_256(secp256k1.Point.fromBytes(pub).toBytes(false).slice(1)).slice(-20)))
/** What `personal_sign` signs: EIP-191's prefix, the length, the bytes. */
function digest(bytes: Uint8Array): Uint8Array {
const prefix = new TextEncoder().encode(`\x19Ethereum Signed Message:\n${bytes.length}`)
return keccak_256(Uint8Array.from([...prefix, ...bytes]))
}
/** Who signed `text`, from a 65-byte r‖s‖v signature. */
function signer(text: string, signature: string): string {
const b = unhex(signature)
if (b.length !== 65) throw new Error('not a 65-byte signature')
const recovered = Uint8Array.from([b[64] - 27, ...b.slice(0, 64)])
return addressOf(secp256k1.recoverPublicKey(recovered, digest(new TextEncoder().encode(text)), { prehash: false }))
}
/**
* The sign-in text for `address`, from a challenge: the contract IAM parses the
* signed text by (luxwallet/connect BuildSiwxMessage), line for line.
*/
function render(c: Challenge, address: string, chain: number): string {
return [
`${c.domain} wants you to sign in with your Ethereum account:`,
address,
'',
c.statement,
'',
`URI: ${c.uri}`,
`Version: ${c.version}`,
`Chain ID: ${chain}`,
`Nonce: ${c.nonce}`,
`Issued At: ${c.issuedAt}`,
`Expiration Time: ${c.expirationTime}`,
].join('\n')
}
/** A throwaway Ethereum wallet: its key lives in this test and nowhere else. */
class Purse {
readonly key = secp256k1.utils.randomSecretKey()
readonly address = addressOf(secp256k1.getPublicKey(this.key))
/** Each EIP-1193 method the page asked, in order. */
readonly asked: string[] = []
/** Signs with this other key while claiming its own address: a forged proof. */
forge: Uint8Array | null = null
answer(method: string, params: unknown[]): unknown {
this.asked.push(method)
if (method === 'eth_requestAccounts' || method === 'eth_accounts') return [this.address.toLowerCase()]
if (method === 'eth_chainId') return '0x1'
if (method === 'personal_sign') {
const [data, from] = params as [string, string]
if (from.toLowerCase() !== this.address.toLowerCase()) throw new Error(`asked to sign as ${from}`)
const sig = secp256k1.sign(digest(unhex(data)), this.forge ?? this.key, { prehash: false, format: 'recovered' })
return `0x${hex(sig.slice(1))}${(27 + sig[0]).toString(16)}`
}
throw new Error(`unsupported method ${method}`)
}
}
/**
* The wallet, injected as an EIP-1193 provider before the page loads. `decline`
* says no at the signature, as a person does (4001); `silent` never answers it,
* as a wallet whose prompt never opened.
*/
async function inject(page: Page, purse: Purse, { decline = false, silent = false } = {}): Promise<void> {
await page.exposeBinding('__purse', (_source, method: string, params: unknown[]) => purse.answer(method, params))
await page.addInitScript(([no, mute]) => {
const w = window as unknown as { __purse: (m: string, p: unknown[]) => Promise<unknown>; ethereum: unknown }
w.ethereum = {
request: async ({ method, params }: { method: string; params?: unknown[] }) => {
if (no && method === 'personal_sign') throw Object.assign(new Error('User rejected the request.'), { code: 4001 })
if (mute && method === 'personal_sign') return new Promise(() => {})
return w.__purse(method, params ?? [])
},
}
}, [decline, silent])
}
/** The access token IAM mints each wallet's account, minted once. */
const issued = new WeakMap<Purse, string>()
/** The account a first-seen wallet is made, as IAM makes it: its own org, a wallet, no email. */
function account(purse: Purse): string {
const held = issued.get(purse)
if (held) return held
const id = createHash('sha256').update(`evm:${purse.address.toLowerCase()}`).digest('hex').slice(0, 16)
const token = jwt({
sub: `wallet-evm-${id}/wallet_evm_${id}`,
name: `wallet_evm_${id}`,
displayName: `evm:${purse.address.slice(0, 6)}...${purse.address.slice(-4)}`,
orgs: [{ org: `wallet-evm-${id}`, role: 'owner' }],
wallets: [{ chain: 'evm', address: purse.address.toLowerCase() }],
})
issued.set(purse, token)
return token
}
interface World extends Seen {
/** Each challenge IAM minted, and the query it was asked with. */
minted: { challenge: Challenge; query: URLSearchParams }[]
/** Each proof IAM's verify was sent, and what it answered. */
verified: { body: Record<string, unknown>; refused: string | null }[]
/** Each token request, as the form it was posted as. */
tokens: URLSearchParams[]
}
/**
* The world, answered: everything off this machine fails but IAM (./issuer), whose
* methods say a wallet signs in (`web3`), whose nonce and verify are a wallet
* sign-in's, and whose account row is a wallet's — no email; made long ago with
* the terms on record, or `fresh` and owing them.
*/
async function world(page: Page, purse: Purse, { web3 = true, fresh = false, verify }: { web3?: boolean; fresh?: boolean; verify?: Record<string, unknown> } = {}): Promise<World> {
await page.route('**/*', (route) => (new URL(route.request().url()).origin === base ? route.continue() : route.abort()))
await page.route(`${base}/v1/billing/tier`, (route) =>
route.fulfill({ json: { user: 'wallet', plan: 'dev', tier: { name: 'dev', displayName: 'Dev' }, balance: { currency: 'usd' } } }),
)
const seen: World = Object.assign(await issuer(page, { site: base, token: account(purse) }), { minted: [], verified: [], tokens: [] })
await page.route(
(u) => u.origin === base && ['/v1/iam/preferences', '/v1/iam/account'].includes(u.pathname),
(route) =>
route.fulfill({
json:
new URL(route.request().url()).pathname === '/v1/iam/account'
? { status: 'ok', data: { name: 'wallet', createdTime: fresh ? new Date().toISOString() : '2024-01-01T00:00:00Z' } }
: { status: 'ok', data: fresh ? {} : { terms: { terms: TERMS_VERSION, aup: AUP_VERSION } } },
}),
)
const spent = new Set<string>()
const refuse = (body: Record<string, unknown>): string | null => {
const text = String(body.message ?? '')
const nonce = /^Nonce: (.+)$/m.exec(text)?.[1] ?? ''
const minted = seen.minted.find((m) => m.challenge.nonce === nonce)
if (!minted || spent.has(nonce)) return 'web3: the nonce is unknown or spent'
spent.add(nonce)
if (body.chain !== 'evm' || body.scheme !== 'secp256k1-eip191') return 'web3: unsupported chain'
if (text !== render(minted.challenge, String(body.address), 1)) return 'web3: malformed login message'
if (signer(text, String(body.signature)) !== checksum(String(body.address))) return 'web3: verification failed: bad-signature'
return null
}
await page.route(
(u) => u.origin === API && ['/v1/iam/auth/methods', '/v1/iam/web3/nonce', '/v1/iam/web3/verify', '/v1/iam/oauth/token'].includes(u.pathname),
async (route) => {
const req = route.request()
const url = new URL(req.url())
if (req.method() === 'OPTIONS') return route.fulfill({ status: 204, headers: cors(route) })
const json = (body: unknown) => route.fulfill({ status: 200, headers: cors(route), contentType: 'application/json', body: JSON.stringify(body) })
if (url.pathname === '/v1/iam/auth/methods')
return json({
status: 'ok',
data: {
password: true,
code: true,
signup: true,
webauthn: true,
web3,
web3Chains: web3 ? CHAINS : [],
oauth: [
{ name: 'provider-github', type: 'GitHub' },
{ name: 'provider-google', type: 'Google' },
],
},
})
if (url.pathname === '/v1/iam/web3/nonce') {
const now = new Date()
const challenge: Challenge = {
// The page's site, as IAM (v1.34.171, internal/wallet `site`) names it for
// a page at one of the client's registered origins.
domain: 'hanzo.ai',
uri: 'https://hanzo.ai/login',
statement: 'Sign in to Hanzo. This request will not trigger a blockchain transaction or cost any gas.',
nonce: hex(secp256k1.utils.randomSecretKey()).slice(0, 32),
issuedAt: now.toISOString().replace(/\.\d+Z$/, 'Z'),
expirationTime: new Date(now.getTime() + 600_000).toISOString().replace(/\.\d+Z$/, 'Z'),
version: '1',
}
seen.minted.push({ challenge, query: url.searchParams })
return json({ status: 'ok', msg: '', data: challenge })
}
if (url.pathname === '/v1/iam/web3/verify') {
const body = JSON.parse(req.postData() ?? '{}') as Record<string, unknown>
const refused = refuse(body)
seen.verified.push({ body, refused })
return json(refused ? { status: 'error', msg: refused } : (verify ?? { status: 'ok', msg: '', data: CODE }))
}
seen.tokens.push(new URLSearchParams(req.postData() ?? ''))
return route.fallback()
},
)
// Served from a loopback address the token and userinfo calls go to this origin
// (app/providers `proxyBaseUrl`), as the dev server's /v1 proxy takes them;
// answered as api.hanzo.ai answers them, and the exchange recorded either way.
const token = account(purse)
await page.route(
(u) => u.origin === base && ['/v1/iam/oauth/token', '/v1/iam/oauth/userinfo'].includes(u.pathname),
(route) => {
const req = route.request()
if (new URL(req.url()).pathname === '/v1/iam/oauth/userinfo') {
const c = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()) as Record<string, unknown>
return route.fulfill({ json: { sub: c.sub, name: c.name, preferred_username: c.name } })
}
seen.tokens.push(new URLSearchParams(req.postData() ?? ''))
return route.fulfill({ json: { access_token: token, id_token: token, refresh_token: 'r1', expires_in: 3600, token_type: 'Bearer' } })
},
)
return seen
}
/** The sign-in card, by the name its section carries. */
const card = (page: Page) => page.getByRole('region', { name: /^(Log in|Sign up)$/ })
/** The wallet's door. */
const door = (page: Page) => card(page).getByRole('button', { name: 'Continue with wallet' })
/** React owns the page (app/providers `data-hydrated`). */
const owned = (page: Page) => page.locator('html[data-hydrated]').waitFor({ state: 'attached' })
/** The next page a completed sign-in goes to, written down as an entry into the app does. */
async function headed(page: Page, to: string): Promise<void> {
await page.addInitScript((address) => {
if (!sessionStorage.getItem('gate.headed')) {
sessionStorage.setItem('gate.headed', '1')
sessionStorage.setItem('signin.destination', address)
}
}, to)
}
/** Base64url SHA-256: the S256 challenge of a PKCE verifier. */
const s256 = (verifier: string) => createHash('sha256').update(verifier).digest('base64url')
for (const route of ['/login', '/signup'] as const) {
for (const width of [1440, 390]) {
test(`${route} at ${width}px offers the wallet after Google and GitHub, drawn as they are`, async ({ page }, info) => {
const purse = new Purse()
await inject(page, purse)
const seen = await world(page, purse)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}${route}`)
await expect(door(page)).toBeVisible({ timeout: 20_000 })
// One order: Google, GitHub, the wallet, the phone, and the email under them.
await expect(card(page).getByRole('button', { name: /Continue with (?!email)/ })).toHaveText([/Google$/, /GitHub$/, /wallet$/, /phone$/])
for (const control of [door(page), card(page).getByRole('button', { name: /Continue with GitHub$/ })])
await expect
.poll(() => control.evaluate((el) => ({ height: Math.round(el.getBoundingClientRect().height), font: getComputedStyle(el).fontSize })))
.toEqual({ height: 56, font: '16px' })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no sideways scroll').toBe(true)
expect(purse.asked, 'the wallet is not asked anything before the press').toEqual([])
expect(seen.minted, 'and IAM mints no challenge').toEqual([])
await page.screenshot({ path: info.outputPath(`wallet${route.replace('/', '-')}-${width}.png`), fullPage: false })
})
}
}
test('a wallet signs in on /login: one challenge, one signature, the code exchanged with its verifier, and on to where the sign-in was headed', async ({ page }, info) => {
const purse = new Purse()
await inject(page, purse)
const seen = await world(page, purse)
const events = await stream(page)
await headed(page, '/pricing')
await page.goto(`${base}/login`)
await owned(page)
await door(page).click()
await page.waitForURL(`${base}/pricing`, { timeout: 20_000 })
expect(purse.asked, 'the account, its network, then one signature').toEqual(['eth_requestAccounts', 'eth_chainId', 'personal_sign'])
expect(seen.minted, 'one challenge').toHaveLength(1)
const q = seen.minted[0].query
expect(q.get('chain'), 'for the evm family').toBe('evm')
expect(q.get('clientId'), 'for the client the code is for').toBe('hanzo-app')
expect(q.get('address'), 'naming the address, as EIP-55 spells it').toBe(purse.address)
expect(seen.verified, 'one proof, and IAM took it').toHaveLength(1)
const { body, refused } = seen.verified[0]
expect(refused).toBeNull()
expect(body).toMatchObject({ chain: 'evm', scheme: 'secp256k1-eip191', address: purse.address, type: 'code', clientId: 'hanzo-app', redirectUri: `${base}/auth/callback`, codeChallengeMethod: 'S256' })
expect(body.state, 'state rides the proof').toBeTruthy()
expect(signer(String(body.message), String(body.signature)), 'the signature is the wallet’s').toBe(purse.address)
// The code is redeemed with the verifier whose S256 challenge the proof carried.
expect(seen.tokens, 'one exchange').toHaveLength(1)
const form = seen.tokens[0]
expect(form.get('grant_type')).toBe('authorization_code')
expect(form.get('code')).toBe(CODE)
expect(form.get('redirect_uri')).toBe(`${base}/auth/callback`)
expect(s256(form.get('code_verifier') ?? ''), 'PKCE: the verifier answers the challenge verify was sent').toBe(body.codeChallenge)
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBe(account(purse))
contract(seen)
expect(seen.authorize, 'no authorize page: the wallet signs in where it is').toEqual([])
expect(seen.posted.map((p) => p.path), 'no credential call on this origin: both wallet legs go to the issuer').toEqual([])
expect(await heard(events, 'login_completed'), 'a login, by wallet').toMatchObject({ method: 'wallet' })
expect(events.filter((e) => e.event === 'signup_submitted'), 'a login asks for no account').toEqual([])
// The proof as IAM received it, for a check against IAM's own verifier.
writeFileSync(info.outputPath('proof.json'), JSON.stringify({ challenge: seen.minted[0].challenge, chain: body.chain, scheme: body.scheme, address: body.address, message: body.message, signature: body.signature }, null, 2))
})
test('/signup: a new wallet is the account asked for, with no email; the terms are asked once, and another account is the way out', async ({ page }, info) => {
const purse = new Purse()
await inject(page, purse)
const seen = await world(page, purse, { fresh: true })
const events = await stream(page)
await page.setViewportSize({ width: 390, height: 900 })
await page.goto(`${base}/signup`)
await owned(page)
await door(page).click()
expect(await heard(events, 'signup_submitted'), 'the account asked for, by wallet').toMatchObject({ method: 'wallet' })
await expect(page.getByRole('button', { name: 'Create account' })).toBeVisible({ timeout: 20_000 })
await expect(page.getByRole('button', { name: 'Use a different account' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Use a different email' }), 'there is no email to swap').toHaveCount(0)
expect(seen.verified.map((v) => v.refused)).toEqual([null])
contract(seen)
await page.screenshot({ path: info.outputPath('wallet-terms-390.png') })
})
for (const width of [1440, 390]) {
test(`Settings at ${width}px shows the wallet that signs the account in, and says it has no email`, async ({ page }, info) => {
const purse = new Purse()
await page.addInitScript((token) => localStorage.setItem('hanzo_iam_access_token', token), account(purse))
await world(page, purse)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}/settings`)
const wallet = page.getByLabel('Sign-in wallet (Ethereum)')
await expect(wallet).toHaveValue(purse.address.toLowerCase(), { timeout: 20_000 })
const email = page.getByLabel('Email', { exact: true })
await expect(email).toHaveValue('')
await expect(email).toHaveAttribute('placeholder', 'None. This account signs in with its wallet.')
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no sideways scroll').toBe(true)
await wallet.scrollIntoViewIfNeeded()
await page.screenshot({ path: info.outputPath(`wallet-settings-${width}.png`) })
})
}
test('no wallet in the browser, no wallet button: one that could not finish is worse than none', async ({ page }) => {
const purse = new Purse()
await world(page, purse)
await page.goto(`${base}/login`)
await expect(card(page).getByRole('button', { name: /Continue with GitHub$/ })).toBeVisible({ timeout: 20_000 })
await owned(page)
await expect(card(page).getByRole('button', { name: /Continue with (?!email)/ })).toHaveText([/Google$/, /GitHub$/, /phone$/])
await expect(door(page)).toHaveCount(0)
})
test('where IAM signs no wallet in, an injected wallet is not offered', async ({ page }) => {
const purse = new Purse()
await inject(page, purse)
await world(page, purse, { web3: false })
await page.goto(`${base}/login`)
await owned(page)
await expect(card(page).getByRole('button', { name: /Continue with (?!email)/ })).toHaveText([/Google$/, /GitHub$/, /phone$/])
await expect(door(page)).toHaveCount(0)
expect(purse.asked).toEqual([])
})
test('declined in the wallet: said in the page’s words, nothing sent to verify, and the door open again', async ({ page }) => {
const purse = new Purse()
await inject(page, purse, { decline: true })
const seen = await world(page, purse)
await page.goto(`${base}/login`)
await owned(page)
await door(page).click()
await expect(card(page).getByRole('alert')).toHaveText('Your wallet declined the sign-in.')
await expect(card(page).getByText('Confirm the sign-in in your wallet.')).toHaveCount(0)
expect(seen.verified, 'no proof, no verify').toEqual([])
await expect(door(page)).toBeEnabled()
expect(new URL(page.url()).pathname).toBe('/login')
})
test('a forged signature is refused, in IAM’s words, and nobody is signed in', async ({ page }) => {
const purse = new Purse()
purse.forge = secp256k1.utils.randomSecretKey()
await inject(page, purse)
const seen = await world(page, purse)
await page.goto(`${base}/login`)
await owned(page)
await door(page).click()
await expect(card(page).getByRole('alert')).toHaveText('web3: verification failed: bad-signature')
expect(seen.verified.map((v) => v.refused)).toEqual(['web3: verification failed: bad-signature'])
expect(seen.tokens, 'no code, no exchange').toEqual([])
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBeNull()
})
test('a second factor the account owes is asked here, for the wallet, not for an address typed beside it', async ({ page }) => {
const purse = new Purse()
await inject(page, purse)
await world(page, purse, { verify: { status: 'ok', msg: '', data: 'NextMfa', data2: [{ mfaType: 'app', isPreferred: true, mfaRememberInHours: 0 }] } })
await page.goto(`${base}/login`)
await owned(page)
await card(page).getByLabel('Email').fill('[email protected]')
await door(page).click()
await expect(card(page).getByText('Two-step verification')).toBeVisible({ timeout: 20_000 })
await expect(card(page).getByText('Your wallet', { exact: true })).toBeVisible()
await expect(card(page).getByText('[email protected]'), 'the typed address is not who the sign-in is for').toHaveCount(0)
})
test('a wallet that never answers locks nothing: the other ways in stay open, and after a minute it is let go', async ({ page }) => {
await page.clock.install()
const purse = new Purse()
await inject(page, purse, { silent: true })
const seen = await world(page, purse)
const events = await stream(page)
await page.goto(`${base}/signup`)
await owned(page)
await door(page).click()
await expect(card(page).getByText('Confirm the sign-in in your wallet.')).toBeVisible()
await expect(door(page), 'its own door waits').toBeDisabled()
for (const name of [/Continue with Google$/, /Continue with GitHub$/, /Continue with phone$/, /^Continue with email$/])
await expect(card(page).getByRole('button', { name }), 'every other way in stays open').toBeEnabled()
await expect(card(page).getByLabel('Email')).toBeEditable()
await page.clock.fastForward(61_000)
await expect(card(page).getByRole('alert')).toHaveText('Your wallet didn’t answer. Open it and try again, or sign in another way.')
await expect(door(page)).toBeEnabled()
expect(seen.verified, 'nothing was signed, so nothing verified').toEqual([])
// The stream has flushed (the panel seen reached it), and no account was asked for.
expect(await heard(events, 'signup_viewed')).toMatchObject({ mode: 'signup' })
expect(events.filter((e) => e.event === 'signup_submitted'), 'no account asked for: the wallet never signed').toEqual([])
})
/** Another app's authorize request, as the desktop app sends it. */
const DESKTOP = new URLSearchParams({
client_id: 'hanzo-app',
redirect_uri: 'hanzo://oauth/hanzo',
response_type: 'code',
scope: 'openid profile email',
state: 'st-desktop',
code_challenge: 'ch-desktop',
code_challenge_method: 'S256',
})
test('an app’s sign-in by wallet mints the app’s code, the app is handed it, and this site signs nobody in', async ({ page }) => {
const purse = new Purse()
await inject(page, purse)
const seen = await world(page, purse)
await page.goto(`${base}/login?${DESKTOP}`)
await owned(page)
await door(page).click()
await expect(page.getByText('Signed in. Return to the app; you can close this tab.')).toBeVisible({ timeout: 20_000 })
expect(seen.verified).toHaveLength(1)
expect(seen.verified[0].body).toMatchObject({ clientId: 'hanzo-app', redirectUri: 'hanzo://oauth/hanzo', state: 'st-desktop', codeChallenge: 'ch-desktop', codeChallengeMethod: 'S256' })
expect(seen.tokens, 'the app redeems its own code').toEqual([])
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBeNull()
})