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