From a5b1c889dca4fa795cb225bd5151535a910d3047 Mon Sep 17 00:00:00 2001 From: tomdebone Date: Tue, 7 Jul 2026 21:58:26 +0200 Subject: [PATCH] fix(tauri-app): auto-refresh access JWT on TokenInvalid responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PDS access JWT expires after 1 hour; the refresh JWT lasts 90 days. Before this commit, every action (post, like, follow, post create, etc.) started failing with the user's first action after the hour mark, forcing a manual re-login. Now safeInvoke catches the TokenInvalid / ExpiredSignature response, calls the 'auth_refresh' Tauri command to mint a fresh access JWT, then retries the original call exactly once. Concurrent 401s during a refresh-window share a single in-flight 'auth_refresh' call via the pendingRefresh promise — without it, a single expired JWT would trigger N parallel refreshes on the Rust side, which would issue N new refresh JWTs and silently drop all but the last one on save(). The refresh() method is exposed on the session store so callers outside safeInvoke (the explicit 'session.refreshed' toast etc.) can also trigger it. The auth_* commands themselves are excluded from the retry path so a bad login doesn't loop into 'refresh → 401 → refresh' forever. Wire shape match: the auth_refresh command returns AccountSession { did, handle, access_jwt, refresh_jwt } which matches our Session type, so the store can 'set(s)' directly without a field-by-field copy. --- crates/tauri-app/src/lib/api/client.ts | 56 +++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/tauri-app/src/lib/api/client.ts b/crates/tauri-app/src/lib/api/client.ts index 09a011a..5441423 100644 --- a/crates/tauri-app/src/lib/api/client.ts +++ b/crates/tauri-app/src/lib/api/client.ts @@ -30,12 +30,40 @@ async function tauriCall(cmd: string, fallback: T, args?: Record(cmd: string, args?: Record): Promise { if (!isTauri()) { throw new Error(`Tauri command ${cmd} requires the desktop runtime`); } - return invoke(cmd, args); + try { + return await invoke(cmd, args); + } catch (e: unknown) { + if (!isTokenInvalid(e) || cmd.startsWith("auth_")) throw e; + const fresh = await session.refresh(); + if (!fresh) throw e; + return await invoke(cmd, args); + } +} + +/// Sniff out a `TokenInvalid` response from the Rust error string. +/// Returns true when the error message looks like an expired/ +/// invalid JWT (the PDS uses a stable `"TokenInvalid"` code in its +/// JSON error body, which `@tauri-apps/api/core` surfaces verbatim). +function isTokenInvalid(e: unknown): boolean { + if (typeof e !== "object" || e === null) return false; + const msg = (e as { message?: string }).message ?? String(e); + if (!msg) return false; + return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature"); } export type Session = { @@ -47,9 +75,35 @@ export type Session = { function createSessionStore() { const { subscribe, set } = writable(null); + // Coalesce concurrent refresh requests into one — every safeInvoke + // call that hits a 401 would otherwise race to call auth_refresh in + // parallel. The pending promise is reset to `null` exactly once in + // the finally block; subsequent callers await the same one. + let pendingRefresh: Promise | null = null; return { subscribe, + /// Mint a fresh access JWT from the stored refresh JWT. Called + /// automatically by [`safeInvoke`] on `TokenInvalid` responses. + /// Returns the new session, or `null` if the refresh itself failed + /// (e.g. refresh JWT expired; at that point the user has to log + /// in again). + async refresh(): Promise { + if (pendingRefresh) return pendingRefresh; + pendingRefresh = (async () => { + try { + const s = await invoke("auth_refresh"); + set(s); + return s; + } catch (e) { + console.warn("session refresh failed", e); + return null; + } finally { + pendingRefresh = null; + } + })(); + return pendingRefresh; + }, async load() { const s = await tauriCall("current_session", null); set(s);