Files
hanzo.ai/e2e/gates/impersonation.spec.ts
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

308 lines
16 KiB
TypeScript

import { expect, test, type Page, type Request } from '@playwright/test'
import { serveExport } from './export'
import { stream, type Wire } from './heard'
import { API, DISCOVERY, contract, issuer, pkce, type Seen as Iam } from './issuer'
import { signIn } from '../session'
import { CLIENT } from '@hanzo/app/lib/api'
/**
* A SUPPORT SESSION IS OPENED BY A LINK, MARKED ON EVERY PAGE, NEVER RENEWED, AND
* HANDED BACK WHEN IT ENDS.
*
* IAM's support flow (hanzoai/iam internal/oidc/impersonate.go) sends a SuperAdmin
* to `/auth/initiate?iss=<issuer>&login_hint=impersonation:<id>`. Measured on the
* shipped bytes, served with the edge's headers (lib/edge.ts), hanzo.id answered
* on this machine: a link that is not a support sign-in for this site is refused in
* words and signs nobody out; the right one starts a PKCE sign-in as the client this
* site signs in as, carrying the hint unchanged and leaking it nowhere; IAM's token
* (`imp`, the operator in `act`, no refresh token) lands on `/` under a banner with
* no dismiss; every end — Exit, the SDK's own sixty seconds early, a token found
* expired — revokes the token and leaves nothing of the person behind; and nothing
* is written or counted as the person.
*/
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url')
const jwt = (claims: object) => [b64({ alg: 'none', typ: 'JWT' }), b64(claims), ''].join('.')
const now = () => Math.floor(Date.now() / 1000)
const ISSUER = DISCOVERY.issuer
const SECRET = '9b2e6c0f4a7d41e3'
const HINT = `impersonation:${SECRET}`
const ACT = { sub: 'u-z', owner: 'admin', name: 'z' }
const PERSON = { sub: 'u-alice', name: 'alice', email: '[email protected]' }
const BANNER = 'Signed in as alice by admin/z — support session'
/** One stream flush (@hanzo/event `flushIntervalMs`) and a margin. */
const FLUSH = 6000
/** A support session's token, as IAM mints it. */
const SUPPORT = (exp: number) => jwt({ ...PERSON, imp: true, act: ACT, exp, orgs: [{ org: 'acme', role: 'member' }] })
/** The operator's own session on this site, which a link must not end. */
const EARLIER = jwt({ sub: 'u-z', name: 'z', email: '[email protected]', exp: now() + 3600, orgs: [{ org: 'admin', role: 'admin' }] })
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 initiate = (search: string) => `${base}/auth/initiate?${search}`
const link = (hint = HINT, iss = ISSUER) => initiate(`iss=${encodeURIComponent(iss)}&login_hint=${encodeURIComponent(hint)}`)
interface Seen extends Iam {
/** Every request the page made, with its headers and body. */
requests: Request[]
console: string[]
events: Wire[]
}
/**
* The world, answered: IAM by the gates' one helper (`./issuer`), minting a support
* session's token (`imp`, the operator in `act`, no refresh token) and sending the
* authorize page back by `back`; this origin's /v1 with an empty answer; the stream
* recorded. Every request is kept, with its headers. Everything else fails.
*/
async function world(page: Page, back?: (authorize: URL) => string): Promise<Seen> {
const requests: Request[] = []
const console: string[] = []
page.on('console', (m) => console.push(m.text()))
page.on('request', (r) => requests.push(r))
await page.route('**/*', (route) => (new URL(route.request().url()).origin === base ? route.continue() : route.abort()))
await page.route(`${base}/v1/**`, (route) => route.fulfill({ json: {} }))
const events = await stream(page)
const iam = await issuer(page, { token: SUPPORT(now() + 900), authorize: back })
return Object.assign(iam, { requests, console, events })
}
/** The bodies posted to IAM's token endpoint, in order. */
const grants = (seen: Seen) =>
seen.requests
.filter((r) => r.url() === `${API}/v1/iam/oauth/token` && r.method() === 'POST')
.map((r) => new URLSearchParams(r.postData() ?? ''))
/** This browser, once: `token` in the SDK's store with a stale refresh token beside it, and `keep`. */
function seed(page: Page, token: string, exp: number, keep: Record<string, string> = {}) {
return page.addInitScript(
({ token, exp, keep }) => {
if (sessionStorage.getItem('gate.seeded')) return
sessionStorage.setItem('gate.seeded', '1')
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_refresh_token', 'r-earlier')
localStorage.setItem('hanzo_iam_expires_at', String(exp * 1000))
for (const [k, v] of Object.entries(keep)) localStorage.setItem(k, v)
},
{ token, exp, keep },
)
}
/** The person's own things this browser holds during a support session. */
const THEIRS = { 'hanzo:who': 'u-alice', hanzo_custom_threads_v1: '[{"id":"ct-alice","title":"Acme offsite"}]' }
/** What this browser holds, read between navigations (one in flight answers null). */
const stored = (page: Page) =>
page
.evaluate(() => ({
access: localStorage.getItem('hanzo_iam_access_token'),
refresh: localStorage.getItem('hanzo_iam_refresh_token'),
who: localStorage.getItem('hanzo:who'),
threads: localStorage.getItem('hanzo_custom_threads_v1'),
}))
.catch(() => null)
const mark = (page: Page) => page.getByRole('status', { name: 'Support session' })
const refreshes = (seen: Seen) => grants(seen).filter((b) => b.get('grant_type') === 'refresh_token').length
const sent = (seen: Seen, path: string) =>
seen.requests.filter((r) => new URL(r.url()).pathname === path && r.method() !== 'GET' && r.method() !== 'OPTIONS')
/**
* Every request that carried the hint, in its address, body or Referer, but the
* two that must: the link's own page load and the authorize at hanzo.id.
*/
const leaks = (seen: Seen) =>
seen.requests
.filter((r) => {
const url = new URL(r.url())
const own = url.origin === base && url.pathname === '/auth/initiate' && r.isNavigationRequest()
return !own && url.pathname !== '/v1/iam/oauth/authorize'
})
.filter((r) => [r.url(), r.postData() ?? '', r.headers().referer ?? ''].some((s) => s.includes(SECRET)))
.map((r) => `${r.method()} ${r.url()} referer=${r.headers().referer ?? ''}`)
test.describe('the link', () => {
for (const [what, address, refusal] of [
['another issuer', () => link(HINT, 'https://evil.example'), 'This sign-in link is not for this site.'],
['no hint', () => initiate(`iss=${encodeURIComponent(ISSUER)}`), 'This sign-in link is not a support sign-in.'],
['a person as the hint', () => link('[email protected]'), 'This sign-in link is not a support sign-in.'],
] as const) {
test(`naming ${what} is refused in words, starts nothing, leaks nothing and signs nobody out`, async ({ page }) => {
const seen = await world(page)
await seed(page, EARLIER, now() + 3600, { 'hanzo:who': 'u-z' })
await page.goto(address())
await expect(page.getByRole('heading', { name: refusal })).toBeVisible()
expect(new URL(page.url()).search, 'the query left the address').toBe('')
expect(await page.content(), 'the hint is never drawn').not.toContain(SECRET)
await page.waitForTimeout(FLUSH)
expect(seen.authorize, 'no sign-in started').toEqual([])
expect(await stored(page), 'the session in this browser is untouched').toMatchObject({ access: EARLIER, refresh: 'r-earlier', who: 'u-z' })
expect(leaks(seen)).toEqual([])
expect(seen.console.filter((m) => m.includes(SECRET))).toEqual([])
})
}
test('the page goes out with no referrer, so nothing it loads names its address', async ({ page }) => {
const seen = await world(page)
const document = page.waitForResponse((r) => new URL(r.url()).pathname === '/auth/initiate')
await page.goto(link(HINT, 'https://evil.example'))
expect((await document).headers()['referrer-policy']).toBe('no-referrer')
await expect(page.getByRole('heading', { name: 'This sign-in link is not for this site.' })).toBeVisible()
const loaded = seen.requests.filter((r) => new URL(r.url()).origin === base && !r.isNavigationRequest())
expect(loaded.length, 'the page loaded its own stylesheets and scripts').toBeGreaterThan(0)
// A font a stylesheet asks for names the stylesheet, which is not this page.
expect(loaded.filter((r) => (r.headers().referer ?? '').includes('/auth/initiate')).map((r) => r.url())).toEqual([])
})
test('the issuer\'s link signs in as this site\'s client, lands on `/` under the banner, and Exit hands the token back', async ({ page }) => {
const seen = await world(page, (a) => `${base}/auth/callback?code=c0de&state=${a.searchParams.get('state')}`)
await seed(page, EARLIER, now() + 3600, { 'hanzo:who': 'u-z' })
await page.goto(link())
// A fresh PKCE sign-in at hanzo.id, as the one client this site signs in as —
// the one IAM_IMPERSONATION_APPS must name — carrying the hint as IAM minted it.
const q = (await pkce(seen, base)).searchParams
expect(seen.authorize).toHaveLength(1)
expect(q.get('client_id')).toBe(CLIENT)
expect(CLIENT).toBe('hanzo-app')
expect(q.get('login_hint')).toBe(HINT)
await expect(mark(page)).toHaveText(new RegExp(`^${BANNER}`), { timeout: 20_000 })
await expect.poll(() => new URL(page.url()).pathname).toBe('/')
expect(grants(seen).map((b) => b.get('grant_type'))).toEqual(['authorization_code'])
expect(grants(seen)[0].get('code_verifier'), 'the code is redeemed with this browser\'s verifier').toBeTruthy()
// IAM issued no refresh token; the earlier session's does not stay to renew this one.
await expect.poll(async () => (await stored(page)).refresh).toBeNull()
await expect(mark(page).getByRole('button')).toHaveText('Exit')
// Nothing is identified or counted as the person, and the hint went nowhere.
// The one identify allowed is the operator's own earlier session, on the
// link's page before the hop to hanzo.id: that page is the operator's browser
// signed in as itself (nothing is cleared first), and whether its identify
// flushes before the page leaves is a race (1 run in 3 here). Its address
// carries no hint (`leaks` below). Once the support session lands, nothing
// is identified at all, the person or anyone.
await page.waitForTimeout(FLUSH)
const before = (e: Wire & { path?: string; distinctId?: string }) => e.path === '/auth/initiate' && e.distinctId === ACT.sub
expect(seen.events.filter((e) => e.type === 'identify' && !before(e))).toEqual([])
expect(seen.events.filter((e) => e.event === 'login_completed')).toEqual([])
expect(leaks(seen)).toEqual([])
expect(seen.console.filter((m) => m.includes(SECRET))).toEqual([])
const support = (await stored(page)).access
await page.evaluate((v) => localStorage.setItem('hanzo_custom_threads_v1', v), THEIRS.hanzo_custom_threads_v1)
await mark(page).getByRole('button', { name: 'Exit' }).click()
await expect.poll(() => seen.revoked.map((b) => b.get('token'))).toEqual([support])
expect(Object.fromEntries(seen.revoked[0])).toMatchObject({ token_type_hint: 'access_token', client_id: CLIENT })
await expect(mark(page)).toHaveCount(0)
await expect.poll(() => stored(page)).toEqual({ access: null, refresh: null, who: null, threads: null })
expect(new URL(page.url()).pathname).toBe('/')
expect(refreshes(seen)).toBe(0)
contract(seen)
})
test('a sign-in IAM refuses leaves the session this browser had', async ({ page }) => {
const seen = await world(
page,
(a) => `${base}/auth/callback?error=access_denied&error_description=refused&state=${a.searchParams.get('state')}`,
)
await seed(page, EARLIER, now() + 3600, { 'hanzo:who': 'u-z' })
await page.goto(link())
await expect.poll(() => seen.authorize.length, { timeout: 15_000 }).toBe(1)
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible({ timeout: 15_000 })
expect(await stored(page)).toMatchObject({ access: EARLIER, refresh: 'r-earlier', who: 'u-z' })
expect(grants(seen)).toEqual([])
contract(seen)
})
})
test.describe('the end', () => {
test('a browser reopened on an expired support session hands it back and keeps nothing of the person', async ({ page }) => {
const seen = await world(page)
const token = SUPPORT(now() - 30)
await seed(page, token, now() - 30, THEIRS)
await page.goto(`${base}/`)
await expect.poll(() => seen.revoked.map((b) => b.get('token')), { timeout: 15_000 }).toEqual([token])
await expect.poll(() => stored(page), { timeout: 15_000 }).toEqual({ access: null, refresh: null, who: null, threads: null })
await expect(mark(page)).toHaveCount(0)
expect(refreshes(seen)).toBe(0)
})
test('the SDK\'s own end, sixty seconds early, is the same end: no refresh, the token handed back, nothing kept', async ({ page }) => {
const seen = await world(page)
// Sixty-five seconds left: the SDK reaches for a renewal at sixty before the end.
const token = SUPPORT(now() + 65)
await seed(page, token, now() + 65, THEIRS)
await page.goto(`${base}/`)
await expect(mark(page)).toHaveText(new RegExp(`^${BANNER}`), { timeout: 20_000 })
await expect.poll(async () => (await stored(page)).refresh).toBeNull()
await expect.poll(() => seen.revoked.map((b) => b.get('token')), { timeout: 30_000 }).toEqual([token])
await expect.poll(() => stored(page), { timeout: 15_000 }).toEqual({ access: null, refresh: null, who: null, threads: null })
await expect(mark(page)).toHaveCount(0)
expect(refreshes(seen), 'no refresh grant was ever sent').toBe(0)
})
})
test.describe('nothing as the person', () => {
/**
* A browser holding kept affiliate and referral codes, settled on a page of the
* site's own (no room: the rooms keep their own look in step, @hanzo/app `useLook`).
*/
async function settle(page: Page, seen: Seen) {
await page.goto(`${base}/pricing`)
await expect.poll(() => seen.requests.some((r) => new URL(r.url()).pathname === '/v1/iam/oauth/userinfo'), { timeout: 20_000 }).toBe(true)
await page.waitForTimeout(FLUSH)
}
const CODES = { hz_aff: 'AFF1', hz_ref: 'REF1' }
async function support(page: Page): Promise<Seen> {
const seen = await world(page)
await seed(page, SUPPORT(now() + 900), now() + 900, { ...CODES, 'hanzo:who': 'u-alice' })
await settle(page, seen)
await expect(mark(page)).toBeVisible()
return seen
}
const tags = (seen: Seen) => seen.requests.filter((r) => new URL(r.url()).pathname === '/v1/project/tags')
test('a support session binds no affiliate and claims no referral for the person\'s org', async ({ page }) => {
const seen = await support(page)
expect(sent(seen, '/v1/affiliate/attribute')).toEqual([])
expect(sent(seen, '/v1/referral/claim')).toEqual([])
})
test('a support session writes nothing to the person\'s preferences', async ({ page }) => {
const seen = await support(page)
expect(sent(seen, '/v1/iam/preferences').map((r) => r.postData())).toEqual([])
})
test('a support session asks for the tag set as nobody', async ({ page }) => {
const seen = await support(page)
expect(tags(seen).length, 'the tag set was asked for').toBeGreaterThan(0)
expect(tags(seen).map((r) => r.headers().authorization ?? null).filter(Boolean)).toEqual([])
})
test('an ordinary session does each of them, so the support session\'s silence is measured', async ({ page }) => {
const seen = await world(page)
await page.addInitScript((codes) => {
for (const [k, v] of Object.entries(codes)) localStorage.setItem(k, v)
}, CODES)
await signIn(page, { sub: 'u-bob', name: 'bob', email: '[email protected]' })
await settle(page, seen)
await expect(mark(page)).toHaveCount(0)
await expect.poll(() => seen.events.filter((e) => e.type === 'identify').length, { timeout: 20_000 }).toBeGreaterThan(0)
expect(sent(seen, '/v1/affiliate/attribute').length).toBeGreaterThan(0)
expect(sent(seen, '/v1/referral/claim').length).toBeGreaterThan(0)
expect(sent(seen, '/v1/iam/preferences').length).toBeGreaterThan(0)
expect(tags(seen).some((r) => (r.headers().authorization ?? '').startsWith('Bearer '))).toBe(true)
})
})