diff --git a/electron/main.ts b/electron/main.ts index ef75d19..53de6ae 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -38,10 +38,6 @@ function clearTokens(): void { try { fs.unlinkSync( tokenFile() ); } catch { /* already gone */ } } -function passwordFile(): string { - return path.join( app.getPath( 'userData' ), 'last-password.txt' ); -} - function loadLastEmail(): string { try { return fs.readFileSync( emailFile(), 'utf-8' ).trim(); } catch { return ''; } } @@ -50,17 +46,8 @@ function saveLastEmail( email: string ): void { fs.writeFileSync( emailFile(), email, 'utf-8' ); } -function loadLastPassword(): string { - try { return fs.readFileSync( passwordFile(), 'utf-8' ).trim(); } catch { return ''; } -} - -function saveLastPassword( password: string ): void { - fs.writeFileSync( passwordFile(), password, 'utf-8' ); -} - function clearCredentials(): void { - try { fs.unlinkSync( emailFile() ); } catch { /* already gone */ } - try { fs.unlinkSync( passwordFile() ); } catch { /* already gone */ } + try { fs.unlinkSync( emailFile() ); } catch { /* already gone */ } } function localRecentsFile(): string { @@ -92,9 +79,27 @@ function removeLocalRecent( folderPath: string ): string[] { return recents; } +// ── Clock offset ─────────────────────────────────────────────────────────────── +// Electron holds the access token directly, so expiry comparisons must use +// server-authoritative time, not the local clock (which can be significantly wrong). +// We read the Date header from the first auth-server response and cache the offset. + +let _serverClockOffsetMs: number | null = null; + +function updateClockOffset( dateHeader: string | undefined ): void { + if ( _serverClockOffsetMs !== null || !dateHeader ) return; + const serverMs = new Date( dateHeader ).getTime(); + if ( isNaN( serverMs ) ) return; + _serverClockOffsetMs = serverMs - Date.now(); +} + +function serverNow(): number { + return Date.now() + ( _serverClockOffsetMs ?? 0 ); +} + // ── Network ──────────────────────────────────────────────────────────────────── -function postJson( url: string, body: unknown ): Promise { +function postJson( url: string, body: unknown, extraHeaders?: Record ): Promise { return new Promise( ( resolve, reject ) => { const data = JSON.stringify( body ); const parsed = new URL( url ); @@ -106,9 +111,11 @@ function postJson( url: string, body: unknown ): Promise { headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength( data ), + ...( extraHeaders ?? {} ), }, }, ( res ) => { + updateClockOffset( res.headers.date ); let raw = ''; res.on( 'data', ( chunk: string ) => { raw += chunk; } ); res.on( 'end', () => { @@ -123,6 +130,127 @@ function postJson( url: string, body: unknown ): Promise { } ); } +// ── Electron token updater ───────────────────────────────────────────────────── +// Checks the access token's expiry (decoded from the JWT payload) against +// server-corrected time every 5 minutes. Refreshes proactively when within +// 15 minutes of expiry — matching the server-side PROACTIVE_REFRESH_MARGIN_SEC. +// On refresh failure: network errors are silently retried next tick; +// auth failures (revoked / expired refresh token) close the main window and +// show the login screen. + +const REFRESH_MARGIN_MS = 15 * 60 * 1000; +const UPDATER_INTERVAL_MS = 5 * 60 * 1000; + +function decodeJwtPayload( token: string ): Record | null { + try { + const parts = token.split( '.' ); + if ( parts.length !== 3 ) return null; + const json = Buffer.from( + parts[ 1 ].replace( /-/g, '+' ).replace( /_/g, '/' ), + 'base64' + ).toString( 'utf-8' ); + return JSON.parse( json ) as Record; + } catch { return null; } +} + +function tokenExpMs( token: string ): number | null { + const payload = decodeJwtPayload( token ); + if ( !payload || typeof payload.exp !== 'number' ) return null; + return payload.exp * 1000; +} + +async function refreshCurrentTokens(): Promise { + const tokens = currentTokens; + if ( !tokens ) return; + try { + const result = await postJson( + `${ AUTH_HOST }/api/auth/refresh`, + { refreshToken: tokens.refreshToken } + ) as Record; + if ( result.accessToken && result.refreshToken ) { + currentTokens = { + accessToken: result.accessToken as string, + refreshToken: result.refreshToken as string, + }; + saveTokens( currentTokens ); + } else { + clearTokens(); + currentTokens = null; + mainWindow?.close(); + createLoginWindow(); + } + } catch { + // network error — keep current tokens and retry next tick + } +} + +async function checkAndRefreshIfDue(): Promise { + const tokens = currentTokens; + if ( !tokens ) return; + const expMs = tokenExpMs( tokens.accessToken ); + if ( expMs === null ) return; + if ( expMs - serverNow() < REFRESH_MARGIN_MS ) { + await refreshCurrentTokens(); + } +} + +function startElectronTokenUpdater(): void { + setInterval( () => void checkAndRefreshIfDue(), UPDATER_INTERVAL_MS ); + setInterval( () => writeHeartbeat(), HEARTBEAT_WRITE_INTERVAL_MS ); +} + +// ── Session heartbeat ─────────────────────────────────────────────────────────── +// A running instance writes its current access token + timestamp every 10 s. +// A newly-starting instance reads this file on launch: if ≤ 30 s old it calls +// POST /api/auth/new-session (requireAuth-guarded) to mint its own independent +// token pair, avoiding the login screen when at least one other instance is live. +// This replaces the plaintext last-password.txt auto-login that was here before. + +const HEARTBEAT_WRITE_INTERVAL_MS = 10_000; +const HEARTBEAT_MAX_AGE_MS = 30_000; + +interface Heartbeat { accessToken: string; timestamp: number; } + +function heartbeatFile(): string { + return path.join( app.getPath( 'userData' ), 'session-heartbeat.json' ); +} + +function writeHeartbeat(): void { + const tokens = currentTokens; + if ( !tokens ) return; + const hb: Heartbeat = { accessToken: tokens.accessToken, timestamp: Date.now() }; + try { fs.writeFileSync( heartbeatFile(), JSON.stringify( hb ), 'utf-8' ); } catch { /* ignore */ } +} + +function loadHeartbeat(): Heartbeat | null { + try { + const raw = fs.readFileSync( heartbeatFile(), 'utf-8' ); + return JSON.parse( raw ) as Heartbeat; + } catch { return null; } +} + +async function tryHeartbeatLogin(): Promise { + const hb = loadHeartbeat(); + if ( !hb ) return false; + if ( Date.now() - hb.timestamp > HEARTBEAT_MAX_AGE_MS ) return false; + try { + const result = await postJson( + `${ AUTH_HOST }/api/auth/new-session`, + {}, + { Authorization: `Bearer ${ hb.accessToken }` } + ) as Record; + if ( result.accessToken && result.refreshToken ) { + currentTokens = { + accessToken: result.accessToken as string, + refreshToken: result.refreshToken as string, + }; + saveTokens( currentTokens ); + return true; + } + } catch { /* network error — fall through to login screen */ } + return false; +} + // ── Header injection ─────────────────────────────────────────────────────────── function registerHeaderInjector( getToken: () => string | null ): void { @@ -222,20 +350,17 @@ function startExpressServer(): void { // ── App lifecycle ────────────────────────────────────────────────────────────── app.whenReady().then( () => { + startElectronTokenUpdater(); registerHeaderInjector( () => currentTokens?.accessToken ?? null ); ipcMain.handle( 'auth:login', async ( _event, email: string, password: string, remember: boolean ) => { try { - const result = await postJson( `${AUTH_HOST}/api/auth/login`, { email, password } ) as Record; + const result = await postJson( `${ AUTH_HOST }/api/auth/login`, { email, password } ) as Record; if ( result.accessToken && result.refreshToken ) { currentTokens = { accessToken: result.accessToken as string, refreshToken: result.refreshToken as string }; saveTokens( currentTokens ); - if ( remember ) { - saveLastEmail( email ); - saveLastPassword( password ); - } else { - clearCredentials(); - } + if ( remember ) saveLastEmail( email ); + else clearCredentials(); return { ok: true }; } return { ok: false, error: ( result.error as string ) ?? 'Login failed' }; @@ -244,8 +369,7 @@ app.whenReady().then( () => { } } ); - ipcMain.handle( 'auth:last-email', () => loadLastEmail() ); - ipcMain.handle( 'auth:last-password', () => loadLastPassword() ); + ipcMain.handle( 'auth:last-email', () => loadLastEmail() ); ipcMain.handle( 'auth:clear-credentials', () => { clearCredentials(); } ); ipcMain.handle( 'local:open-folder', async () => { @@ -277,7 +401,7 @@ app.whenReady().then( () => { if ( currentTokens ) { try { const result = await postJson( - `${AUTH_HOST}/api/auth/refresh`, + `${ AUTH_HOST }/api/auth/refresh`, { refreshToken: currentTokens.refreshToken } ) as Record; if ( result.accessToken && result.refreshToken ) { @@ -295,7 +419,11 @@ app.whenReady().then( () => { createLoginWindow(); } } else { - createLoginWindow(); + // No saved tokens — try to mint a new independent session from a running instance's + // heartbeat before falling through to the login screen. + const gotSession = await tryHeartbeatLogin(); + if ( gotSession ) createMainWindow(); + else createLoginWindow(); } }, 500 ); diff --git a/electron/preload.ts b/electron/preload.ts index e15c084..d19cfda 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -7,8 +7,6 @@ contextBridge.exposeInMainWorld( 'electronAuth', { ipcRenderer.send( 'auth:login-success' ), lastEmail: () => ipcRenderer.invoke( 'auth:last-email' ), - lastPassword: () => - ipcRenderer.invoke( 'auth:last-password' ), clearCredentials: () => ipcRenderer.invoke( 'auth:clear-credentials' ), } ); diff --git a/source/auth-connector b/source/auth-connector index cb2fba6..7fa8811 160000 --- a/source/auth-connector +++ b/source/auth-connector @@ -1 +1 @@ -Subproject commit cb2fba6e8b4a7c8ed236113cd40084e100df4021 +Subproject commit 7fa88118960df0fa876594cf306b3a1766634245 diff --git a/source/auth/GuardedCall.ts b/source/auth/GuardedCall.ts new file mode 100644 index 0000000..5c252b0 --- /dev/null +++ b/source/auth/GuardedCall.ts @@ -0,0 +1,118 @@ +import { TokenUpdater, AuthState } from './TokenUpdater.js'; +import { sleep } from '../library-ts/browser/animation/sleep.js'; + +export type CallTier = 'user' | 'editor' | 'silent'; + +const REFRESHING_WAIT_MS = 6_000; +const RETRY_DELAYS_MS: Record<'editor' | 'silent', number[]> = { + editor: [ 1_000, 3_000, 8_000 ], + silent: [ 1_000, 3_000, 8_000 ], +}; + +export class GuardedCallError extends Error +{ + constructor( message: string, readonly tier: CallTier ) + { + super( message ); + this.name = 'GuardedCallError'; + } +} + +export class GuardedCall +{ + private static _instance: GuardedCall | null = null; + + static init( updater: TokenUpdater ): GuardedCall + { + this._instance = new GuardedCall( updater ); + return this._instance; + } + + static get(): GuardedCall + { + if ( !this._instance ) throw new Error( 'GuardedCall not initialized' ); + return this._instance; + } + + constructor( private readonly _updater: TokenUpdater ) {} + + async call( tier: CallTier, fn: () => Promise ): Promise + { + try { await this._preflight( tier ); } + catch ( e ) + { + if ( tier !== 'silent' ) throw e; + console.warn( '[auth] guarded call skipped — session expired', e ); + return new Response( null, { status: 0 } ); + } + + return this._executeWithRetry( tier, fn ); + } + + private async _preflight( tier: CallTier ): Promise + { + const state = this._updater.state; + if ( state === 'expired' ) throw new GuardedCallError( 'Session expired', tier ); + if ( state === 'refreshing' ) await this._waitForNotRefreshing( tier ); + } + + private _waitForNotRefreshing( tier: CallTier ): Promise + { + return new Promise( ( resolve, reject ) => + { + const timer = setTimeout( () => + { + this._updater.onStateChanged.removeListener( handler ); + resolve(); + }, REFRESHING_WAIT_MS ); + + const handler = ( state: AuthState ) => + { + if ( state === 'refreshing' ) return; + clearTimeout( timer ); + this._updater.onStateChanged.removeListener( handler ); + if ( state === 'expired' ) reject( new GuardedCallError( 'Session expired', tier ) ); + else resolve(); + }; + + this._updater.onStateChanged.addListener( handler ); + } ); + } + + private async _executeWithRetry( tier: CallTier, fn: () => Promise ): Promise + { + const delays: number[] = tier === 'user' ? [] : RETRY_DELAYS_MS[ tier ]; + let lastError: unknown; + + for ( let attempt = 0; attempt <= delays.length; attempt++ ) + { + if ( attempt > 0 ) await sleep( delays[ attempt - 1 ] ); + + try + { + const res = await fn(); + if ( res.ok || res.status === 401 ) return res; + throw new GuardedCallError( `HTTP ${ res.status }`, tier ); + } + catch ( e ) + { + lastError = e; + const sessionDead = this._updater.state === 'expired'; + + if ( tier === 'user' ) throw e; + if ( tier === 'editor' && sessionDead ) throw e; + if ( sessionDead ) break; + + if ( tier === 'silent' ) console.warn( '[auth] guarded call failed (attempt', attempt + 1, ')', e ); + } + } + + if ( tier === 'silent' ) + { + console.warn( '[auth] guarded call giving up after retries' ); + return new Response( null, { status: 0 } ); + } + + throw lastError; + } +} diff --git a/source/auth/TokenUpdater.ts b/source/auth/TokenUpdater.ts new file mode 100644 index 0000000..20c7f54 --- /dev/null +++ b/source/auth/TokenUpdater.ts @@ -0,0 +1,111 @@ +import { EventSlot } from '../library-ts/browser/events/EventSlot.js'; +import { ActivityAnalyser } from '../library-ts/browser/dom/ActivityAnalyser.js'; + +// Central token-lifecycle owner for the browser session. Runs periodically and on +// user activity, keeping the session's cookies fresh via a cheap authenticated ping. +// Uses Web Locks to elect exactly one leader tab; other tabs follow state via +// BroadcastChannel. The actual refresh decision is server-authoritative (see +// PROACTIVE_REFRESH_MARGIN_SEC in auth-connector's jwtMiddleware) — this class +// never inspects or compares token expiry itself. + +export type AuthState = 'valid' | 'refreshing' | 'expired' | 'network-error'; + +type AuthChannelMessage = + | { type: 'state'; value: AuthState } + | { type: 'request-state' }; + +const CHANNEL_NAME = 'roject-auth'; +const LOCK_NAME = 'roject-token-updater-leader'; + +export class TokenUpdater +{ + static readonly CHECK_INTERVAL_MS = 5 * 60 * 1000; + + readonly onStateChanged = new EventSlot(); + + private _state: AuthState = 'valid'; + get state(): AuthState { return this._state; } + + private readonly _activity = new ActivityAnalyser(); + private _checking = false; + private _isLeader = false; + private readonly _channel = new BroadcastChannel( CHANNEL_NAME ); + + start(): void + { + this._channel.addEventListener( 'message', ( e: MessageEvent ) => + { + const msg = e.data as AuthChannelMessage; + + if ( this._isLeader ) + { + if ( msg.type === 'request-state' ) + this._channel.postMessage( { type: 'state', value: this._state } ); + return; + } + + if ( msg.type === 'state' ) this._setState( msg.value ); + } ); + + if ( !( 'locks' in navigator ) ) + { + this._becomeLeader(); + return; + } + + // Ask the current leader (if any) for its state so this tab syncs immediately. + this._channel.postMessage( { type: 'request-state' } ); + + // Queue for the exclusive lock. The first tab gets it immediately; subsequent + // tabs wait silently (listening via BroadcastChannel) until the current holder + // closes, then automatically become the new leader. + void navigator.locks.request( LOCK_NAME, async () => + { + this._becomeLeader(); + await new Promise( () => {} ); // hold the lock until tab closes + } ); + } + + private _becomeLeader(): void + { + this._isLeader = true; + this._activity.start(); + this._activity.onActive.addListener( () => this._check() ); + setInterval( () => this._check(), TokenUpdater.CHECK_INTERVAL_MS ); + void this._check(); + } + + private async _check(): Promise + { + if ( this._checking ) return; + this._checking = true; + this._setState( 'refreshing' ); + + try + { + const res = await fetch( '/api/auth/me' ); + + if ( res.ok ) this._setState( 'valid' ); + else if ( res.status === 401 ) this._setState( 'expired' ); + else this._setState( 'network-error' ); + } + catch + { + this._setState( 'network-error' ); + } + finally + { + this._checking = false; + } + } + + private _setState( state: AuthState ): void + { + if ( this._state === state ) return; + this._state = state; + this.onStateChanged.dispatch( state ); + + if ( this._isLeader ) + this._channel.postMessage( { type: 'state', value: state } ); + } +} diff --git a/source/components/editor-shell/editor-shell.ts b/source/components/editor-shell/editor-shell.ts index 7b91544..607a8b6 100644 --- a/source/components/editor-shell/editor-shell.ts +++ b/source/components/editor-shell/editor-shell.ts @@ -1,5 +1,7 @@ import { Editor } from '../../editor/Editor.js'; import { EditorConsole } from '../../editor/EditorConsole.js'; +import { TokenUpdater } from '../../auth/TokenUpdater.js'; +import { GuardedCall } from '../../auth/GuardedCall.js'; // ── Serialized layout types ─────────────────────────────────────────────────── @@ -117,12 +119,21 @@ class EditorShell extends HTMLElement private activePortraitPanel: string = 'center'; private _deviceId: string = ''; private _saveTimer: ReturnType | null = null; + private readonly _tokenUpdater = new TokenUpdater(); async connectedCallback(): Promise { const authRes = await fetch( '/api/auth/me' ); if ( !authRes.ok ) { location.href = '/'; return; } + this._tokenUpdater.onStateChanged.addListener( state => + { + if ( state === 'expired' ) { location.href = '/'; return; } + if ( state === 'network-error' ) console.warn( '[auth] session check failed — network error' ); + } ); + this._tokenUpdater.start(); + GuardedCall.init( this._tokenUpdater ); + const params = new URLSearchParams( location.search ); const projectId = params.get( 'project' ) ?? ''; const localRoot = params.get( 'localRoot' ) ?? ''; @@ -358,7 +369,7 @@ class EditorShell extends HTMLElement { try { - const res = await fetch( this._layoutUrl() ); + const res = await GuardedCall.get().call( 'silent', () => fetch( this._layoutUrl() ) ); if ( !res.ok ) return null; const data = await res.json(); return data ?? null; @@ -375,15 +386,11 @@ class EditorShell extends HTMLElement private async _saveLayout(): Promise { const layout = this._serializeLayout(); - try - { - await fetch( this._layoutUrl(), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( layout ), - } ); - } - catch {} + await GuardedCall.get().call( 'silent', () => fetch( this._layoutUrl(), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( layout ), + } ) ); } // ── Resize handles ────────────────────────────────────────────────────────── diff --git a/source/editor/Editor.ts b/source/editor/Editor.ts index bc5a625..df49159 100644 --- a/source/editor/Editor.ts +++ b/source/editor/Editor.ts @@ -1,5 +1,6 @@ import { EventSlot } from '../library-ts/browser/events/EventSlot.js'; import { FileEditorRegistry } from './FileEditorRegistry.js'; +import { GuardedCall } from '../auth/GuardedCall.js'; export interface DocumentOpenedEvent @@ -57,7 +58,7 @@ export class Editor if ( ! this.openDocs.has( filePath ) ) { - const res = await fetch( this._readUrl( filePath ) ); + const res = await GuardedCall.get().call( 'editor', () => fetch( this._readUrl( filePath ) ) ); const content = await res.text(); this.openDocs.set( filePath, { content, dirty: false } ); } @@ -87,7 +88,7 @@ export class Editor if ( !this.openDocs.has( filePath ) ) { - const res = await fetch( this._readUrl( filePath ) ); + const res = await GuardedCall.get().call( 'editor', () => fetch( this._readUrl( filePath ) ) ); const content = await res.text(); this.openDocs.set( filePath, { content, dirty: false } ); } @@ -106,14 +107,14 @@ export class Editor return; } - await fetch( + await GuardedCall.get().call( 'user', () => fetch( this._writeUrl( filePath ), { method: 'PUT', headers: { 'Content-Type': 'text/plain' }, body: doc.content } - ); + ) ); doc.dirty = false; this.onDocumentSaved.dispatch( { path: filePath } ); diff --git a/source/library-ts b/source/library-ts index 98895de..aac73ef 160000 --- a/source/library-ts +++ b/source/library-ts @@ -1 +1 @@ -Subproject commit 98895de241f4f05e98ab46f9976e3756c2df5c7d +Subproject commit aac73efae0dda1b4f46de576e55e47ec835c9ffa diff --git a/workspace/boards/backlog.html b/workspace/boards/backlog.html index db1f4f3..cab916a 100644 --- a/workspace/boards/backlog.html +++ b/workspace/boards/backlog.html @@ -49,6 +49,18 @@
Nice To Have
+ + Reactive one-retry-on-401 fallback for token refresh + + Considered as part of the auth-update central Token Updater work and deliberately + not built: when a call 401s in the narrow window right after the access token + expired but before the next proactive refresh tick, retry it once after triggering + a refresh, instead of failing immediately. Skipped for now to keep the guarded-call + path simple (calls only care whether access is valid, nothing else); revisit if the + 5-minute proactive refresh interval turns out to leave a real-world gap. + + + Real-Time Multi-User Collaboration diff --git a/workspace/boards/tasks.html b/workspace/boards/tasks.html index 034c934..13f7243 100644 --- a/workspace/boards/tasks.html +++ b/workspace/boards/tasks.html @@ -222,6 +222,55 @@
Done
+ + Auth Update — session refresh overhaul + + Full plan and rationale: workspace/history/2026/08-August/02-Saturday/auth-update.page. + Root cause was two bugs: browser tabs racing the same single-use refresh token + (rokojori-auth), and Electron never refreshing after its one-shot startup call. + + — Phase 1: rokojori-auth refresh-token grace window. db.ts: + RefreshToken gained usedAt/replacedBy; + markUsed() marks-rotated instead of deleting; create() prunes + expired rows. routes/auth.ts /api/auth/refresh: first use rotates + + calls markUsed(); same token reused within 10s (REFRESH_GRACE_TTL) + resolves to the same replacement pair instead of 401ing. + + — Phase 2: ActivityAnalyser gained OnVisibilityChange. + New source/auth/TokenUpdater.ts: periodic 5-min timer + activity-triggered + pings to GET /api/auth/me, EventSlot-driven + valid | refreshing | expired | network-error state. Wired into + editor-shell.ts. Browser access token is httpOnly, so proactive + margin decision moved server-side: jwtMiddleware rotates within 15 min of + real expiry. + + — Phase 3: source/auth/GuardedCall.ts: singleton wrapper with three + tiers — user (no retry, throw), editor (3 retries 1s/3s/8s, throw), + silent (same delays, never throws, console.warn). Pre-flight blocks + on expired; waits up to 6 s on refreshing. Wired into + Editor.ts and editor-shell.ts layout save/load. + + — Phase 4: Web Locks leader election in TokenUpdater.ts. First tab + acquires roject-token-updater-leader, runs checks, broadcasts state via + BroadcastChannel. Followers listen and mirror state. Leader handoff is + automatic when the holder tab closes. + + — Phase 5: Electron token updater in electron/main.ts. Server + clock offset from Date response header. JWT exp decoded + directly (no httpOnly cookie). checkAndRefreshIfDue() every 5 min, + refreshes within 15 min of expiry. + + — Phase 6: POST /api/auth/new-session in + rokojori-auth/routes/auth.ts. requireAuth-guarded; mints a + fresh independent token pair via issueTokenPair. + + — Phase 7: Session heartbeat in electron/main.ts. Running instance + writes { accessToken, timestamp } every 10 s. New instance reads it on + startup; if ≤30 s old calls POST /api/auth/new-session to skip login. + Retired plaintext last-password.txt auto-login. + + + Per-project per-device layout persistence diff --git a/workspace/history/2026/08-August/02-Saturday/auth-update.page b/workspace/history/2026/08-August/02-Saturday/auth-update.page new file mode 100644 index 0000000..f6c321b --- /dev/null +++ b/workspace/history/2026/08-August/02-Saturday/auth-update.page @@ -0,0 +1,85 @@ + + + + New Page + + + + + + + +

Auth Update

Fixing session refresh for good: two concrete bugs found, plus a central token-lifecycle architecture to replace ad-hoc per-call refresh handling.
+
+ +

Update Loop & Activity Analyzer

Token refreshing moves out of individual API calls and into one central unit that runs periodically and on user activity (via ActivityAnalyser), using server-authoritative time to decide when a refresh is actually due — not the browser's local clock.
+

Call Refactoring

API calls stop managing auth and retries themselves. A central guarded-call function checks whether it's safe to call before firing, and applies a configurable retry policy depending on whether the call was user-triggered, a normal editor action, or a silent background action.
+
+

Root Cause: Two Separate Bugs

Investigation found two distinct causes behind “auth fails after a while”, not one. Browser: refresh tokens are single-use — rokojori-auth's /api/auth/refresh deletes the used token then issues a new pair (routes/auth.tsissueTokenPair). When Roject's editor fires several parallel API calls right as the access token expires, each one independently triggers jwtMiddleware's silent refresh (auth-connector/source/server/auth.tstryRefresh) using the same refresh-token cookie; the first call wins and rotates it, every other call already in flight gets a 401 against the now-deleted token and force-redirects to login even though the session is fine. Electron: electron/main.ts only ever refreshes once, in the startup setTimeout block. Nothing refreshes the Bearer token again for the rest of the running session, so once ACCESS_TOKEN_TTL (1h default) elapses, every subsequent request silently sends an expired token until the app is restarted.
+

Central Token Updater (done, browser)

Built as source/auth/TokenUpdater.ts: a periodic timer every 5 minutes plus an immediate check on ActivityAnalyser.onActive (covering tab/window resume), each ping hitting the cheap GET /api/auth/me. Turned out the client-side exp/server-time-offset comparison originally planned here doesn't apply to the browser flow at all — the access token cookie is httpOnly, so client JS can never read its exp in the first place. The proactive decision moved server-side instead: jwtMiddleware (auth-connector/source/server/auth.ts) now rotates the cookie once the token is within PROACTIVE_REFRESH_MARGIN_SEC (15 min default) of its real, server-signed expiry — using the server's own clock, trivially authoritative, no offset math needed. The Updater's only job on the browser side is making sure a request happens often enough for the server to act on; it exposes an EventSlot-driven valid | refreshing | expired | network-error state that editor-shell currently reacts to directly (redirect to login on expired, console.warn on network-error) until Phase 3 routes this through the shared guarded-call wrapper instead. The exp/server-time-offset comparison as originally described still applies as designed — to Electron (Phase 5/7), where the token is a Bearer value actually held in the main process, not hidden behind a cookie. No reactive retry-on-401 fallback — deliberately skipped, tracked as a Nice To Have in the backlog if the 5-minute margin ever proves too wide.
+
+

Multi-Session Coordination

rokojori-auth already supports multiple parallel sessions — every login creates an independent refresh-token row (refreshTokens.create) without invalidating others. The problem is that within one session, several writers can share the same refresh token and race each other. Browser tabs in the same profile share one cookie jar, so they're literally the same session; fix via leader election with the Web Locks API — confirmed as the approach — one tab holds an exclusive lock and runs the updater, others follow via BroadcastChannel. Electron deliberately will not get a single-instance lock — multiple projects need to run in parallel, each as its own instance, and each mints its own fully independent session (no shared tokens.json, nothing to race over). To keep this transparent instead of showing a login screen per instance: every running instance writes its current access token plus a timestamp to a shared heartbeat file every 10s (piggybacking on the updater's tick). A newly-starting instance checks that file on launch — if it was written within the last ~30s, it POSTs that token to a new requireAuth-guarded endpoint, /api/auth/new-session, which mints a brand-new independent token pair for the same user via issueTokenPair (the same call /login already uses, just triggered by an existing valid access token instead of a password). The new instance now owns its own refresh token from the start — never shared, never racing. If the heartbeat is stale or the mint call fails, it falls through to the normal login screen. This also replaces the plaintext-password auto-login currently in main.ts (saveLastPassword/loadLastPassword) with something safer — proof of a live session instead of a stored secret.
+

Server-Side Refresh Tolerance

Client-side coordination can't reach across process/app/device boundaries — a browser tab, an Electron instance, and a second device can all share the same refresh-token record with no way to elect one leader across them. The real fix has to live on the server: give a just-rotated refresh token a short grace window instead of deleting it immediately in rokojori-auth/routes/auth.ts, so a near-simultaneous second refresh call still succeeds instead of hard-401ing. This is the baseline correctness guarantee; client-side leader election (Web Locks) is only an optimization on top to reduce how often that grace window gets exercised.
+
+

Guarded Calls & Retry Policy

A single wrapper function classifies every call by retry tier: user actions (save, delete) never auto-retry — the user gets a warning and repeats manually; editor actions (autosave, sync) retry with backoff then surface failure; silent actions (layout persistence, telemetry) retry-or-not with no UI, but always log to the console as a console.warn so failures stay debuggable instead of vanishing silently. None of the three tiers retry against a confirmed-dead session (expired state) — only against transient refreshing/network-error states. Calls should also check the shared auth/network state proactively before firing, not just react to a failed response.
+

Phases

1. Add a short grace window to refresh-token rotation in rokojori-auth so concurrent refresh calls stop hard-failing — fixes the browser race outright, independent of any client changes. 2. Build the central Token Updater (periodic loop + ActivityAnalyser.onActive + server-time offset), replacing jwtMiddleware's silent per-request refresh and electron/main.ts's one-shot startup refresh. 3. Refactor apiFetch and Electron's request path into the shared guarded-call function with the three-tier retry policy. 4. Add Web Locks-based leader election across browser tabs, with BroadcastChannel token sharing. 5. Move the Electron updater into the main process, no single-instance lock — each project runs as its own instance with its own independent session. 6. Add POST /api/auth/new-session to rokojori-auth (mints a fresh token pair from an existing valid access token). 7. Electron: write a heartbeat file (current access token + timestamp) every 10s from the updater tick; on startup, if the heartbeat is ≤ 30s old, silently mint a new session from it instead of showing the login screen; retire the plaintext last-password.txt auto-login in favour of this.
+
+

Technical Details

Server time offset: read the Date response header once, diff against local Date.now(), cache the offset, use localNow + offset for all expiry comparisons — ties directly into the existing clock-skew task instead of duplicating it. ActivityAnalyser (library-ts/browser/dom/ActivityAnalyser.ts) needs an OnVisibilityChange listener added alongside its existing focus/blur/mouse/touch set, since switching tabs within one window doesn't fire window focus/blur at all. Shared auth state should be an EventSlot-driven enum (valid | refreshing | expired | network-error) that both the updater and the guarded-call wrapper read and write, following existing project convention — no ad-hoc event buses.
+

Open Questions

All resolved. Proactive refresh runs every 5 minutes; Web Locks API confirmed for browser tab leader election; no reactive retry-on-401 (tracked as a Nice To Have in the backlog instead); silent-tier failures log via console.warn; Electron runs multiple concurrent instances with no single-instance lock, each minting its own independent session transparently via the heartbeat + /api/auth/new-session mechanism described under Multi-Session Coordination. One deliberately deferred hardening note for later: the heartbeat bootstrap currently reuses the general-purpose access token rather than a narrow-scope, short-lived bootstrap token — acceptable for now since it's no weaker than the existing on-disk tokens.json, but worth revisiting if the security surface needs tightening later.
+
+ + + +