Skip to content

Commit 1aab641

Browse files
authored
Merge pull request #270 from quarto-dev/feature/bd-jv5u083l-expiry-refresh-only
fix(bd-jv5u083l): renewal verdict deadline unwedges expiry logout
2 parents 95fc897 + 24f5c24 commit 1aab641

3 files changed

Lines changed: 68 additions & 13 deletions

File tree

hub-client/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ be in reverse chronological order (latest first).
1515
1616
-->
1717

18+
### 2026-06-11
19+
20+
- [`1ecc1d8c`](https://github.com/quarto-dev/q2/commits/1ecc1d8c): Sessions whose silent sign-in renewal never completes (e.g. Google One Tap blocked) now correctly reach the login screen at token expiry, instead of silently losing both the expiry logout and all future renewal attempts.
21+
1822
### 2026-06-10
1923

2024
- [`a7bb7a08`](https://github.com/quarto-dev/q2/commits/a7bb7a08): Upgrade Automerge to 3.2.6, substantially reducing memory usage when loading documents.

hub-client/src/hooks/useAuth.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,36 @@ describe('useAuth', () => {
322322
);
323323
});
324324

325+
it('clears auth at expiry even when the renewal never settles (wedged IdP)', async () => {
326+
const user = { email: 'a@b.com', name: 'A', picture: null };
327+
mockFetchAuthMe
328+
.mockResolvedValueOnce(user) // mount check
329+
.mockResolvedValueOnce(null); // expiry re-check
330+
331+
const { result } = renderHook(() => useAuth(), { wrapper });
332+
await vi.waitFor(() =>
333+
expect(result.current.auth).toEqual(user),
334+
);
335+
336+
// Refresh point: renewal is triggered, but the IdP never calls back
337+
// (GIS blocked / FedCM — neither onCredential nor onError fires).
338+
await act(async () => {
339+
vi.advanceTimersByTime(COOKIE_MAX_AGE_MS - REFRESH_BUFFER_MS + 100);
340+
});
341+
expect(mockProvider.lastSilentRenewalOpts?.enabled).toBe(true);
342+
343+
// The verdict deadline must settle isRefreshing so the expiry
344+
// re-check can still reach its logout verdict.
345+
await act(async () => {
346+
vi.advanceTimersByTime(REFRESH_BUFFER_MS + 100);
347+
});
348+
349+
await vi.waitFor(() =>
350+
expect(result.current.auth).toBeNull(),
351+
);
352+
expect(result.current.sessionExpired).toBe(true);
353+
});
354+
325355
it('keeps auth if server confirms valid cookie at expiry', async () => {
326356
const user = { email: 'a@b.com', name: 'A', picture: null };
327357
const freshUser = {

hub-client/src/hooks/useAuth.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,17 @@ const DEFAULT_SESSION_MS = 3600 * 1000;
3939
/** Re-check interval when an expiry-time verdict couldn't be reached. */
4040
const EXPIRY_RECHECK_MS = 60 * 1000;
4141

42+
/** Time the IdP gets to settle a renewal before it counts as failed (30 s). */
43+
export const REFRESH_VERDICT_TIMEOUT_MS = 30 * 1000;
44+
4245
export function useAuth() {
4346
const provider = useAuthProvider();
4447
const [auth, setAuth] = useState<AuthState | null>(null);
4548
const [loading, setLoading] = useState(true);
4649
const [refreshEnabled, setRefreshEnabled] = useState(false);
4750
const [sessionExpired, setSessionExpired] = useState(false);
4851
const isRefreshing = useRef(false);
49-
const refreshTimer = useRef<ReturnType<typeof setTimeout>>(null);
50-
const expiryTimer = useRef<ReturnType<typeof setTimeout>>(null);
52+
const refreshDeadline = useRef<ReturnType<typeof setTimeout>>(null);
5153

5254
// Effective expiry of the current session (0 = no session).
5355
const expiresAtRef = useRef<number>(0);
@@ -84,13 +86,30 @@ export function useAuth() {
8486
return () => { cancelled = true; };
8587
}, []);
8688

89+
/** A renewal settled (success, failure, or deadline) — clear the in-flight gate. */
90+
const settleRefresh = useCallback(() => {
91+
if (refreshDeadline.current) clearTimeout(refreshDeadline.current);
92+
refreshDeadline.current = null;
93+
isRefreshing.current = false;
94+
}, []);
95+
96+
/** Renewal failed without a hub verdict — settle and stand One Tap down. */
97+
const abandonRenewal = useCallback(() => {
98+
settleRefresh();
99+
setRefreshEnabled(false);
100+
}, [settleRefresh]);
101+
87102
// Single entry point for activating One Tap. Coalesces concurrent
88103
// triggers (e.g. N parallel 401s) into one refresh attempt.
89104
const triggerRefresh = useCallback(() => {
90105
if (isRefreshing.current) return;
91106
isRefreshing.current = true;
92107
setRefreshEnabled(true);
93-
}, []);
108+
// The IdP may never call back (GIS blocked / FedCM policies). Without a
109+
// deadline, isRefreshing wedges true for the tab's lifetime: the expiry
110+
// re-check never reaches a verdict and refocus/retry are disabled.
111+
refreshDeadline.current = setTimeout(abandonRenewal, REFRESH_VERDICT_TIMEOUT_MS);
112+
}, [abandonRenewal]);
94113

95114
// Silent renewal via the active AuthProvider. The provider collapses
96115
// "renewal returned no usable credential" into onError, so the consumer
@@ -110,17 +129,21 @@ export function useAuth() {
110129
if (sessionLapsed()) expireSession();
111130
})
112131
.finally(() => {
113-
isRefreshing.current = false;
132+
settleRefresh();
114133
});
115134
setRefreshEnabled(false);
116135
},
117136
onError: () => {
118-
isRefreshing.current = false;
119-
setRefreshEnabled(false);
137+
abandonRenewal();
120138
if (sessionLapsed()) expireSession();
121139
},
122140
});
123141

142+
// Clear any pending renewal deadline on unmount.
143+
useEffect(() => () => {
144+
if (refreshDeadline.current) clearTimeout(refreshDeadline.current);
145+
}, []);
146+
124147
// On visibility change, verify the cookie. If rejected, try One Tap
125148
// refresh; a network error means offline and never clears the session.
126149
useEffect(() => {
@@ -157,9 +180,6 @@ export function useAuth() {
157180
// Schedule silent refresh and an expiry-time server re-check from the
158181
// session's real expiry.
159182
useEffect(() => {
160-
if (refreshTimer.current) clearTimeout(refreshTimer.current);
161-
if (expiryTimer.current) clearTimeout(expiryTimer.current);
162-
163183
if (!auth) {
164184
expiresAtRef.current = 0;
165185
return;
@@ -169,15 +189,16 @@ export function useAuth() {
169189
expiresAtRef.current = expiresAt;
170190

171191
// Silent refresh before expiry; immediately if already inside the buffer.
172-
refreshTimer.current = setTimeout(
192+
const refreshTimer = setTimeout(
173193
triggerRefresh,
174194
Math.max(expiresAt - REFRESH_BUFFER_MS - Date.now(), 0),
175195
);
176196

177197
// Expiry-time re-check. Only a definitive 401/403 clears the session;
178198
// network errors reschedule (logout on evidence, not on schedule).
199+
let expiryTimer: ReturnType<typeof setTimeout>;
179200
const scheduleExpiryCheck = (delay: number) => {
180-
expiryTimer.current = setTimeout(() => {
201+
expiryTimer = setTimeout(() => {
181202
fetchAuthMe()
182203
.then((me) => {
183204
if (me) {
@@ -199,8 +220,8 @@ export function useAuth() {
199220
scheduleExpiryCheck(msUntilExpiry > 0 ? msUntilExpiry + 1000 : EXPIRY_RECHECK_MS);
200221

201222
return () => {
202-
if (refreshTimer.current) clearTimeout(refreshTimer.current);
203-
if (expiryTimer.current) clearTimeout(expiryTimer.current);
223+
clearTimeout(refreshTimer);
224+
clearTimeout(expiryTimer);
204225
};
205226
}, [auth, applyAuth, expireSession, triggerRefresh]);
206227

0 commit comments

Comments
 (0)