mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The page hands IAM the credential (id_token_hint), its nonce (idp_nonce) and the address (login_hint) and trusts nothing in it; a credential naming no address is not forwarded. tap.spec.ts held the old contract (address only, auto_select false): it now asserts the nonce, the credential and the hint, no prompt parameter, and that a panel which assumes Google until IAM answers withdraws the prompt when IAM does not offer it. Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
166 lines
8.4 KiB
TypeScript
166 lines
8.4 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
||
import { serveExport } from './export'
|
||
import { person } from './heard'
|
||
import { contract, issuer, jwt, type Seen } from './issuer'
|
||
|
||
/**
|
||
* GOOGLE ONE TAP IS OFFERED ON /login AND /signup, AND NOWHERE ELSE.
|
||
*
|
||
* Google's script is answered here by a stand-in that records what the page asks
|
||
* of it; nothing is fetched off this machine. What is measured is when the page
|
||
* asks Google to prompt, when it withdraws the prompt, and where a tapped account
|
||
* goes: the same top-level hop through IAM the Google button makes, carrying the
|
||
* credential Google signed (`id_token_hint`), the nonce the page asked Google to
|
||
* bind (`idp_nonce`) and the account's address (`login_hint`), so IAM can sign the
|
||
* person in without a second choice of account, or send Google straight to it.
|
||
* The page reads one thing from the credential, the address, and trusts nothing.
|
||
*/
|
||
|
||
let base: string
|
||
let stop: () => Promise<void>
|
||
test.beforeAll(async () => {
|
||
const served = await serveExport()
|
||
base = served.url
|
||
stop = served.close
|
||
})
|
||
test.afterAll(async () => stop())
|
||
|
||
const GSI = 'https://accounts.google.com/gsi/client'
|
||
const CLIENT = '113591532635-s8pvqrebprkbndluhftddmdap4htvu1p.apps.googleusercontent.com'
|
||
|
||
/** Google's script as a recording: what was initialized, how often it prompted, how often it was withdrawn. */
|
||
const STAND_IN = `
|
||
window.__tap = { inits: [], prompts: 0, cancels: 0 }
|
||
window.google = { accounts: { id: {
|
||
initialize(config) { window.__tap.inits.push(config) },
|
||
prompt() { window.__tap.prompts++ },
|
||
cancel() { window.__tap.cancels++ },
|
||
} } }
|
||
`
|
||
|
||
interface Tap {
|
||
seen: Seen
|
||
loads: () => number
|
||
}
|
||
|
||
/** The world as login.spec answers it, with Google's script the stand-in and a count of the times it is asked for. */
|
||
async function world(page: Page, { driven = false }: { driven?: boolean } = {}): Promise<Tap> {
|
||
await page.route('**/*', (route) => (new URL(route.request().url()).origin === base ? route.continue() : route.abort()))
|
||
const seen = await issuer(page, { site: base, token: jwt({ sub: 'ada', name: 'ada', email: '[email protected]' }) })
|
||
let loads = 0
|
||
await page.route(GSI, (route) => {
|
||
loads++
|
||
return route.fulfill({ contentType: 'text/javascript', body: STAND_IN })
|
||
})
|
||
if (!driven) await person(page)
|
||
return { seen, loads: () => loads }
|
||
}
|
||
|
||
const tapped = (page: Page) => page.evaluate(() => (window as unknown as { __tap?: { inits: unknown[]; prompts: number; cancels: number } }).__tap)
|
||
|
||
/** An account tapped: Google hands the page's callback a credential. */
|
||
const tap = (page: Page, credential: string) =>
|
||
page.evaluate((c) => (window as unknown as { __tap: { inits: { callback: (r: { credential: string }) => void }[] } }).__tap.inits[0].callback({ credential: c }), credential)
|
||
|
||
for (const route of ['/login', '/signup'] as const) {
|
||
test(`${route} asks Google to prompt, once, as IAM's Google client`, async ({ page }) => {
|
||
const { loads } = await world(page)
|
||
await page.goto(`${base}${route}`)
|
||
await expect.poll(() => tapped(page).then((t) => t?.prompts), { timeout: 15_000 }).toBe(1)
|
||
const t = (await tapped(page))!
|
||
expect(loads(), 'the script, once').toBe(1)
|
||
expect(t.inits, 'initialized once').toHaveLength(1)
|
||
expect(t.inits[0]).toMatchObject({ client_id: CLIENT, auto_select: true, cancel_on_tap_outside: true })
|
||
expect(typeof (t.inits[0] as { nonce?: unknown }).nonce, 'a nonce for Google to bind into the credential').toBe('string')
|
||
})
|
||
|
||
test(`an account tapped on ${route} is the Google hop carrying its credential, its nonce and its address`, async ({ page }) => {
|
||
const { seen } = await world(page)
|
||
await page.goto(`${base}${route}`)
|
||
await expect.poll(() => tapped(page).then((t) => t?.prompts), { timeout: 15_000 }).toBe(1)
|
||
const credential = jwt({ email: '[email protected]', name: 'Taken From Google', sub: 'google-sub' })
|
||
const nonce = (await tapped(page))!.inits[0] as { nonce: string }
|
||
await tap(page, credential)
|
||
await expect.poll(() => seen.authorize.length, { timeout: 15_000 }).toBe(1)
|
||
const q = seen.authorize[0].searchParams
|
||
expect(`${seen.authorize[0].origin}${seen.authorize[0].pathname}`, 'IAM’s authorize, on this origin').toBe(`${base}/v1/iam/oauth/authorize`)
|
||
expect(q.get('provider')).toBe('provider-google')
|
||
expect(q.get('login_hint')).toBe('[email protected]')
|
||
expect(q.get('id_token_hint'), 'the credential, whole, for IAM to verify').toBe(credential)
|
||
expect(q.get('idp_nonce'), 'the nonce Google was asked to bind').toBe(nonce.nonce)
|
||
expect(q.get('prompt'), 'and no screen is asked for').toBeNull()
|
||
expect(q.get('signup') === 'true', 'only /signup asks for an account').toBe(route === '/signup')
|
||
expect(q.get('code'), 'nothing of Google’s credential is a code').toBeNull()
|
||
expect(q.get('id_token')).toBeNull()
|
||
contract(seen)
|
||
})
|
||
}
|
||
|
||
test('a credential that names no address is still only the Google hop, with no hint and nothing for IAM to verify', async ({ page }) => {
|
||
const { seen } = await world(page)
|
||
await page.goto(`${base}/login`)
|
||
await expect.poll(() => tapped(page).then((t) => t?.prompts), { timeout: 15_000 }).toBe(1)
|
||
await tap(page, 'not.a-token.at-all')
|
||
await expect.poll(() => seen.authorize.length, { timeout: 15_000 }).toBe(1)
|
||
expect(seen.authorize[0].searchParams.get('provider')).toBe('provider-google')
|
||
expect(seen.authorize[0].searchParams.has('login_hint')).toBe(false)
|
||
expect(seen.authorize[0].searchParams.has('id_token_hint')).toBe(false)
|
||
expect(seen.authorize[0].searchParams.has('idp_nonce')).toBe(false)
|
||
})
|
||
|
||
test('a press withdraws the prompt', async ({ page }) => {
|
||
await world(page)
|
||
await page.goto(`${base}/login`)
|
||
await expect.poll(() => tapped(page).then((t) => t?.prompts), { timeout: 15_000 }).toBe(1)
|
||
await page.getByRole('region', { name: 'Log in' }).getByLabel('Email').fill('[email protected]')
|
||
await page.getByRole('region', { name: 'Log in' }).getByRole('button', { name: 'Continue with email' }).click()
|
||
await expect.poll(() => tapped(page).then((t) => t?.cancels), { timeout: 15_000 }).toBeGreaterThan(0)
|
||
expect((await tapped(page))!.prompts, 'and is not shown again').toBe(1)
|
||
})
|
||
|
||
test('the landing draws the same panel and never loads Google', async ({ page }) => {
|
||
const { loads } = await world(page)
|
||
await page.goto(`${base}/`)
|
||
await expect(page.getByRole('button', { name: 'Continue with Google' })).toBeVisible({ timeout: 20_000 })
|
||
await page.waitForTimeout(1500)
|
||
expect(loads()).toBe(0)
|
||
})
|
||
|
||
test('no prompt where IAM does not offer Google', async ({ page }) => {
|
||
const { loads } = await world(page)
|
||
await page.route('**/v1/iam/auth/methods*', (route) =>
|
||
route.fulfill({
|
||
contentType: 'application/json',
|
||
headers: { 'access-control-allow-origin': base },
|
||
body: JSON.stringify({ status: 'ok', data: { password: true, code: true, signup: true, oauth: [{ name: 'provider-github', type: 'GitHub' }] } }),
|
||
}),
|
||
)
|
||
await page.goto(`${base}/login`)
|
||
await expect(page.getByRole('button', { name: 'Continue with GitHub' })).toBeVisible({ timeout: 20_000 })
|
||
await expect(page.getByRole('button', { name: 'Continue with Google' })).toHaveCount(0)
|
||
await page.waitForTimeout(1500)
|
||
// The panel offers Google until IAM says otherwise (components/auth/panel `FIRST`), so the prompt
|
||
// may have been asked for once before the answer; it is withdrawn when the answer comes, never again.
|
||
const t = (await tapped(page))!
|
||
expect(t.prompts, 'at most the one asked before IAM answered').toBeLessThanOrEqual(1)
|
||
if (t.prompts > 0) expect(t.cancels, 'and withdrawn once IAM does not offer Google').toBeGreaterThan(0)
|
||
expect(loads(), 'and the script is asked for only once Google is on offer').toBeLessThanOrEqual(1)
|
||
})
|
||
|
||
test('a browser driven by a script is not prompted', async ({ page }) => {
|
||
const { loads } = await world(page, { driven: true })
|
||
await page.goto(`${base}/login`)
|
||
await expect(page.getByRole('button', { name: 'Continue with Google' })).toBeVisible({ timeout: 20_000 })
|
||
await page.waitForTimeout(1500)
|
||
expect(loads()).toBe(0)
|
||
})
|
||
|
||
test('a browser that sends Global Privacy Control is not prompted', async ({ page }) => {
|
||
const { loads } = await world(page)
|
||
await page.addInitScript(() => Object.defineProperty(Navigator.prototype, 'globalPrivacyControl', { get: () => true }))
|
||
await page.goto(`${base}/login`)
|
||
await expect(page.getByRole('button', { name: 'Continue with Google' })).toBeVisible({ timeout: 20_000 })
|
||
await page.waitForTimeout(1500)
|
||
expect(loads()).toBe(0)
|
||
})
|