Files
hanzo.ai/e2e/unit/impersonation.spec.ts
zeekay d13fa65191 Refuse any link but a support sign-in, send /auth/* with no referrer, and hand the token back at every end
initiation() takes only an impersonation: hint for this site's issuer; the client is the one
the site signs in as (hanzo-app). The edge gets a no-referrer route for /auth/*. Exit, the
SDK's early end and a token found expired all leave the same way: RFC 7009 revoke, tokens
cleared, the person's keys emptied. The tag request, <Look/>, bind and claim do nothing as
the person. The gate rides e2e/gates/issuer.ts, which now sends authorize back, mints a
support token without a refresh token, echoes the mark in userinfo and records revocations.

Claude-Session: https://claude.ai/code/session_01HvACxex9ZZTzJCCHgirsGy
2026-10-06 23:35:42 -07:00

200 lines
8.2 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { HINT_PREFIX, INITIATE_PATH, banner, impersonated, initiation, isolate, issuer, revoke } from '../../lib/auth/impersonation'
import { NOREFERRER } from '../../lib/edge'
/**
* A support session (hanzoai/iam internal/oidc/impersonate.go): IAM marks the
* token `imp` and names the operator in `act`. /auth/initiate takes `iss` and
* a one-time `login_hint` (OIDC Core ยง4). These are the decisions, over values.
*/
const ISSUER = 'https://hanzo.id'
const HINT = 'impersonation:4f1c+9/a=b&c'
const query = (params: [string, string][]) => `?${new URLSearchParams(params)}`
test.describe('initiation: a support sign-in or nothing', () => {
test('the issuer this site signs in at, with an impersonation hint, starts a sign-in carrying it unchanged', () => {
expect(initiation(query([['iss', ISSUER], ['login_hint', HINT]]), ISSUER)).toEqual({ hint: HINT })
})
test('the prefix is the one IAM mints (impersonate.go hintPrefix)', () => {
expect(HINT_PREFIX).toBe('impersonation:')
})
test('a percent-encoded hint arrives decoded once, exactly as IAM minted it', () => {
expect(initiation(`?iss=${encodeURIComponent(ISSUER)}&login_hint=impersonation%3Aab%2Bcd`, ISSUER)).toEqual({
hint: 'impersonation:ab+cd',
})
})
for (const [what, search] of [
['no iss', query([['login_hint', HINT]])],
['an empty iss', query([['iss', ''], ['login_hint', HINT]])],
['another issuer', query([['iss', 'https://evil.example'], ['login_hint', HINT]])],
['the issuer with a trailing slash', query([['iss', `${ISSUER}/`], ['login_hint', HINT]])],
['the issuer at another case', query([['iss', 'https://HANZO.id'], ['login_hint', HINT]])],
['two iss', query([['iss', ISSUER], ['iss', 'https://evil.example'], ['login_hint', HINT]])],
['no hint', query([['iss', ISSUER]])],
['an empty hint', query([['iss', ISSUER], ['login_hint', '']])],
['a person as the hint', query([['iss', ISSUER], ['login_hint', '[email protected]']])],
['the bare prefix', query([['iss', ISSUER], ['login_hint', 'impersonation:']])],
['the prefix not at the start', query([['iss', ISSUER], ['login_hint', ` ${HINT}`]])],
['the prefix at another case', query([['iss', ISSUER], ['login_hint', HINT.toUpperCase()]])],
['two hints', query([['iss', ISSUER], ['login_hint', HINT], ['login_hint', 'impersonation:other']])],
] as const) {
test(`${what} is refused, and the refusal never repeats the hint`, () => {
const asked = initiation(search, ISSUER)
expect('refused' in asked && asked.refused).toBeTruthy()
expect(JSON.stringify(asked)).not.toMatch(/impersonation|4f1c|alice/i)
})
}
test('the edge sends the initiate page with no referrer', () => {
expect(INITIATE_PATH.startsWith(NOREFERRER.prefix)).toBe(true)
expect(NOREFERRER.headers['Referrer-Policy']).toBe('no-referrer')
})
})
test.describe('impersonated: imp alone decides, act names the operator', () => {
const exp = 2_000_000_000
test('imp with the operator: the person, the operator and the expiry', () => {
const session = impersonated({ sub: 'u-1', name: 'alice', imp: true, act: { sub: 'u-z', owner: 'admin', name: 'z' }, exp })
expect(session).toEqual({ name: 'alice', operator: 'admin/z', expires: exp * 1000 })
expect(banner(session!)).toBe('Signed in as alice by admin/z โ€” support session')
})
test('imp with no complete act is still a support session', () => {
expect(impersonated({ name: 'alice', imp: true })?.operator).toBe('an operator')
expect(impersonated({ name: 'alice', imp: true, act: { owner: 'admin' } })?.operator).toBe('an operator')
expect(impersonated({ name: 'alice', imp: true, act: 'admin/z' })?.operator).toBe('an operator')
})
test('no name falls back to the email; no exp is no expiry', () => {
expect(impersonated({ email: '[email protected]', imp: true, act: { owner: 'admin', name: 'z' } })).toEqual({
name: '[email protected]',
operator: 'admin/z',
expires: null,
})
})
test('anything but imp: true is an ordinary session', () => {
for (const claims of [
{ name: 'alice', act: { sub: 'k', owner: 'acme', name: 'key' } },
{ name: 'alice', imp: 'true', act: { owner: 'admin', name: 'z' } },
{ name: 'alice', imp: 1 },
{ name: 'alice', imp: false },
{},
null,
undefined,
'imp',
]) {
expect(impersonated(claims)).toBeNull()
}
})
})
test.describe('issuer: the discovery document of the configured server', () => {
function discovery(answer: (url: string) => Response | Promise<Response>) {
const asked: string[] = []
const was = globalThis.fetch
globalThis.fetch = async (input: string | URL | Request) => {
asked.push(String(input))
return answer(String(input))
}
return { asked, restore: () => (globalThis.fetch = was) }
}
test('api.hanzo.ai names hanzo.id as its issuer', async () => {
const d = discovery(() => new Response(JSON.stringify({ issuer: ISSUER })))
try {
expect(await issuer('https://api.hanzo.ai/')).toBe(ISSUER)
expect(d.asked).toEqual(['https://api.hanzo.ai/.well-known/openid-configuration'])
} finally {
d.restore()
}
})
test('unreachable or unreadable, the server is its own issuer, as in the SDK', async () => {
for (const answer of [
() => new Response('nope', { status: 503 }),
() => new Response('<html>', { status: 200 }),
() => new Response(JSON.stringify({ issuer: 7 })),
() => Promise.reject(new TypeError('offline')),
]) {
const d = discovery(answer)
try {
expect(await issuer('https://api.hanzo.ai')).toBe('https://api.hanzo.ai')
} finally {
d.restore()
}
}
})
})
test.describe('revoke: the support token handed back to IAM', () => {
function iam(answer: () => Promise<Response>) {
const asked: { url: string; init?: RequestInit }[] = []
const was = globalThis.fetch
globalThis.fetch = async (input: string | URL | Request, init?: RequestInit) => {
asked.push({ url: String(input), init })
return answer()
}
return { asked, restore: () => (globalThis.fetch = was) }
}
test('RFC 7009 at the host the SDK signs out at, as the client, carried past a navigation', async () => {
const i = iam(async () => new Response('', { status: 200 }))
try {
await revoke('tok', { serverUrl: 'https://api.hanzo.ai/', clientId: 'hanzo-app' })
await revoke('tok', { serverUrl: 'https://hanzo.id', proxyBaseUrl: 'https://platform.hanzo.ai', clientId: 'hanzo-cloud' })
expect(i.asked.map((a) => a.url)).toEqual([
'https://api.hanzo.ai/v1/iam/oauth/revoke',
'https://platform.hanzo.ai/v1/iam/oauth/revoke',
])
const [first] = i.asked
expect(first.init?.method).toBe('POST')
expect(first.init?.keepalive).toBe(true)
const headers = first.init?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe('Bearer tok')
expect(Object.fromEntries(new URLSearchParams(String(first.init?.body)))).toEqual({
token: 'tok',
token_type_hint: 'access_token',
client_id: 'hanzo-app',
})
} finally {
i.restore()
}
})
test('an issuer that fails or never answers costs at most the wait, and never throws', async () => {
for (const answer of [() => Promise.reject(new TypeError('offline')), () => new Promise<Response>(() => {})]) {
const i = iam(answer)
try {
const began = Date.now()
await revoke('tok', { serverUrl: 'https://api.hanzo.ai', clientId: 'hanzo-app' })
expect(Date.now() - began).toBeLessThan(2500)
} finally {
i.restore()
}
}
})
})
test('isolate drops the refresh token an earlier session left, and nothing else', () => {
const store: Record<string, string> = {
hanzo_iam_access_token: 'a',
hanzo_iam_refresh_token: 'r-earlier',
hanzo_iam_expires_at: '1',
}
const g = globalThis as unknown as { window?: unknown }
const was = g.window
g.window = { localStorage: { removeItem: (k: string) => delete store[k] } }
try {
isolate()
expect(store).toEqual({ hanzo_iam_access_token: 'a', hanzo_iam_expires_at: '1' })
} finally {
g.window = was
}
})