Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions hub-client/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ be in reverse chronological order (latest first).

-->

### 2026-06-11

- [`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.

### 2026-06-10

- [`a7bb7a08`](https://github.com/quarto-dev/q2/commits/a7bb7a08): Upgrade Automerge to 3.2.6, substantially reducing memory usage when loading documents.
Expand Down
30 changes: 30 additions & 0 deletions hub-client/src/hooks/useAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,36 @@ describe('useAuth', () => {
);
});

it('clears auth at expiry even when the renewal never settles (wedged IdP)', async () => {
const user = { email: 'a@b.com', name: 'A', picture: null };
mockFetchAuthMe
.mockResolvedValueOnce(user) // mount check
.mockResolvedValueOnce(null); // expiry re-check

const { result } = renderHook(() => useAuth(), { wrapper });
await vi.waitFor(() =>
expect(result.current.auth).toEqual(user),
);

// Refresh point: renewal is triggered, but the IdP never calls back
// (GIS blocked / FedCM — neither onCredential nor onError fires).
await act(async () => {
vi.advanceTimersByTime(COOKIE_MAX_AGE_MS - REFRESH_BUFFER_MS + 100);
});
expect(mockProvider.lastSilentRenewalOpts?.enabled).toBe(true);

// The verdict deadline must settle isRefreshing so the expiry
// re-check can still reach its logout verdict.
await act(async () => {
vi.advanceTimersByTime(REFRESH_BUFFER_MS + 100);
});

await vi.waitFor(() =>
expect(result.current.auth).toBeNull(),
);
expect(result.current.sessionExpired).toBe(true);
});

it('keeps auth if server confirms valid cookie at expiry', async () => {
const user = { email: 'a@b.com', name: 'A', picture: null };
const freshUser = {
Expand Down
47 changes: 34 additions & 13 deletions hub-client/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,17 @@ const DEFAULT_SESSION_MS = 3600 * 1000;
/** Re-check interval when an expiry-time verdict couldn't be reached. */
const EXPIRY_RECHECK_MS = 60 * 1000;

/** Time the IdP gets to settle a renewal before it counts as failed (30 s). */
export const REFRESH_VERDICT_TIMEOUT_MS = 30 * 1000;

export function useAuth() {
const provider = useAuthProvider();
const [auth, setAuth] = useState<AuthState | null>(null);
const [loading, setLoading] = useState(true);
const [refreshEnabled, setRefreshEnabled] = useState(false);
const [sessionExpired, setSessionExpired] = useState(false);
const isRefreshing = useRef(false);
const refreshTimer = useRef<ReturnType<typeof setTimeout>>(null);
const expiryTimer = useRef<ReturnType<typeof setTimeout>>(null);
const refreshDeadline = useRef<ReturnType<typeof setTimeout>>(null);

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

/** A renewal settled (success, failure, or deadline) — clear the in-flight gate. */
const settleRefresh = useCallback(() => {
if (refreshDeadline.current) clearTimeout(refreshDeadline.current);
refreshDeadline.current = null;
isRefreshing.current = false;
}, []);

/** Renewal failed without a hub verdict — settle and stand One Tap down. */
const abandonRenewal = useCallback(() => {
settleRefresh();
setRefreshEnabled(false);
}, [settleRefresh]);

// Single entry point for activating One Tap. Coalesces concurrent
// triggers (e.g. N parallel 401s) into one refresh attempt.
const triggerRefresh = useCallback(() => {
if (isRefreshing.current) return;
isRefreshing.current = true;
setRefreshEnabled(true);
}, []);
// The IdP may never call back (GIS blocked / FedCM policies). Without a
// deadline, isRefreshing wedges true for the tab's lifetime: the expiry
// re-check never reaches a verdict and refocus/retry are disabled.
refreshDeadline.current = setTimeout(abandonRenewal, REFRESH_VERDICT_TIMEOUT_MS);
}, [abandonRenewal]);

// Silent renewal via the active AuthProvider. The provider collapses
// "renewal returned no usable credential" into onError, so the consumer
Expand All @@ -110,17 +129,21 @@ export function useAuth() {
if (sessionLapsed()) expireSession();
})
.finally(() => {
isRefreshing.current = false;
settleRefresh();
});
setRefreshEnabled(false);
},
onError: () => {
isRefreshing.current = false;
setRefreshEnabled(false);
abandonRenewal();
if (sessionLapsed()) expireSession();
},
});

// Clear any pending renewal deadline on unmount.
useEffect(() => () => {
if (refreshDeadline.current) clearTimeout(refreshDeadline.current);
}, []);

// On visibility change, verify the cookie. If rejected, try One Tap
// refresh; a network error means offline and never clears the session.
useEffect(() => {
Expand Down Expand Up @@ -157,9 +180,6 @@ export function useAuth() {
// Schedule silent refresh and an expiry-time server re-check from the
// session's real expiry.
useEffect(() => {
if (refreshTimer.current) clearTimeout(refreshTimer.current);
if (expiryTimer.current) clearTimeout(expiryTimer.current);

if (!auth) {
expiresAtRef.current = 0;
return;
Expand All @@ -169,15 +189,16 @@ export function useAuth() {
expiresAtRef.current = expiresAt;

// Silent refresh before expiry; immediately if already inside the buffer.
refreshTimer.current = setTimeout(
const refreshTimer = setTimeout(
triggerRefresh,
Math.max(expiresAt - REFRESH_BUFFER_MS - Date.now(), 0),
);

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

return () => {
if (refreshTimer.current) clearTimeout(refreshTimer.current);
if (expiryTimer.current) clearTimeout(expiryTimer.current);
clearTimeout(refreshTimer);
clearTimeout(expiryTimer);
};
}, [auth, applyAuth, expireSession, triggerRefresh]);

Expand Down
Loading