fix(tauri-app): auto-refresh access JWT on TokenInvalid responses
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.
This commit is contained in:
@@ -30,12 +30,40 @@ async function tauriCall<T>(cmd: string, fallback: T, args?: Record<string, unkn
|
|||||||
* the browser preview this throws a friendly Error so the UI can
|
* the browser preview this throws a friendly Error so the UI can
|
||||||
* show a "running in browser preview" notice. In the Tauri
|
* show a "running in browser preview" notice. In the Tauri
|
||||||
* webview it falls through to a normal `invoke` call.
|
* webview it falls through to a normal `invoke` call.
|
||||||
|
*
|
||||||
|
* **Auto-refresh on 401**: the access JWT expires after 1 hour. When
|
||||||
|
* the PDS rejects our token with `TokenInvalid` (the rusty
|
||||||
|
* `routes::auth` handlers return `{"error":"TokenInvalid",...}`),
|
||||||
|
* we ask the Rust shell for a fresh access JWT via the
|
||||||
|
* `auth_refresh` Tauri command. The Rust side reads the stored
|
||||||
|
* refresh JWT (valid for 90 days) and rotates both. We retry
|
||||||
|
* exactly once on the same `cmd` + `args`. The `auth_*` commands
|
||||||
|
* themselves are skipped so a failing login doesn't trigger an
|
||||||
|
* infinite refresh loop.
|
||||||
*/
|
*/
|
||||||
async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
throw new Error(`Tauri command ${cmd} requires the desktop runtime`);
|
throw new Error(`Tauri command ${cmd} requires the desktop runtime`);
|
||||||
}
|
}
|
||||||
return invoke<T>(cmd, args);
|
try {
|
||||||
|
return await invoke<T>(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<T>(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 = {
|
export type Session = {
|
||||||
@@ -47,9 +75,35 @@ export type Session = {
|
|||||||
|
|
||||||
function createSessionStore() {
|
function createSessionStore() {
|
||||||
const { subscribe, set } = writable<Session | null>(null);
|
const { subscribe, set } = writable<Session | null>(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<Session | null> | null = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
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<Session | null> {
|
||||||
|
if (pendingRefresh) return pendingRefresh;
|
||||||
|
pendingRefresh = (async () => {
|
||||||
|
try {
|
||||||
|
const s = await invoke<Session>("auth_refresh");
|
||||||
|
set(s);
|
||||||
|
return s;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("session refresh failed", e);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
pendingRefresh = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return pendingRefresh;
|
||||||
|
},
|
||||||
async load() {
|
async load() {
|
||||||
const s = await tauriCall<Session | null>("current_session", null);
|
const s = await tauriCall<Session | null>("current_session", null);
|
||||||
set(s);
|
set(s);
|
||||||
|
|||||||
Reference in New Issue
Block a user