mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
/login and /signup draw "Continue with wallet" and "Continue with phone" after Google and GitHub, each only where IAM's methods say it is on. Wallet: where IAM signs a wallet in (@hanzo/iam 0.21.37 `wallet`) and the browser injects an EIP-1193 wallet; the press is loginWithWallet (IAM's challenge, personal_sign of its EIP-4361 text, IAM's verify) and ends as a password's sign-in ends. A wallet IAM has not seen is made an account in an org of its own, with no email. Phone: where IAM says it sends codes (`code`) and has answered. The number is read in its country with the SMS consent ticked, then IAM's code flow. IAM's address lookup answers an email address only, so a number is texted as any is and the account that holds it is signed in by the code. Settings -> General -> Sign in another way adds an email or a phone to the signed-in account through IAM's own proof (addAddress, PUT /v1/iam/address), drawn only where IAM's methods say `address`. Same account, org, keys and balance; nothing is created, so nothing earns twice. Gates: wallet.spec (a throwaway secp256k1 key behind an injected provider), phone.spec, address.spec. login.spec expects the phone after the providers.
473 lines
23 KiB
TypeScript
473 lines
23 KiB
TypeScript
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). */
|
||
async function inject(page: Page, purse: Purse, { decline = false } = {}): Promise<void> {
|
||
await page.exposeBinding('__purse', (_source, method: string, params: unknown[]) => purse.answer(method, params))
|
||
await page.addInitScript((no) => {
|
||
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 })
|
||
return w.__purse(method, params ?? [])
|
||
},
|
||
}
|
||
}, decline)
|
||
}
|
||
|
||
/** 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 = {
|
||
domain: 'api.hanzo.ai',
|
||
uri: 'https://api.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', 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 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()
|
||
})
|
||
|
||
/** 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()
|
||
})
|