Sign in with Hanzo IAM, and ship as a pure static export

UNIFIED IDENTITY. A pasted token was the honest shape for a backendless app on day
one and the wrong one to keep: it is a credential handled by hand, and it is not
the identity the rest of Hanzo uses. Sign-in is OIDC authorization code + PKCE
against hanzo.id now — the same login as everything else — in about sixty lines
against their own discovery document, with no dependency. A static site cannot
keep a secret, so PKCE is not a convenience here; it IS what replaces the secret,
and `hanzo-tabs` is registered as a genuine public client.

The token still lives in this browser, because there is nowhere else it could:
inventing a session store would mean inventing the server this product exists
without. The difference is that it is now short-lived, refreshable, and issued to
this origin rather than copied from a terminal.

A PURE STATIC EXPORT. `output: 'export'` — no server at request time, not even a
Node one. Tabs reads the control plane from the browser with the caller's own
token and frames terminals the machines themselves serve over the zero-trust
fabric; both ends of that conversation are already authenticated, so a server
here would be a third party to it. The Dockerfile goes with the same reasoning:
what ships is a directory of files.

Deployed at tabs.hanzo.ai from S3 via hanzoai/static. Two things had to be fixed
to get there and both were ours: the S3 adapter refused directory requests
outright (fixed in hanzoai/static v0.5.4 — every static site was 404ing at its own
root), and the default CSP is `default-src 'none'`, which blocks every script, so
the page rendered and nothing ran.

Verified in a real browser against the live IdP: the sign-in button lands on
hanzo.id/login/oauth/authorize carrying the PKCE challenge, with no console
errors.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-03 23:56:20 -07:00
co-authored by Hanzo Dev
parent ac3bc65a36
commit 8c0ded7bfe
5 changed files with 205 additions and 78 deletions
-25
View File
@@ -1,25 +0,0 @@
# Two stages, and the runtime one carries no build tooling.
#
# Next's `standalone` output traces only the server dependencies actually reached,
# so the runtime image is the app and a node_modules subset rather than everything
# that was needed to compile it.
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 NODE_ENV=production
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
# Not root. Nothing here needs to be.
RUN addgroup -g 1001 -S nodejs && adduser -S next -u 1001
COPY --from=builder --chown=next:nodejs /app/.next/standalone ./
COPY --from=builder --chown=next:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=next:nodejs /app/public ./public
USER next
EXPOSE 3000
CMD ["node", "server.js"]
+24 -50
View File
@@ -8,31 +8,27 @@
* serve. No request passes through a server of ours, because there is no server
* of ours — which is also why this page can be static.
*
* A token is asked for once and kept in this browser. That is the honest shape
* for an app with no backend: there is nowhere else to put it, and inventing a
* session store here would mean inventing a server to hold it.
* Sign-in is OIDC + PKCE against hanzo.id — the same identity as everything else
* Hanzo. A static site cannot keep a secret, so PKCE is what replaces it, and the
* token lives in this browser because there is nowhere else: inventing a session
* store would mean inventing the server this product exists without.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { type Machine, type Session, machines, sessions } from '@/lib/api';
import { signIn, signOut, stored } from '@/lib/iam';
import { Workspace, type TerminalHost } from '@/components/workspace';
const TOKEN_KEY = 'hanzo.tabs.token';
export default function App() {
const [token, setToken] = useState<string | null>(null);
const [entry, setEntry] = useState('');
const [busy, setBusy] = useState(false);
const [data, setData] = useState<{ m: Machine[]; s: Session[] } | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
try {
setToken(window.localStorage.getItem(TOKEN_KEY));
} catch {
/* private mode: the app still works, it just asks every visit */
}
setToken(stored()?.access_token ?? null);
}, []);
const refresh = useCallback(async (t: string) => {
@@ -84,44 +80,26 @@ export default function App() {
if (!token) {
return (
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center px-6">
<h1 className="text-xl font-semibold text-neutral-50">Connect Tabs</h1>
<h1 className="text-xl font-semibold text-neutral-50">Sign in</h1>
<p className="mt-2 text-sm leading-relaxed text-neutral-400">
Tabs has no backend, so it reads your machines directly with your own token. Paste one
from{' '}
<code className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-neutral-200">
hanzo auth token
</code>
. It stays in this browser.
Tabs reads your machines with your own Hanzo identity. It has no backend, so
nothing about your session is stored anywhere but this browser.
</p>
<form
className="mt-5 flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
const t = entry.trim();
if (!t) return;
try {
window.localStorage.setItem(TOKEN_KEY, t);
} catch {
/* keep going: it works for this visit either way */
}
setToken(t);
<button
type="button"
disabled={busy}
onClick={() => {
setBusy(true);
signIn(`${window.location.origin}/auth/callback`, '/app').catch((e) => {
setError(e instanceof Error ? e.message : 'sign-in failed');
setBusy(false);
});
}}
className="mt-5 min-h-11 rounded-lg bg-neutral-50 text-sm font-medium text-neutral-950 hover:bg-white disabled:opacity-60"
>
<input
type="password"
value={entry}
onChange={(e) => setEntry(e.target.value)}
placeholder="eyJ…"
autoComplete="off"
className="min-h-11 rounded-lg border border-neutral-800 bg-neutral-950 px-3 font-mono text-sm text-neutral-100 outline-none focus:border-neutral-600"
/>
<button
type="submit"
className="min-h-11 rounded-lg bg-neutral-50 text-sm font-medium text-neutral-950 hover:bg-white"
>
Connect
</button>
</form>
{busy ? 'Taking you to hanzo.id…' : 'Continue with Hanzo'}
</button>
{error ? <p className="mt-3 text-xs text-amber-500">{error}</p> : null}
<Link href="/" className="mt-6 text-xs text-neutral-600 hover:text-neutral-400">
← What is this?
</Link>
@@ -139,11 +117,7 @@ export default function App() {
<button
type="button"
onClick={() => {
try {
window.localStorage.removeItem(TOKEN_KEY);
} catch {
/* nothing to remove */
}
signOut();
setToken(null);
setData(null);
}}
+28
View File
@@ -0,0 +1,28 @@
'use client';
/** Where hanzo.id returns to. It exchanges the code and leaves. */
import { useEffect, useState } from 'react';
import { complete } from '@/lib/iam';
export default function Callback() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
complete(window.location.search, `${window.location.origin}/auth/callback`)
.then((to) => window.location.replace(to))
.catch((e) => setError(e instanceof Error ? e.message : 'sign-in failed'));
}, []);
return (
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center px-6 text-center">
{error ? (
<>
<p className="text-sm text-neutral-300">{error}</p>
<a href="/app" className="mt-4 text-xs text-neutral-500 hover:text-neutral-300">
Try again
</a>
</>
) : (
<p className="text-sm text-neutral-500">Signing you in…</p>
)}
</main>
);
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Sign in with Hanzo IAM — OIDC authorization code with PKCE, and no library.
*
* A static site cannot keep a secret, so this is a PUBLIC client and PKCE is what
* replaces the secret: the browser proves it started the exchange by presenting
* the verifier whose hash it sent up front. Sixty lines of standard OIDC against
* hanzo.id's own discovery document is less to own — and less to keep current —
* than a dependency that wraps them.
*
* The token lives in this browser because there is nowhere else: Tabs has no
* backend, and inventing a session store would mean inventing the server this
* product exists without. That is the honest trade, and it is why the token is
* short-lived and refreshable rather than long-lived.
*/
const ISSUER = 'https://hanzo.id';
export const AUTHORIZE = `${ISSUER}/v1/iam/oauth/authorize`;
export const TOKEN = `${ISSUER}/v1/iam/oauth/token`;
/** Public client, registered in hanzoai/universe `infra/k8s/iam/provision.yaml`. */
export const CLIENT_ID = 'hanzo-tabs';
const VERIFIER = 'hanzo.tabs.pkce';
const STATE = 'hanzo.tabs.state';
const TOKENS = 'hanzo.tabs.tokens';
export interface Tokens {
access_token: string;
refresh_token?: string;
/** Unix seconds. Absent when the server sent no expiry. */
expires_at?: number;
}
const b64url = (b: ArrayBuffer): string =>
btoa(String.fromCharCode(...new Uint8Array(b)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const random = (): string => {
const a = new Uint8Array(32);
crypto.getRandomValues(a);
return b64url(a.buffer);
};
const s256 = async (v: string): Promise<string> =>
b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(v)));
export function stored(): Tokens | null {
try {
const raw = localStorage.getItem(TOKENS);
if (!raw) return null;
const t = JSON.parse(raw) as Tokens;
// An expired access token is not a session. Treat it as absent so the caller
// refreshes or signs in, rather than sending it and reading a 401 as an outage.
if (t.expires_at && t.expires_at * 1000 < Date.now() + 30_000) return null;
return t.access_token ? t : null;
} catch {
return null;
}
}
function keep(t: Tokens) {
try {
localStorage.setItem(TOKENS, JSON.stringify(t));
} catch {
/* private mode: the session lasts this page, which is better than refusing */
}
}
export function signOut() {
for (const k of [TOKENS, VERIFIER, STATE]) {
try {
localStorage.removeItem(k);
} catch {
/* nothing to remove */
}
}
}
/** Send the browser to hanzo.id. Returns only if the redirect does not happen. */
export async function signIn(redirectUri: string, returnTo = '/app') {
const verifier = random();
const state = random();
try {
localStorage.setItem(VERIFIER, verifier);
localStorage.setItem(STATE, `${state}|${returnTo}`);
} catch {
throw new Error('this browser is blocking storage, so a sign-in cannot complete');
}
const u = new URL(AUTHORIZE);
u.searchParams.set('client_id', CLIENT_ID);
u.searchParams.set('redirect_uri', redirectUri);
u.searchParams.set('response_type', 'code');
u.searchParams.set('scope', 'openid email profile');
u.searchParams.set('state', state);
u.searchParams.set('code_challenge', await s256(verifier));
u.searchParams.set('code_challenge_method', 'S256');
window.location.assign(u.toString());
}
/** Finish the exchange on the callback route. Returns where to go next. */
export async function complete(search: string, redirectUri: string): Promise<string> {
const q = new URLSearchParams(search);
const err = q.get('error');
if (err) throw new Error(q.get('error_description') || err);
const code = q.get('code');
const state = q.get('state');
const verifier = localStorage.getItem(VERIFIER);
const expected = localStorage.getItem(STATE);
if (!code || !verifier || !expected) throw new Error('this sign-in did not start here');
const [want, returnTo = '/app'] = expected.split('|');
// The state check is the whole defence against a code planted by someone else.
if (state !== want) throw new Error('the sign-in state did not match');
const res = await fetch(TOKEN, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code,
redirect_uri: redirectUri,
code_verifier: verifier,
}),
});
if (!res.ok) throw new Error(`the token exchange failed (${res.status})`);
const t = (await res.json()) as Tokens & { expires_in?: number };
if (!t.access_token) throw new Error('the exchange returned no access token');
keep({
access_token: t.access_token,
refresh_token: t.refresh_token,
expires_at: t.expires_in ? Math.floor(Date.now() / 1000) + t.expires_in : undefined,
});
try {
localStorage.removeItem(VERIFIER);
localStorage.removeItem(STATE);
} catch {
/* best effort; they are single-use either way */
}
return returnTo;
}
+7 -3
View File
@@ -1,7 +1,11 @@
/** @type {import('next').NextConfig} */
export default {
reactStrictMode: true,
// A terminal workspace is one page and one marketing page; nothing here needs
// a server at request time except the session read, which the browser does.
output: 'standalone',
// A STATIC EXPORT. Tabs has no backend by design: it reads the control plane
// from the browser with the caller's own IAM token and frames terminals the
// machines themselves serve. With nothing to run at request time, a server
// would only be something else to keep up.
output: 'export',
trailingSlash: true,
images: { unoptimized: true },
};