mirror of
https://github.com/hanzo-apps/tabs.git
synced 2026-09-15 19:32:29 +00:00
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:
-25
@@ -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
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user