Skip to content

Commit 6a8f598

Browse files
aaspinwallclaude
andcommitted
feat(send): recover revoked sessions via silent refresh before forced logout
Address review on #960: on an x-logout (a Keycloak-revoked access token), the frontend now attempts a silent refresh before tearing the session down, and only forces logout when the refresh token is also gone. A transient refresh error keeps the session (fail open). - auth-store: new recoverOrForceLogout() runs the deduped silent refresh; refreshAccessToken now records whether a failure was genuine so the decision keys off the failure kind, not isLoggedIn (which can be false during an extension cold-start and would otherwise turn a network blip into a logout). - api.ts / trpc.ts: on x-logout, recover and retry the request once with the fresh token instead of unconditionally forcing logout. In api.ts x-logout is branched ahead of the plain-401 path so there is exactly one refresh. The OIDC refresh token lives only client-side (oidc-client-ts), so this recovery is necessarily on the frontend; the backend only signals revocation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b73424c commit 6a8f598

6 files changed

Lines changed: 293 additions & 53 deletions

File tree

packages/send/frontend/src/lib/api.ts

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,38 @@ export class ApiConnection {
129129
return null;
130130
}
131131

132-
// Handle authentication errors
133-
if (resp.status === 401) {
132+
// Handle authentication errors. x-logout always rides on a 401, so branch on
133+
// it first and keep the two paths mutually exclusive — a revoked session
134+
// gets exactly one recovery attempt, never a second refresh from the plain
135+
// 401 branch below.
136+
if (resp.headers?.get?.('x-logout')) {
137+
// The backend flags a revoked OIDC session (logout / password change /
138+
// admin force-logout). Try to recover with the refresh token before
139+
// tearing down; recoverOrForceLogout forces logout only if the refresh
140+
// token is also dead, and keeps the session on a transient error (#974).
141+
try {
142+
const { useAuthStore } =
143+
await import('@send-frontend/stores/auth-store');
144+
const authStore = useAuthStore();
145+
const recovered = await authStore.recoverOrForceLogout();
146+
147+
if (recovered && requestHeaders['Authorization']) {
148+
// Session rolled forward — retry once with the freshly-rotated token.
149+
const newToken = await authStore.getAccessToken();
150+
if (newToken) {
151+
opts.headers['Authorization'] = `Bearer ${newToken}`;
152+
resp = await fetch(url, opts);
153+
}
154+
} else if (!recovered) {
155+
// Genuine logout (state already cleared) or a transient error (session
156+
// kept, fail open) — either way this request is done.
157+
return null;
158+
}
159+
} catch (error) {
160+
console.error('Forced-logout handling failed:', error);
161+
return null;
162+
}
163+
} else if (resp.status === 401) {
134164
// If we're using OIDC and get 401, try to refresh the token
135165
if (requestHeaders['Authorization']) {
136166
try {
@@ -166,20 +196,6 @@ export class ApiConnection {
166196
}
167197
}
168198

169-
// The backend sets x-logout when the OIDC session is no longer active
170-
// (logout / password change / admin force-logout). Clear local auth and
171-
// stop — regardless of the response status (#960).
172-
if (resp.headers?.get?.('x-logout')) {
173-
try {
174-
const { useAuthStore } =
175-
await import('@send-frontend/stores/auth-store');
176-
await useAuthStore().handleForcedLogout();
177-
} catch (error) {
178-
console.error('Forced-logout handling failed:', error);
179-
}
180-
return null;
181-
}
182-
183199
if (!resp.ok) {
184200
// Surface the status/body for the caller's diagnostics before discarding
185201
// the response. Reading the body is safe here because we return null

packages/send/frontend/src/lib/trpc.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,25 +98,42 @@ export async function fetchWithLogoutCheck(
9898
url: RequestInfo | URL,
9999
options: RequestInit
100100
): Promise<Response> {
101-
const headers = new Headers(options.headers);
102-
try {
103-
const { useAuthStore } = await import('@send-frontend/stores/auth-store');
104-
if (!headers.has('Authorization')) {
105-
const token = await useAuthStore().getAccessToken();
106-
if (token) {
107-
headers.set('Authorization', `Bearer ${token}`);
101+
// Attach the current OIDC access token (unless the caller already set one).
102+
// Rebuilt for the post-refresh retry so it picks up the freshly-rotated token.
103+
async function buildHeaders(): Promise<Headers> {
104+
const headers = new Headers(options.headers);
105+
try {
106+
const { useAuthStore } = await import('@send-frontend/stores/auth-store');
107+
if (!headers.has('Authorization')) {
108+
const token = await useAuthStore().getAccessToken();
109+
if (token) {
110+
headers.set('Authorization', `Bearer ${token}`);
111+
}
108112
}
113+
} catch {
114+
// No token available — fall back to cookie auth.
109115
}
110-
} catch {
111-
// No token available — fall back to cookie auth.
116+
return headers;
112117
}
113118

114-
const res = await fetch(url, { ...options, headers, credentials: 'include' });
119+
const res = await fetch(url, {
120+
...options,
121+
headers: await buildHeaders(),
122+
credentials: 'include',
123+
});
115124

116125
if (res.headers?.get?.('x-logout')) {
117126
try {
118127
const { useAuthStore } = await import('@send-frontend/stores/auth-store');
119-
await useAuthStore().handleForcedLogout();
128+
// A revoked access token may just need refreshing — recover and retry
129+
// rather than forcing logout when the refresh token is still alive (#974).
130+
if (await useAuthStore().recoverOrForceLogout()) {
131+
return await fetch(url, {
132+
...options,
133+
headers: await buildHeaders(),
134+
credentials: 'include',
135+
});
136+
}
120137
} catch (error) {
121138
console.error('Forced-logout handling failed:', error);
122139
}

packages/send/frontend/src/stores/auth-store.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ export const useAuthStore = defineStore('auth', () => {
7979
// invalid_grant, which reads as a spurious logout.
8080
let inFlightRefresh: Promise<User | null> | null = null;
8181

82+
// Outcome of the most recently completed refresh: `true` when it failed
83+
// genuinely (refresh token revoked/expired), `false` on success or a transient
84+
// error. recoverOrForceLogout reads this to decide force-logout vs fail-open,
85+
// rather than inferring from isLoggedIn — which can already be false (e.g. an
86+
// extension cold-start request fires before checkAuthStatus flips it true) and
87+
// would otherwise turn a network blip into a spurious logout.
88+
let lastRefreshFailedGenuinely = false;
89+
8290
/**
8391
* Notify the add-on background that the session is over so its menu reverts
8492
* to logged-out. Only meaningful inside Thunderbird, where the token-bridge
@@ -107,6 +115,7 @@ export const useAuthStore = defineStore('auth', () => {
107115
if (inFlightRefresh) return inFlightRefresh;
108116

109117
inFlightRefresh = (async () => {
118+
lastRefreshFailedGenuinely = false;
110119
try {
111120
const user = await userManager.signinSilent();
112121
currentUser.value = user;
@@ -118,6 +127,7 @@ export const useAuthStore = defineStore('auth', () => {
118127
return user;
119128
} catch (error) {
120129
if (isGenuineAuthFailure(error)) {
130+
lastRefreshFailedGenuinely = true;
121131
console.warn(
122132
`Silent token refresh failed — session ended (${
123133
(error as { error?: string }).error
@@ -442,6 +452,39 @@ export const useAuthStore = defineStore('auth', () => {
442452
return user?.access_token ?? null;
443453
}
444454

455+
/**
456+
* The backend reported the current access token revoked (x-logout, #960).
457+
* Before tearing the session down, try a silent refresh: a revoked/expired
458+
* *access* token can often be replaced using a still-valid *refresh* token,
459+
* so the session keeps rolling instead of bouncing the user to login (PR #974
460+
* review). Only force logout when the refresh token is also gone; on a
461+
* transient refresh error keep the session (fail open).
462+
*
463+
* Goes through refreshAccessToken() — an unconditional signinSilent — rather
464+
* than getAccessToken(), because the token behind x-logout is still within
465+
* its lifetime (the backend exp-gates the signal), so getAccessToken() would
466+
* hand back the same stale, revoked token without refreshing.
467+
*
468+
* @returns `true` if the session was recovered — the caller should retry the
469+
* request with the fresh token — and `false` otherwise (forced logout on a
470+
* genuine failure, or session kept on a transient error).
471+
*/
472+
async function recoverOrForceLogout(): Promise<boolean> {
473+
const user = await refreshAccessToken();
474+
if (user && !user.expired) {
475+
return true; // refresh token still valid → session continues
476+
}
477+
// Force logout ONLY on a genuine refresh failure (refresh token gone). A
478+
// transient error keeps the session (fail open). We read the failure kind
479+
// refreshAccessToken just recorded rather than inferring from isLoggedIn —
480+
// there is no await between its resolution above and this read, so the flag
481+
// still reflects this refresh.
482+
if (lastRefreshFailedGenuinely) {
483+
await handleForcedLogout(); // genuine failure → clear state and redirect
484+
}
485+
return false;
486+
}
487+
445488
async function loadUser() {
446489
// Always check for a stored user instead of getting it from
447490
// the userManager.
@@ -648,6 +691,7 @@ export const useAuthStore = defineStore('auth', () => {
648691
getAccessToken,
649692
logoutFromOIDC,
650693
handleForcedLogout,
694+
recoverOrForceLogout,
651695
refreshToken,
652696
loginToKeyCloak, // Alias for loginToOIDC
653697

packages/send/frontend/src/test/lib/api.test.ts

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
99

1010
// Stub the auth store so api.call's dynamic imports (getAccessToken and the
1111
// x-logout handler) don't pull in pinia/oidc at test time.
12-
const { mockForcedLogout } = vi.hoisted(() => ({ mockForcedLogout: vi.fn() }));
12+
const { mockGetAccessToken, mockRecover, mockRefreshToken } = vi.hoisted(
13+
() => ({
14+
mockGetAccessToken: vi.fn<() => Promise<string | null>>(async () => null),
15+
mockRecover: vi.fn<() => Promise<boolean>>(async () => false),
16+
mockRefreshToken: vi.fn<() => Promise<string | null>>(async () => null),
17+
})
18+
);
1319
vi.mock('@send-frontend/stores/auth-store', () => ({
1420
useAuthStore: () => ({
15-
getAccessToken: async () => null,
16-
handleForcedLogout: mockForcedLogout,
17-
refreshToken: async () => null,
21+
getAccessToken: mockGetAccessToken,
22+
recoverOrForceLogout: mockRecover,
23+
refreshToken: mockRefreshToken,
1824
}),
1925
}));
2026

@@ -137,19 +143,22 @@ describe('ApiConnection.call — onFailure diagnostics', () => {
137143
});
138144
});
139145

140-
describe('ApiConnection.call — x-logout forced logout (#960)', () => {
146+
describe('ApiConnection.call — x-logout session recovery (#960/#974)', () => {
141147
afterEach(() => {
142148
vi.unstubAllGlobals();
143149
vi.restoreAllMocks();
144-
mockForcedLogout.mockClear();
150+
mockGetAccessToken.mockReset().mockResolvedValue(null);
151+
mockRecover.mockReset().mockResolvedValue(false);
152+
mockRefreshToken.mockReset().mockResolvedValue(null);
145153
});
146154

147-
it('clears the session and returns null when the response has x-logout', async () => {
148-
mockFetch(
155+
it('returns null without retrying when recovery fails (refresh token dead)', async () => {
156+
mockRecover.mockResolvedValue(false);
157+
const fetchFn = mockFetch(
149158
() =>
150159
({
151-
ok: true,
152-
status: 200,
160+
ok: false,
161+
status: 401,
153162
headers: { get: (k: string) => (k === 'x-logout' ? '1' : null) },
154163
json: async () => ({ ok: true }),
155164
}) as unknown as Response
@@ -158,11 +167,50 @@ describe('ApiConnection.call — x-logout forced logout (#960)', () => {
158167
const api = new ApiConnection(SERVER);
159168
const result = await api.call('uploads', {}, 'POST');
160169

161-
expect(mockForcedLogout).toHaveBeenCalledTimes(1);
170+
expect(mockRecover).toHaveBeenCalledTimes(1);
171+
expect(fetchFn).toHaveBeenCalledTimes(1); // no retry
162172
expect(result).toBeNull();
163173
});
164174

165-
it('does not force logout when the header is absent', async () => {
175+
it('retries with a fresh token and returns the retry body when recovery succeeds', async () => {
176+
// Present an OIDC bearer token so the retry path (which needs an existing
177+
// Authorization header) is taken.
178+
mockGetAccessToken.mockResolvedValueOnce('stale'); // initial request token
179+
mockRecover.mockResolvedValue(true);
180+
mockGetAccessToken.mockResolvedValueOnce('fresh'); // token used on retry
181+
182+
let call = 0;
183+
const fetchFn = mockFetch(() => {
184+
call += 1;
185+
if (call === 1) {
186+
return {
187+
ok: false,
188+
status: 401,
189+
headers: { get: (k: string) => (k === 'x-logout' ? '1' : null) },
190+
json: async () => ({ stale: true }),
191+
} as unknown as Response;
192+
}
193+
return {
194+
ok: true,
195+
status: 200,
196+
headers: { get: () => null },
197+
json: async () => ({ ok: true }),
198+
} as unknown as Response;
199+
});
200+
201+
const api = new ApiConnection(SERVER);
202+
const result = await api.call('uploads', {}, 'POST');
203+
204+
expect(mockRecover).toHaveBeenCalledTimes(1);
205+
expect(fetchFn).toHaveBeenCalledTimes(2); // retried once
206+
const retryOpts = (fetchFn.mock.calls[1] as unknown[])[1] as {
207+
headers: Record<string, string>;
208+
};
209+
expect(retryOpts.headers['Authorization']).toBe('Bearer fresh');
210+
expect(result).toEqual({ ok: true });
211+
});
212+
213+
it('does not attempt recovery when the header is absent', async () => {
166214
mockFetch(
167215
() =>
168216
({
@@ -176,7 +224,7 @@ describe('ApiConnection.call — x-logout forced logout (#960)', () => {
176224
const api = new ApiConnection(SERVER);
177225
const result = await api.call('uploads', {}, 'POST');
178226

179-
expect(mockForcedLogout).not.toHaveBeenCalled();
227+
expect(mockRecover).not.toHaveBeenCalled();
180228
expect(result).toEqual({ ok: true });
181229
});
182230
});

packages/send/frontend/src/test/lib/trpc.test.ts

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import {
77
} from '@send-frontend/lib/trpc';
88
import { TRPC_WS_PATH } from '@send-frontend/lib/config';
99

10-
const { mockGetAccessToken, mockForcedLogout } = vi.hoisted(() => ({
10+
const { mockGetAccessToken, mockRecover } = vi.hoisted(() => ({
1111
mockGetAccessToken: vi.fn(),
12-
mockForcedLogout: vi.fn(),
12+
mockRecover: vi.fn(async () => false),
1313
}));
1414
vi.mock('@send-frontend/stores/auth-store', () => ({
1515
useAuthStore: () => ({
1616
getAccessToken: mockGetAccessToken,
17-
handleForcedLogout: mockForcedLogout,
17+
recoverOrForceLogout: mockRecover,
1818
}),
1919
}));
2020

@@ -107,19 +107,46 @@ describe('fetchWithLogoutCheck (#960)', () => {
107107
expect(mockGetAccessToken).not.toHaveBeenCalled();
108108
});
109109

110-
it('forces logout when the response carries x-logout', async () => {
110+
it('recovers (no retry) and returns the original response when recovery fails', async () => {
111111
mockGetAccessToken.mockResolvedValue(null);
112-
vi.stubGlobal(
113-
'fetch',
114-
vi.fn(() =>
115-
Promise.resolve({
116-
headers: { get: (k: string) => (k === 'x-logout' ? '1' : null) },
117-
} as unknown as Response)
118-
)
119-
);
112+
mockRecover.mockResolvedValue(false);
113+
const original = {
114+
headers: { get: (k: string) => (k === 'x-logout' ? '1' : null) },
115+
} as unknown as Response;
116+
const fetchFn = vi.fn(() => Promise.resolve(original));
117+
vi.stubGlobal('fetch', fetchFn);
118+
119+
const res = await fetchWithLogoutCheck('https://x/trpc', {});
120+
121+
expect(mockRecover).toHaveBeenCalledTimes(1);
122+
expect(fetchFn).toHaveBeenCalledTimes(1); // no retry
123+
expect(res).toBe(original);
124+
});
125+
126+
it('retries once with a refreshed token when recovery succeeds', async () => {
127+
mockRecover.mockResolvedValue(true);
128+
// First call: initial request has no token; retry: getAccessToken yields one.
129+
mockGetAccessToken.mockResolvedValueOnce(null).mockResolvedValueOnce('fresh');
130+
131+
const inits: RequestInit[] = [];
132+
let call = 0;
133+
const fetchFn = vi.fn((_url, opts) => {
134+
inits.push(opts);
135+
call += 1;
136+
return Promise.resolve({
137+
headers: {
138+
get: (k: string) => (call === 1 && k === 'x-logout' ? '1' : null),
139+
},
140+
} as unknown as Response);
141+
});
142+
vi.stubGlobal('fetch', fetchFn);
120143

121144
await fetchWithLogoutCheck('https://x/trpc', {});
122145

123-
expect(mockForcedLogout).toHaveBeenCalledTimes(1);
146+
expect(mockRecover).toHaveBeenCalledTimes(1);
147+
expect(fetchFn).toHaveBeenCalledTimes(2); // retried once
148+
expect((inits[1].headers as Headers).get('Authorization')).toBe(
149+
'Bearer fresh'
150+
);
124151
});
125152
});

0 commit comments

Comments
 (0)