mirror of
https://github.com/hanzoai/kms.git
synced 2026-10-17 11:18:16 +00:00
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:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user