diff --git a/web/src/auth-state.ts b/web/src/auth-state.ts index 74da7779a..e9f994805 100644 --- a/web/src/auth-state.ts +++ b/web/src/auth-state.ts @@ -61,11 +61,10 @@ export const getAccessToken = (): string | null => { accessToken = storedToken; tokenExpiresAt = expiresAt; } - // Do NOT remove expired tokens here. Callers such as InstanceContext.initialize() - // run concurrently with AuthContext.initialize() via Promise.all. If we eagerly - // delete the expired token from localStorage, hasStoredToken() (called synchronously - // inside AuthContext.initialize()) finds nothing and skips the refresh attempt, - // logging the user out even when the refresh-token cookie is still valid. + // Do NOT remove expired tokens here. getRequestToken() in connect.ts calls + // hasStoredToken() to decide whether to attempt a refresh — if we eagerly delete + // the expired token, it returns null immediately, skipping the refresh and sending + // the request without credentials. // clearAccessToken() handles proper cleanup after a confirmed auth failure or logout. } } catch (e) { diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 0ce0741dd..943b4a207 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; -import { clearAccessToken, hasStoredToken } from "@/auth-state"; -import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/connect"; +import { clearAccessToken, getAccessToken } from "@/auth-state"; +import { authServiceClient, refreshAccessToken, shortcutServiceClient, userServiceClient } from "@/connect"; import { userKeys } from "@/hooks/useUserQueries"; import type { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb"; import type { User, UserSetting_GeneralSetting, UserSetting_WebhooksSetting } from "@/types/proto/api/v1/user_service_pb"; @@ -53,9 +53,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { const initialize = useCallback(async () => { setState((prev) => ({ ...prev, isLoading: true })); - // If there is no stored token at all, the user is not authenticated. - // Skip the network call — there is nothing to refresh and no session to restore. - if (!hasStoredToken()) { + // Try to get or refresh the access token. + // This handles PWA isolated storage scenarios (e.g., iOS Safari) where localStorage + // may be empty but a valid HTTP-only refresh token cookie still exists. + // getAccessToken() returns a cached token or loads from localStorage if valid. + if (!getAccessToken()) { + try { + await refreshAccessToken(); + } catch { + // Refresh failed - no valid session + } + } + + // If we still don't have a token after refresh attempt, skip getCurrentUser call + // to avoid unnecessary network request for unauthenticated users. + if (!getAccessToken()) { setState({ currentUser: undefined, userGeneralSetting: undefined,