mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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
200 lines
8.2 KiB
TypeScript
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
|
|
}
|
|
})
|