Files
hanzo.ai/lib/auth/impersonation.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

128 lines
5.3 KiB
TypeScript

/**
* Support sessions: a SuperAdmin signed in AS a person, to see what they see.
*
* IAM marks every such token, and its userinfo, `imp: true`, names the operator in
* `act {sub, owner, name}`, caps it at fifteen minutes and issues it no refresh token
* (hanzoai/iam internal/oidc/impersonate.go). A site is reached through OIDC Core §4
* third-party-initiated login: `/auth/initiate?iss=<issuer>&login_hint=impersonation:<id>`.
*
* The same bytes in hanzo.ai (lib/auth) and platform.hanzo.ai (src/lib/auth), and no
* React, so the file moves into @hanzo/iam unchanged.
*/
import { OIDC_PATHS, trimServerUrl } from '@hanzo/iam/paths'
/** The initiate-login address IAM's support flow sends a browser to. */
export const INITIATE_PATH = '/auth/initiate'
/** What every hint IAM issues for a support session begins with (impersonate.go `hintPrefix`). */
export const HINT_PREFIX = 'impersonation:'
/** A support session: who it is, who opened it, and until when. */
export interface Impersonation {
/** The person signed in as: the `name` claim, else the email. */
name: string
/** The operator, `<act.owner>/<act.name>`. */
operator: string
/** Epoch ms the token expires at, or null when it states none. */
expires: number | null
}
const text = (v: unknown): string => (typeof v === 'string' ? v : '')
/**
* The support session `claims` (a token's or userinfo's) describe, or null.
*
* `imp: true` alone decides, so the mark and the no-refresh rule hold whenever IAM
* says so. `act` only names the operator: IAM also sets it for an org key, so `act`
* without `imp` is an ordinary session.
*/
export function impersonated(claims: unknown): Impersonation | null {
if (!claims || typeof claims !== 'object') return null
const c = claims as Record<string, unknown>
if (c.imp !== true) return null
const act = c.act && typeof c.act === 'object' ? (c.act as Record<string, unknown>) : {}
const owner = text(act.owner)
const who = text(act.name)
return {
name: text(c.name) || text(c.email),
operator: owner && who ? `${owner}/${who}` : 'an operator',
expires: typeof c.exp === 'number' ? c.exp * 1000 : null,
}
}
/** The banner's sentence. */
export function banner(i: Impersonation): string {
return `Signed in as ${i.name} by ${i.operator} — support session`
}
/** What an initiate-login request asks for: a support sign-in carrying `hint`, or a refusal. */
export type Initiation = { hint: string } | { refused: string }
/**
* Read an initiate-login query. It is a support sign-in or nothing: exactly one `iss`,
* equal to the issuer this site signs in at, and exactly one `login_hint`, an
* impersonation hint, passed on as written. Anything else is refused, so a crafted
* link cannot start a sign-in as whoever the issuer already holds. A refusal never
* repeats the hint.
*/
export function initiation(search: string, issuer: string): Initiation {
const q = new URLSearchParams(search)
const iss = q.getAll('iss')
if (iss.length !== 1 || iss[0] !== issuer) return { refused: 'This sign-in link is not for this site.' }
const hints = q.getAll('login_hint')
if (hints.length !== 1 || !hints[0].startsWith(HINT_PREFIX) || hints[0].length === HINT_PREFIX.length)
return { refused: 'This sign-in link is not a support sign-in.' }
return { hint: hints[0] }
}
/**
* The issuer identifier of the OP at `serverUrl`: its discovery document's `issuer`,
* else `serverUrl` itself, which is the SDK's own fallback.
*/
export async function issuer(serverUrl: string): Promise<string> {
const base = trimServerUrl(serverUrl)
try {
const res = await fetch(`${base}${OIDC_PATHS.discovery}`, { headers: { Accept: 'application/json' } })
const doc = res.ok ? ((await res.json()) as { issuer?: unknown }) : null
if (typeof doc?.issuer === 'string' && doc.issuer) return doc.issuer
} catch {
/* unreachable: the fallback below */
}
return base
}
/** Longest a revocation is waited on; `keepalive` carries it past a navigation. */
const REVOKE_WAIT_MS = 1500
/**
* Hand a support session's access token back to IAM (RFC 7009), at the host the
* SDK's own sign-out revokes at (`proxyBaseUrl`, else `serverUrl`). Never throws, and
* resolves within REVOKE_WAIT_MS whether or not IAM answered.
*/
export async function revoke(
token: string,
iam: { serverUrl: string; proxyBaseUrl?: string; clientId: string },
): Promise<void> {
const send = fetch(`${trimServerUrl(iam.proxyBaseUrl ?? iam.serverUrl)}${OIDC_PATHS.revoke}`, {
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Bearer ${token}` },
body: new URLSearchParams({ token, token_type_hint: 'access_token', client_id: iam.clientId }).toString(),
}).catch(() => undefined)
await Promise.race([send, new Promise((done) => setTimeout(done, REVOKE_WAIT_MS))])
}
/**
* Drop a stored refresh token. IAM issues a support session none, and @hanzo/iam
* 0.21.28 `storeTokens` writes one only when one is answered, so the earlier
* session's stays beside the new access token and the next refresh would mint that
* earlier person's token. Without it the session ends at its own expiry.
*/
export function isolate(): void {
try {
window.localStorage.removeItem('hanzo_iam_refresh_token')
} catch {
/* no store: nothing kept */
}
}