Files
zeekayandHanzo Dev d29fbde919 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]>
2026-08-04 02:12:32 -07:00

102 lines
3.3 KiB
TypeScript

/**
* Sign in with Hanzo IAM.
*
* The flow itself is `@hanzo/iam/browser` — the SDK every Hanzo app signs in
* with. This module only *configures* it, so Tabs owns an issuer and a client
* id and nothing else: no PKCE, no token store, no endpoint literals. Those are
* the parts an app gets subtly wrong and then keeps wrong, and the parts that
* have to change in lockstep with IAM.
*
* Endpoints come from OIDC discovery rather than being written down here, which
* is why there is no path in this file to drift.
*
* Tabs is a PUBLIC client: a static site cannot keep a secret, so PKCE is what
* replaces one — the browser proves it started the exchange by presenting the
* verifier whose hash it sent up front. The registration must therefore carry no
* client secret (`public: true` in provision.yaml); a stored secret the browser
* cannot present is what makes the token endpoint demand client auth and fail
* the exchange.
*/
import {
configureIam,
getIam,
getSession,
handleCallback,
logout,
startLogin,
} from '@hanzo/iam/browser';
/** The brand identity origin. One identity for everything Hanzo. */
export const ISSUER = 'https://hanzo.id';
/** `<org>-<app>`, registered in hanzoai/universe `infra/k8s/iam/provision.yaml`. */
export const CLIENT_ID = 'hanzo-tabs';
let ready = false;
/**
* Configure the singleton, once, in the browser.
*
* Deferred rather than run at module scope because this is a statically exported
* site: module scope also runs during the export build, where there is no
* `window` and no storage to configure against.
*/
export function iam() {
if (!ready) {
configureIam({ issuer: ISSUER, clientId: CLIENT_ID, scope: 'openid profile email' });
ready = true;
}
}
/** Whether this browser holds a live session. */
export function session() {
iam();
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();
await startLogin({ redirect: returnTo });
}
/** Finish the exchange on the callback route. Returns where to go next. */
export async function complete(): Promise<string> {
iam();
const { redirect } = await handleCallback();
return redirect || '/app';
}
/** RP-initiated logout, and the local session with it. */
export async function signOut() {
iam();
await logout();
}