Preserve native sessions during temporary server outages
Client checks / checks (push) Canceled after 0s
Client dependency and security checks / audit (push) Canceled after 0s

This commit is contained in:
2026-10-25 17:51:10 +01:00
parent 58df8d06e1
commit 7aef4171af
5 changed files with 79 additions and 6 deletions
+36 -6
View File
@@ -1,6 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { sessionResponseState, sessionRetryDelay } from './session-recovery.js';
let sessionRestoreTimer: ReturnType<typeof setTimeout> | undefined;
let sessionRestoreAttempt = 0;
let sessionRestoreGeneration = 0;
type ClientSettings = { server_address: string; username: string };
@@ -63,30 +68,51 @@
async function restoreNativeSession() {
if (!nativeClientAvailable() || !username || !serverAddress) return;
const generation = ++sessionRestoreGeneration;
clearTimeout(sessionRestoreTimer);
let address = serverAddress;
const account = username;
const isCurrentRestore = () => generation === sessionRestoreGeneration && address === serverAddress && account === username;
try {
const safeServerAddress = validatedServerAddress(serverAddress);
serverAddress = safeServerAddress;
address = safeServerAddress;
const settings: ClientSettings = { server_address: safeServerAddress, username };
const storedSession = await invoke<string | null>('load_server_session', { settings });
if (!storedSession) return;
if (!storedSession || !isCurrentRestore()) return;
const response = await fetch(`${safeServerAddress}/api/v1/auth/session`, {
headers: { authorization: `Bearer ${storedSession}` },
});
if (!response.ok) {
if (!isCurrentRestore()) return;
const state = sessionResponseState(response.status);
if (state === 'revoked') {
await invoke('clear_server_session', { settings });
if (!isCurrentRestore()) return;
sessionToken = '';
authenticated = false;
authError = 'Authentication revoked. Log in again.';
return;
}
if (state !== 'valid') throw new Error('Session verification unavailable');
const result: { must_replace_credentials: boolean } = await response.json();
if (!isCurrentRestore()) return;
sessionRestoreAttempt = 0;
authError = '';
sessionToken = storedSession;
authenticated = true;
mustReplaceCredentials = result.must_replace_credentials;
connectionState = 'CONNECTED';
await loadAuthenticatedData();
await loadAuthenticatedData().catch(() => {
authError = 'Authenticated, but dashboard data could not be loaded.';
});
if (mustReplaceCredentials) newUsername = username;
} catch {
// A stale keyring session must not prevent the normal login form from opening.
sessionToken = '';
authenticated = false;
if (!isCurrentRestore()) return;
// Transport/server failures are not proof of revocation: preserve the keyring.
authError = 'Server unavailable. Retrying saved session.';
sessionRestoreTimer = setTimeout(() => {
if (isCurrentRestore()) void restoreNativeSession();
}, sessionRetryDelay(sessionRestoreAttempt++));
}
}
@@ -336,6 +362,8 @@
}
async function login() {
sessionRestoreGeneration += 1;
clearTimeout(sessionRestoreTimer);
if (connectionState === 'INCOMPATIBLE') {
authError = 'This client version is not compatible with the server.';
return;
@@ -449,6 +477,8 @@
}
async function logout() {
sessionRestoreGeneration += 1;
clearTimeout(sessionRestoreTimer);
realtimeSocket?.close();
realtimeSocket = undefined;
realtimeState = 'DISCONNECTED';
+14
View File
@@ -0,0 +1,14 @@
/** Classify authoritative rejection separately from temporary server failures.
* @param {number} status HTTP response status.
*/
export function sessionResponseState(status) {
if (status === 401 || status === 403) return 'revoked';
return status >= 200 && status < 300 ? 'valid' : 'unavailable';
}
/** Milliseconds before each retry; attempt zero is the immediate first retry.
* @param {number} attempt Zero-based retry index.
*/
export function sessionRetryDelay(attempt) {
return [0, 3000, 10000, 15000][attempt] ?? 30000;
}
+17
View File
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { sessionResponseState, sessionRetryDelay } from './session-recovery.js';
test('only explicit authentication rejection invalidates a saved session', () => {
for (const status of [401, 403]) assert.equal(sessionResponseState(status), 'revoked');
for (const status of [404, 429, 500, 502, 503])
assert.equal(sessionResponseState(status), 'unavailable');
assert.equal(sessionResponseState(200), 'valid');
});
test('saved session retries follow the shared recovery schedule', () => {
assert.deepEqual(
Array.from({ length: 7 }, (_, attempt) => sessionRetryDelay(attempt)),
[0, 3000, 10000, 15000, 30000, 30000, 30000],
);
});