Files
hanzo.ai/e2e/gates/issuer.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

303 lines
14 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, type BrowserContext, type Page, type Route } from '@playwright/test'
/**
* IAM, ANSWERED ON THIS MACHINE, AT THE HOSTS THE SITE USES.
*
* api.hanzo.ai every call: discovery, the address lookup and the sign-in
* methods, the token and userinfo (app/providers.tsx serverUrl
* and proxyBaseUrl)
* hanzo.id a page the browser is sent to — the authorize — recorded and
* never followed; the calls still made there are rooms'
* (`ROOMS`)
* this origin the credential calls (`site`): sign-in, codes, sign-up and
* sign-out, as universe's `iam-on-hanzo-ai` route answers them;
* and a provider's authorize, recorded and never followed like
* hanzo.id's, as `signin-on-hanzo-ai` sends it to IAM
*
* Measured live: discovery is the same bytes at api.hanzo.ai and at hanzo.id
* (issuer hanzo.id, every endpoint on hanzo.id), and api.hanzo.ai answers
* /v1/iam/oauth/* and /v1/iam/auth/* with CORS for https://hanzo.ai exactly as
* hanzo.id does. The SDK reads discovery at `${serverUrl}/.well-known/…`;
* /v1/iam/.well-known/… serves the same document, so both are answered.
*
* Everything asked is recorded, so a gate states where the browser went rather
* than that something answered. `contract` is the rule every IAM gate holds.
*/
export const API = 'https://api.hanzo.ai'
export const ID = 'https://hanzo.id'
const AUTHORIZE = '/v1/iam/oauth/authorize'
/** What discovery serves, trimmed to the fields the SDK reads plus the issuer. */
export const DISCOVERY = {
issuer: ID,
authorization_endpoint: `${ID}${AUTHORIZE}`,
token_endpoint: `${ID}/v1/iam/oauth/token`,
userinfo_endpoint: `${ID}/v1/iam/oauth/userinfo`,
revocation_endpoint: `${ID}/v1/iam/oauth/revoke`,
end_session_endpoint: `${ID}/v1/iam/oauth/logout`,
jwks_uri: `${ID}/v1/iam/.well-known/jwks`,
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
scopes_supported: ['openid', 'email', 'profile', 'address', 'phone', 'offline_access'],
}
/** An unsigned token the SDK reads as live for an hour. No server ever sees it. */
export function jwt(claims: Record<string, unknown>): string {
const part = (v: unknown) => Buffer.from(JSON.stringify(v)).toString('base64url')
return `${part({ alg: 'none', typ: 'JWT' })}.${part({ exp: Math.floor(Date.now() / 1000) + 3600, ...claims })}.`
}
/** IAM's envelope. An `error` answers 400. */
export type Answer = { status: string; msg?: string; code?: string; data?: unknown }
export interface Options {
/** Withhold discovery until `release()`: the page stays put until then. */
hold?: boolean
/** The origin the export is served at. Its /v1/iam routes are answered by `reply`. */
site?: string
/** This origin's answers by path: an answer, or one read off the JSON body. */
reply?: Record<string, Answer | ((body: Record<string, unknown>) => Answer)>
/** The access token the token endpoint mints. */
token?: string
/**
* Where the authorize page sends the browser back (a 302), or null to record it
* and go no further (the default): a gate that finishes a sign-in names its callback.
*/
authorize?: (url: URL) => string | null
}
/** One request to IAM off this machine. `navigation` is a page the browser was sent to. */
export type Asked = { method: string; url: URL; navigation: boolean }
export interface Seen {
/** Every request to api.hanzo.ai's IAM and to hanzo.id, in order. */
asked: Asked[]
/** Every authorize page the browser was sent to, in order. */
authorize: URL[]
/** What this origin's IAM routes were sent. */
posted: { path: string; type: string; body: string; url: URL }[]
/** Every address lookup made of IAM: one per address typed, where the application makes accounts. */
identified: Record<string, unknown>[]
/** What was handed back to IAM's revocation endpoint (RFC 7009), in order. */
revoked: URLSearchParams[]
/** Let a held discovery answer. */
release: () => void
}
const isIam = (url: URL) =>
(url.origin === API && (url.pathname.startsWith('/v1/iam/') || url.pathname.endsWith('/.well-known/openid-configuration'))) ||
url.origin === ID
/** The claims an `Authorization: Bearer` header carries, or null. */
export function claims(auth: string | undefined): Record<string, unknown> | null {
const raw = auth?.replace(/^Bearer /, '').split('.')[1]
if (!raw) return null
try {
return JSON.parse(Buffer.from(raw, 'base64url').toString())
} catch {
return null
}
}
/** CORS as the live hosts answer it: the asking origin, with credentials. */
function cors(route: Route): Record<string, string> {
const h = route.request().headers()
return {
'access-control-allow-origin': h.origin ?? '*',
'access-control-allow-credentials': 'true',
'access-control-allow-headers': h['access-control-request-headers'] ?? 'content-type, authorization',
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
}
}
/** Answer IAM on `on` (a page, or every page of a context). */
export async function issuer(on: Page | BrowserContext, options: Options = {}): Promise<Seen> {
const { hold = false, site, reply = {}, token = jwt({ sub: 'gate', name: 'gate', email: '[email protected]', orgs: [{ org: 'hanzo', role: 'owner' }] }), authorize } = options
let release: () => void = () => {}
const held = hold
? new Promise<void>((resolve) => {
release = resolve
})
: Promise.resolve()
const seen: Seen = { asked: [], authorize: [], posted: [], identified: [], revoked: [], release: () => release() }
await on.route(isIam, async (route) => {
const req = route.request()
const url = new URL(req.url())
if (req.method() === 'OPTIONS') return route.fulfill({ status: 204, headers: cors(route) })
const navigation = req.isNavigationRequest()
seen.asked.push({ method: req.method(), url, navigation })
const json = (body: unknown, status = 200) => route.fulfill({ status, headers: cors(route), contentType: 'application/json', body: JSON.stringify(body) })
// hanzo.id is a page. The authorize is recorded and not followed: letting it
// complete would put a live login page inside a hermetic gate. Anything else
// asked of it is refused, and `contract` names it.
if (url.origin === ID) {
if (navigation && url.pathname === AUTHORIZE) {
seen.authorize.push(url)
const back = authorize?.(url)
if (back) return route.fulfill({ status: 302, headers: { location: back }, body: '' })
}
return route.abort()
}
const path = url.pathname
if (path.endsWith('/.well-known/openid-configuration')) {
await held
return json(DISCOVERY)
}
if (path === '/v1/iam/oauth/token') {
// A refresh rotates, as IAM's does: the refresh token answered is never the one presented.
const presented = new URLSearchParams(req.postData() ?? '').get('refresh_token')
const refresh = presented ? presented.replace(/\d*$/, (n) => String(Number(n || 0) + 1)) : 'r1'
// The token's own life, and no refresh token for a support session's, as IAM mints them.
const minted = claims(`Bearer ${token}`) ?? {}
const life = typeof minted.exp === 'number' ? minted.exp - Math.floor(Date.now() / 1000) : 3600
const renewal = minted.imp === true ? {} : { refresh_token: refresh }
return json({ access_token: token, id_token: token, ...renewal, expires_in: life, token_type: 'Bearer' })
}
if (path === '/v1/iam/oauth/userinfo') {
const c = claims(req.headers().authorization)
// A support session's mark rides userinfo too (IAM internal/oidc/userinfo.go).
const mark = c?.imp === true ? { imp: true, act: c.act } : {}
return c ? json({ sub: c.sub, name: c.name, email: c.email, preferred_username: c.name, ...mark }) : json({ error: 'invalid_token' }, 401)
}
if (path === '/v1/iam/oauth/revoke') {
seen.revoked.push(new URLSearchParams(req.postData() ?? ''))
return route.fulfill({ status: 200, headers: cors(route), body: '' })
}
if (path === '/v1/iam/auth/identifier') {
const body = JSON.parse(req.postData() || '{}')
seen.identified.push(body)
// Answered only where a gate says what IAM knows of the address; else IAM is
// unreachable, and the sign-in carries on as it would without the answer.
const given = reply[path]
if (!given) return route.abort()
return json(typeof given === 'function' ? given(body) : given)
}
if (path === '/v1/iam/auth/methods')
return json({
status: 'ok',
data: {
password: true,
code: true,
signup: true,
// Out of the order they are offered in, so the order drawn is the panel's.
oauth: [
{ name: 'provider-github', type: 'GitHub' },
{ name: 'provider-apple', type: 'Apple' },
{ name: 'provider-google', type: 'Google' },
],
},
})
// Every other path of api.hanzo.ai's IAM is the spec's own to answer.
return route.fallback()
})
// A provider's authorize on this site's own origin (lib/auth/signin): a page,
// recorded and not followed, exactly as hanzo.id's is. Registered after the
// site's calls, so it is asked first.
const own = async (route: Route) => {
const req = route.request()
const url = new URL(req.url())
if (!req.isNavigationRequest()) return route.abort()
seen.authorize.push(url)
const back = authorize?.(url)
return back ? route.fulfill({ status: 302, headers: { location: back }, body: '' }) : route.abort()
}
if (site)
await on.route(
(url) => url.origin === site && url.pathname.startsWith('/v1/iam/'),
async (route) => {
const req = route.request()
const url = new URL(req.url())
seen.posted.push({ path: url.pathname, type: req.headers()['content-type'] ?? '', body: req.postData() ?? '', url })
const given = reply[url.pathname]
const answer =
typeof given === 'function'
? given(JSON.parse(req.postData() || '{}'))
: (given ?? { status: 'ok', data: url.pathname === '/v1/iam/login' ? 'c0de' : null })
await route.fulfill({ status: answer.status === 'error' ? 400 : 200, contentType: 'application/json', body: JSON.stringify(answer) })
},
)
await on.route((url) => url.pathname === AUTHORIZE && url.origin !== ID && url.origin !== API, own)
return seen
}
/** What `host` was asked, as `METHOD origin/path`; a page the browser was sent to is `GO`. */
export function asked(seen: Seen, host = ID): string[] {
return seen.asked.filter((a) => a.url.origin === host).map((a) => `${a.navigation ? 'GO' : a.method} ${a.url.origin}${a.url.pathname}`)
}
/**
* The calls still made to hanzo.id. @hanzo/app addresses a signed-in person's
* account row (lib/account) and appearance (`Look`, through @hanzo/appearance)
* at `where().iam`, the one address it also links IAM's own pages under —
* /account, /join — so both follow the pages and not api.hanzo.ai. Named, so
* that anything else asked of hanzo.id fails.
*/
const ROOMS = ['/v1/iam/account', '/v1/iam/preferences'].map((path) => `${ID}${path}`)
/**
* THE CONTRACT: hanzo.id is the authorize page. Discovery, the methods, the
* token and userinfo are asked of api.hanzo.ai, the credential calls and
* sign-out of this origin, and no page of hanzo.id is visited for a sign-out.
*/
export function contract(seen: Seen): void {
expect(
asked(seen).filter((a) => a !== `GO ${ID}${AUTHORIZE}` && !ROOMS.includes(a.split(' ')[1])),
'hanzo.id is a page the browser is sent to for authorize, and is asked nothing else',
).toEqual([])
}
/** A sign-in finished without visiting hanzo.id: the code exchanged and the person read at api.hanzo.ai. */
export function exchanged(seen: Seen): void {
contract(seen)
expect(seen.authorize, 'no authorize page').toEqual([])
expect(asked(seen, API), 'the code is exchanged and the person read at api.hanzo.ai').toEqual(
expect.arrayContaining([`POST ${API}/v1/iam/oauth/token`, `GET ${API}/v1/iam/oauth/userinfo`]),
)
}
/**
* The authorize the browser was sent to is an authorization-code + PKCE round
* trip at `at` — hanzo.id, or for a provider this site itself — that returns to
* `base`'s callback; polled, because the sign-in reads discovery before it
* navigates.
*/
export async function pkce(seen: Seen, base: string, at = ID): Promise<URL> {
await expect
.poll(() => seen.authorize.length, {
timeout: 15000,
message: `no authorize request left the browser — nothing started a sign-in at ${at}`,
})
.toBeGreaterThan(0)
const url = seen.authorize.at(-1)!
expect(url.origin, at === ID ? 'the authorize page is hanzo.id, where discovery puts it' : 'a provider’s authorize is on this site').toBe(at)
expect(url.pathname, 'the canonical authorize endpoint discovery advertises').toBe(AUTHORIZE)
expect(
[...new Set(seen.asked.filter((a) => a.url.pathname.endsWith('/.well-known/openid-configuration')).map((a) => a.url.href))],
'discovery was asked of api.hanzo.ai, at the address the SDK reads it from',
).toEqual([`${API}/.well-known/openid-configuration`])
const q = url.searchParams
expect(q.get('response_type'), 'authorization code, never an implicit token').toBe('code')
expect(q.get('client_id'), 'the client this host authenticates as').toBe('hanzo-app')
// PKCE is the reason this is safe to run in a browser with no secret: the
// challenge binds the code to this browser. Without S256 it is a bare code
// flow and an intercepted code is enough to take the session.
expect(q.get('code_challenge_method'), 'PKCE must be S256').toBe('S256')
expect(q.get('code_challenge'), 'PKCE challenge present').toBeTruthy()
expect(q.get('state'), 'state present — this is the CSRF binding').toBeTruthy()
expect(q.get('scope') ?? '', 'scope').toContain('openid')
// Derived from the host being served, which is what keeps redirect_uri and
// client_id from drifting apart across the several hosts this export answers on.
expect(q.get('redirect_uri'), 'returns to this origin, at the callback route').toBe(`${base}/auth/callback`)
return url
}