Skip to content

Commit 973bf57

Browse files
committed
Harden auth reconnect recovery (#413)
1 parent a8ea72b commit 973bf57

4 files changed

Lines changed: 60 additions & 5 deletions

File tree

web/src/lib/api/auth.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import { apiGet, apiPost, parseApiResponse } from './client.js';
55

6+
const AUTH_REQUEST_TIMEOUT_MS = 5_000;
7+
68
export interface AuthStatusResponse {
79
needsSetup: boolean;
810
isAuthenticated: boolean;
@@ -32,7 +34,9 @@ export interface UserResponse {
3234

3335
/** Checks whether the server requires authentication. Unauthenticated. */
3436
export async function getAuthStatus(): Promise<AuthStatusResponse> {
35-
const response = await fetch('/api/v1/auth/status');
37+
const response = await fetch('/api/v1/auth/status', {
38+
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT_MS),
39+
});
3640
return parseApiResponse<AuthStatusResponse>(response);
3741
}
3842

@@ -58,7 +62,7 @@ export async function register(username: string, password: string): Promise<Regi
5862

5963
/** Fetches the current authenticated user. */
6064
export async function getUser(): Promise<UserResponse> {
61-
return apiGet<UserResponse>('/api/v1/auth/user');
65+
return apiGet<UserResponse>('/api/v1/auth/user', { timeoutMs: AUTH_REQUEST_TIMEOUT_MS });
6266
}
6367

6468
/** Logs out the current session. */

web/src/lib/stores/__tests__/auth.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,27 @@ describe('AuthStore', () => {
6666
});
6767

6868
describe('checkAuthStatus', () => {
69+
it('coalesces concurrent recovery checks', async () => {
70+
let resolveStatus!: (status: {
71+
needsSetup: boolean;
72+
isAuthenticated: boolean;
73+
authDisabled: boolean;
74+
}) => void;
75+
vi.mocked(getAuthStatus).mockImplementation(
76+
() =>
77+
new Promise((resolve) => {
78+
resolveStatus = resolve;
79+
}),
80+
);
81+
const auth = new AuthStore();
82+
const first = auth.checkAuthStatus();
83+
const second = auth.checkAuthStatus();
84+
expect(getAuthStatus).toHaveBeenCalledTimes(1);
85+
resolveStatus({ needsSetup: false, isAuthenticated: false, authDisabled: false });
86+
await Promise.all([first, second]);
87+
expect(getAuthStatus).toHaveBeenCalledTimes(1);
88+
});
89+
6990
it('sets needsSetup when server reports setup needed', async () => {
7091
vi.mocked(getAuthStatus).mockResolvedValue({
7192
needsSetup: true,
@@ -210,9 +231,11 @@ describe('AuthStore', () => {
210231
});
211232
const auth = new AuthStore();
212233
auth.needsSetup = true;
234+
auth.isUnavailable = true;
213235
const result = await auth.register('newuser', 'pass');
214236
expect(result.success).toBe(true);
215237
expect(auth.needsSetup).toBe(false);
238+
expect(auth.isUnavailable).toBe(false);
216239
expect(auth.token).toBe('reg-token');
217240
expect(setAuthToken).toHaveBeenCalledWith('reg-token');
218241
});

web/src/lib/stores/auth.svelte.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export interface AuthResult {
5757
}
5858

5959
export class AuthStore {
60+
private statusCheck: Promise<void> | null = null;
6061
token = $state<string | null>(null);
6162
user = $state<AuthUser | null>(null);
6263
isLoading = $state(true);
@@ -72,6 +73,17 @@ export class AuthStore {
7273

7374
/** Queries the server for auth status and validates any stored token. */
7475
async checkAuthStatus(): Promise<void> {
76+
if (this.statusCheck) return this.statusCheck;
77+
const check = this.performAuthStatusCheck();
78+
this.statusCheck = check;
79+
try {
80+
await check;
81+
} finally {
82+
if (this.statusCheck === check) this.statusCheck = null;
83+
}
84+
}
85+
86+
private async performAuthStatusCheck(): Promise<void> {
7587
try {
7688
this.isLoading = true;
7789
this.isUnavailable = false;
@@ -158,6 +170,7 @@ export class AuthStore {
158170
this.token = data.token;
159171
this.user = data.user;
160172
this.needsSetup = false;
173+
this.isUnavailable = false;
161174
setAuthToken(data.token);
162175
return { success: true };
163176
} catch (err: unknown) {

web/src/routes/+layout.svelte

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,12 +300,27 @@
300300
});
301301
302302
onMount(() => {
303-
auth.checkAuthStatus();
303+
void auth.checkAuthStatus();
304304
const handleOnline = () => {
305-
if (auth.isUnavailable) void auth.checkAuthStatus();
305+
void auth.checkAuthStatus();
306306
};
307+
const handleVisibilityChange = () => {
308+
if (document.visibilityState === 'visible' && auth.isUnavailable) {
309+
void auth.checkAuthStatus();
310+
}
311+
};
312+
const retryInterval = window.setInterval(() => {
313+
if (document.visibilityState === 'visible' && auth.isUnavailable) {
314+
void auth.checkAuthStatus();
315+
}
316+
}, 15_000);
307317
window.addEventListener('online', handleOnline);
308-
return () => window.removeEventListener('online', handleOnline);
318+
document.addEventListener('visibilitychange', handleVisibilityChange);
319+
return () => {
320+
window.clearInterval(retryInterval);
321+
window.removeEventListener('online', handleOnline);
322+
document.removeEventListener('visibilitychange', handleVisibilityChange);
323+
};
309324
});
310325
311326
function handlePageHide() {

0 commit comments

Comments
 (0)