The console signs in on hanzo.ai, carrying hanzo-kms's own request

Signed out, the console no longer draws "Sign in with hanzo.id". It has
the SDK mint hanzo-kms's PKCE request (its callback, an S256 challenge,
no nonce) and replaces the page with hanzo.ai/login plus that query.
hanzo.ai finishes it and the callback lands on the page that was asked
for.

Discovery is read from api.hanzo.ai and the token, revoke and logout
calls stay on this origin's /v1/iam, so nothing calls hanzo.id. Sign-out
ends the session here with the SDK's end(), which @hanzo/iam 0.21.31
has. The Status page stops printing the issuer.
This commit is contained in:
2026-10-16 18:37:41 -07:00
parent 8448502838
commit 5cab2f1f5a
8 changed files with 161 additions and 38 deletions
+20
View File
@@ -77,6 +77,26 @@ degrades to a yellow job and never fails the release.
they break the day the reusable workflows themselves move to `.hanzo/workflows` —
worth inlining then, not now.
## Console sign-in (`frontend/src/lib/session.ts`)
kms.hanzo.ai signs in on hanzo.ai, one way. Signed out, the console draws nothing and
leaves at once: the SDK mints its own request (`getLoginUrl`: client `hanzo-kms`,
redirect `https://kms.hanzo.ai/auth/callback`, an S256 challenge, no nonce; the verifier
stays on this origin) and the page is replaced by `https://hanzo.ai/login` + that query
(`kms.ts` `home`, `signinAddress`). hanzo.ai finishes it; `/auth/callback` redeems the
code on this origin's `/v1/iam` (the host routes `/v1` to cloud) and lands on the page
stashed under `hanzo_kms_return` (`back` keeps it on this origin). Sign-out is the SDK's
`end()` (revoke both tokens, end the session, all on this origin), then `/`, which
leaves for hanzo.ai again.
- Nothing calls or names hanzo.id: discovery is read from `https://api.hanzo.ai`, the
token calls ride `proxyBaseUrl` = this origin, and `/v1/kms/config`'s `issuer` is used
only by a brand with no `home` (it signs in at its own issuer). The CSP is universe
`static-sites.yaml` `kms-headers`, `connect-src 'self' https://api.hanzo.ai`.
- Any hanzo-org account signs in; reading secrets takes an admin of the org or a grant
there (`/v1/kms/secrets` 403 "takes an admin of the org or a grant there" otherwise).
Measured 2026-10-16 with a throwaway.
## Entry points
```
+1 -1
View File
@@ -13,7 +13,7 @@
"test": "node --test --test-reporter=tap --experimental-strip-types src/lib/kms.test.ts"
},
"dependencies": {
"@hanzo/iam": "^0.21.18",
"@hanzo/iam": "^0.21.31",
"@tanstack/react-query": "^5.62.7",
"clsx": "^2.1.1",
"react": "^19.0.0",
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@hanzo/iam':
specifier: ^0.21.18
version: 0.21.18([email protected]([email protected]))([email protected])
specifier: ^0.21.31
version: 0.21.31([email protected]([email protected]))([email protected])
'@tanstack/react-query':
specifier: ^5.62.7
version: 5.100.11([email protected])
@@ -297,8 +297,8 @@ packages:
cpu: [x64]
os: [win32]
'@hanzo/[email protected]8':
resolution: {integrity: sha512-IQNxp9xO3X5ZBizzstWPmRneMk4sV4wpMNmvpV3+u/Y0n54PITLFMdiNkJr43GeFy/YH0oRefe9HXgUd9VOghw==}
'@hanzo/[email protected].31':
resolution: {integrity: sha512-/fOLYKcCfUOQVN+LGRL7qFJU7oMqX4YgZY0PJS7pYGfzf4BK1txMCGGUpmYsKLNrdTBMDrOxDAHu/wiYLerZCA==}
engines: {node: '>=18'}
peerDependencies:
react: '>=17'
@@ -1219,7 +1219,7 @@ snapshots:
'@esbuild/[email protected]':
optional: true
'@hanzo/[email protected]8([email protected]([email protected]))([email protected])':
'@hanzo/[email protected].31([email protected]([email protected]))([email protected])':
dependencies:
jose: 6.2.12
libphonenumber-js: 1.13.13
+21 -17
View File
@@ -10,7 +10,7 @@ import { SecretsPage } from '@/pages/Secrets'
import { StatusPage } from '@/pages/Status'
// The console loads its configuration, finishes a sign-in that returned to the
// callback path, and shows either the sign-in screen or the pages.
// callback path, and shows the pages, or leaves for sign-in.
export function App() {
const config = useQuery({ queryKey: ['config'], queryFn: fetchConfig, staleTime: Infinity })
@@ -48,7 +48,7 @@ function Console({ config }: { config: Config }) {
}, [config])
if (!ready) return <Centered>Signing in</Centered>
if (!signedIn()) return <SignIn product={product} issuer={config.issuer} error={error} />
if (!signedIn()) return <SignIn product={product} error={error} />
return (
<Shell product={product} user={who()} onSignOut={() => void signOut()}>
@@ -69,20 +69,29 @@ function Console({ config }: { config: Config }) {
)
}
function SignIn({ product, issuer, error }: { product: string; issuer: string; error: string | null }) {
const [busy, setBusy] = useState(false)
// Signed out, the console leaves at once for the brand's sign-in page with its own
// request. Only a sign-in that failed, or could not start, stays here to say so.
let leaving = false
function SignIn({ product, error }: { product: string; error: string | null }) {
const [failed, setFailed] = useState<string | null>(null)
const host = issuer.replace(/^https?:\/\//, '')
function start() {
setBusy(true)
if (leaving) return
leaving = true
setFailed(null)
signIn().catch((e: Error) => {
leaving = false
setFailed(e.message)
setBusy(false)
})
}
useEffect(() => {
if (!error) start()
}, [error])
if (!error && !failed) return <Centered>Opening sign-in</Centered>
return (
<Centered>
<Card className="w-full max-w-sm p-6">
@@ -90,16 +99,11 @@ function SignIn({ product, issuer, error }: { product: string; issuer: string; e
<img src="/favicon.svg" alt="" className="h-7 w-7" />
<div className="text-base font-semibold text-neutral-50">{product}</div>
</div>
<p className="mb-5 text-[13px] text-neutral-400">
Your organization's secrets, sealed at rest. Sign in with your account at {host} to see them.
</p>
{(error || failed) && (
<div className="mb-4">
<Notice>{failed ?? error}</Notice>
</div>
)}
<Button variant="primary" className="w-full" onClick={start} disabled={busy}>
{busy ? 'Opening sign-in' : `Sign in with ${host}`}
<div className="mb-4">
<Notice>{failed ?? error}</Notice>
</div>
<Button variant="primary" className="w-full" onClick={start}>
Sign in again
</Button>
</Card>
</Centered>
+36 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from 'node:assert'
import { describe, it } from 'node:test'
import { cleanPath, clientId, draftBody, failure, listUrl, secretUrl, subject } from './kms.ts'
import { back, cleanPath, clientId, draftBody, failure, home, listUrl, secretUrl, signinAddress, subject } from './kms.ts'
describe('addresses', () => {
it('names the IAM client <org>-kms', () => {
@@ -50,3 +50,38 @@ describe('responses', () => {
assert.equal(subject('not-a-jwt'), '')
})
})
describe('sign-in', () => {
it('signs Hanzo in on hanzo.ai and reads IAM at the API, never hanzo.id', () => {
assert.deepEqual(home('hanzo'), { signin: 'https://hanzo.ai/login', api: 'https://api.hanzo.ai' })
assert.ok(!JSON.stringify(home('hanzo')).includes('hanzo.id'))
})
it('leaves every other brand at its own issuer', () => {
assert.equal(home('lux'), null)
assert.equal(home('zoo'), null)
assert.equal(home('constructor'), null)
assert.equal(home('__proto__'), null)
})
it("carries the console's own authorize query whole to the sign-in page", () => {
const authorize =
'https://hanzo.id/v1/iam/oauth/authorize?client_id=hanzo-kms&redirect_uri=https%3A%2F%2Fkms.hanzo.ai%2Fauth%2Fcallback&response_type=code&scope=openid+profile+email&state=S&code_challenge=C&code_challenge_method=S256'
const to = new URL(signinAddress('https://hanzo.ai/login', authorize))
assert.equal(to.origin + to.pathname, 'https://hanzo.ai/login')
assert.equal(to.searchParams.get('client_id'), 'hanzo-kms')
assert.equal(to.searchParams.get('redirect_uri'), 'https://kms.hanzo.ai/auth/callback')
assert.equal(to.searchParams.get('code_challenge'), 'C')
assert.equal(to.searchParams.get('code_challenge_method'), 'S256')
assert.equal(to.searchParams.get('state'), 'S')
assert.equal(signinAddress(null, authorize), authorize)
})
it('comes back only to a path on this origin', () => {
assert.equal(back('/status?x=1'), '/status?x=1')
assert.equal(back(null), '/')
for (const bad of ['https://evil.example/', '//evil.example/x', '/\\evil.example/x', '/\t/evil.example/x', 'status']) {
assert.equal(back(bad), '/', bad)
}
})
})
+38
View File
@@ -53,6 +53,44 @@ export function clientId(brand: string): string {
return `${brand}-kms`
}
/** Where a brand's people sign in, and where its console reads IAM's discovery.
* Only Hanzo signs in on its own site (hanzo.ai/login finishes this console's own
* request); a brand absent here signs in at its issuer, as the server names it.
* Brands never cross. */
export interface Home {
/** The page that finishes this console's authorize request. */
signin: string
/** IAM's public endpoints (discovery). */
api: string
}
const HOMES: Readonly<Record<string, Home>> = {
hanzo: { signin: 'https://hanzo.ai/login', api: 'https://api.hanzo.ai' },
}
export function home(brand: string): Home | null {
return Object.hasOwn(HOMES, brand) ? HOMES[brand] : null
}
/** The address that finishes an authorize request: the brand's sign-in page carrying
* the request's own query, or the authorize address itself where there is none. */
export function signinAddress(page: string | null, authorize: string): string {
return page ? `${page}${new URL(authorize).search}` : authorize
}
/** A same-origin path to come back to, or `/`: one leading slash and nothing the
* URL parser would read as another host. */
export function back(path: string | null): string {
if (!path || !path.startsWith('/')) return '/'
try {
const base = 'https://kms.invalid'
const u = new URL(path, base)
return u.origin === base ? `${u.pathname}${u.search}${u.hash}` : '/'
} catch {
return '/'
}
}
/** A path as the API stores it: `/a/b`, or empty for the org root. */
export function cleanPath(path: string): string {
const parts = path
+39 -11
View File
@@ -1,32 +1,60 @@
// Sign-in is Hanzo IAM: authorization code with PKCE against the issuer the
// server names in /v1/kms/config, as the brand's `<org>-kms` client.
// Sign-in is Hanzo IAM: authorization code with PKCE, as the brand's `<org>-kms`
// client. The SDK mints this console's own request (its client, its /auth/callback, an
// S256 challenge whose verifier stays on this origin) and the browser takes it to the
// brand's sign-in page: hanzo.ai/login for Hanzo, which finishes it and sends the code
// back here. The callback redeems it on this origin's /v1/iam, which the host routes to
// cloud; discovery is read from the brand's API. A brand without a sign-in page goes to
// its issuer, as /v1/kms/config names it.
import { configureIam, getSession, handleCallback, logout, startLogin } from '@hanzo/iam/browser'
import { clientId, subject, type Config } from './kms'
import { configureIam, getIam, getLoginUrl, getSession, handleCallback } from '@hanzo/iam/browser'
import { back, clientId, home, signinAddress, subject, type Config } from './kms'
export const callbackPath = '/auth/callback'
/** Where the page a sign-in started from waits for the callback. */
const RETURN_KEY = 'hanzo_kms_return'
let page: string | null = null
export function configure(config: Config): void {
const h = home(config.brand)
page = h?.signin ?? null
configureIam({
issuer: config.issuer,
issuer: h?.api ?? config.issuer,
clientId: clientId(config.brand),
redirect: `${window.location.origin}${callbackPath}`,
...(h ? { proxyBaseUrl: window.location.origin } : {}),
})
}
/** Finishes a sign-in that returned to the callback path and says where to go next. */
export async function finishSignIn(): Promise<string> {
const { redirect } = await handleCallback()
return redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/'
await handleCallback()
let to: string | null = null
try {
to = sessionStorage.getItem(RETURN_KEY)
sessionStorage.removeItem(RETURN_KEY)
} catch {
// Private mode: the console's home.
}
return back(to)
}
export function signIn(): Promise<void> {
/** Leaves for sign-in with this console's own request, to come back to this page. */
export async function signIn(): Promise<void> {
const here = `${window.location.pathname}${window.location.search}`
return startLogin({ redirect: here === callbackPath ? '/' : here })
try {
sessionStorage.setItem(RETURN_KEY, back(here === callbackPath ? '/' : here))
} catch {
// Private mode: the callback lands on the home.
}
window.location.replace(signinAddress(page, await getLoginUrl()))
}
export function signOut(): Promise<void> {
return logout()
/** Hands both tokens back and ends the session on this origin, then starts over here. */
export async function signOut(): Promise<void> {
await getIam().end()
window.location.replace('/')
}
export function signedIn(): boolean {
+1 -3
View File
@@ -5,8 +5,7 @@ import { who } from '@/lib/session'
import { Badge, Card } from '@/components/ui'
import { Failure } from '@/components/Failure'
// Status: readiness from GET /v1/kms/health, and the configuration this console
// signed in with.
// Status: readiness from GET /v1/kms/health, and who this console is signed in as.
export function StatusPage({ config }: { config: Config }) {
const health = useQuery({ queryKey: ['health'], queryFn: fetchHealth, refetchInterval: 10_000 })
@@ -44,7 +43,6 @@ export function StatusPage({ config }: { config: Config }) {
<h2 className="mb-3 text-sm font-semibold text-neutral-200">Session</h2>
<dl className="grid grid-cols-3 gap-y-1 text-[13px]">
<Field label="Signed in as" value={who()} />
<Field label="Issuer" value={config.issuer} />
<Field label="API" value={config.apiBase} />
</dl>
</Card>