Files
zeekay 39386df20f tap: the tapped credential goes to IAM to be verified, and the gate says so
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
2026-10-26 03:29:12 -07:00

166 lines
8.4 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 { 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)
})