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