Keep a session alive with the refresh grant, not a silent iframe

An access token lasts an hour. Tabs is a window you leave open all day watching
agents work, so an hour-old token is the normal case on returning to the tab —
without a renew you are dropped at a sign-in screen with panes still on screen,
and a workspace left open quietly fails every registry poll until someone
notices the machines have gone stale.

@hanzo/iam will attempt OIDC's `prompt=none` hidden iframe, and it cannot work
here: hanzo.id answers `frame-ancestors 'none'`, which is the right policy for a
login page and refuses the frame regardless of what this app's CSP allows.
Widening frame-src to permit the origin changes nothing, so it is not widened.

The refresh grant is the mechanism that does work for a public client, needs no
frame and no third-party cookie. Renewed once on load when the stored token has
aged out, and every ten minutes while the workspace is open — well inside the
hour, so the renew never races the expiry it exists to prevent.

A failed renew is not an error: an expired or revoked refresh token means the
session is simply over, and the sign-in screen is the correct thing to show.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-04 02:12:32 -07:00
co-authored by Hanzo Dev
parent 83daecee60
commit d29fbde919
2 changed files with 46 additions and 3 deletions
+19 -3
View File
@@ -18,7 +18,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { type Machine, type Session, machines, sessions } from '@/lib/api';
import { session, signIn, signOut } from '@/lib/iam';
import { renew, session, signIn, signOut } from '@/lib/iam';
import { Workspace, type TerminalHost } from '@/components/workspace';
export default function App() {
@@ -27,8 +27,17 @@ export default function App() {
const [data, setData] = useState<{ m: Machine[]; s: Session[] } | null>(null);
const [error, setError] = useState<string | null>(null);
// The session this browser already holds, renewed if the access token has
// aged out. Tabs is a window you leave open all day watching agents work, so
// an hour-old token is the normal case on returning to the tab, not an edge
// one — without the renew you would be signed out every time you came back.
useEffect(() => {
setToken(session().accessToken);
const s = session();
if (s.authenticated) {
setToken(s.accessToken);
return;
}
void renew().then((r) => setToken(r.accessToken));
}, []);
const refresh = useCallback(async (t: string) => {
@@ -49,7 +58,14 @@ export default function App() {
// Machines heartbeat every 30s and the plane calls one stale after 90s, so
// polling faster than the fact changes would only cost requests.
const t = setInterval(() => void refresh(token), 30_000);
return () => clearInterval(t);
// Renewed on its own clock, well inside the access token's hour, so a
// workspace left open keeps reading the registry instead of quietly failing
// every poll until someone notices the panes have gone stale.
const r = setInterval(() => void renew().then((s) => setToken(s.accessToken)), 10 * 60_000);
return () => {
clearInterval(t);
clearInterval(r);
};
}, [token, refresh]);
/** Machines that can serve shells, and the tunnel each one's terminals live on.
+27
View File
@@ -20,6 +20,7 @@
import {
configureIam,
getIam,
getSession,
handleCallback,
logout,
@@ -54,6 +55,32 @@ export function session() {
return getSession();
}
/**
* Renew the access token with the refresh grant, returning the live session.
*
* An access token lasts an hour and Tabs is a window you leave open all day
* watching agents work, so without this a session ends mid-afternoon and drops
* you at a sign-in screen with panes still on screen.
*
* The refresh grant, not OIDC's `prompt=none` iframe: hanzo.id sends
* `frame-ancestors 'none'`, which is the right answer for a login page and makes
* silent renew structurally unavailable no matter what this app's own CSP allows.
* A public client's refresh token is the mechanism that works, and it needs no
* third-party cookie to do it.
*
* Failure is not an error here — an expired or revoked refresh token means the
* session is simply over, and the caller shows the sign-in screen.
*/
export async function renew() {
iam();
try {
await getIam().refreshAccessToken();
} catch {
/* the session is over; getSession reports it and the caller signs in again */
}
return getSession();
}
/** Send the browser to hanzo.id. Returns only if the redirect does not happen. */
export async function signIn(returnTo = '/app') {
iam();