From e37e872da7f02570ac0037493866c54d0077d77e Mon Sep 17 00:00:00 2001 From: mkaminsk Date: Tue, 3 Feb 2026 15:15:45 +0100 Subject: [PATCH] bug: Session not cleared upon different logons https://track.akamai.com/jira/browse/LILO-1461 --- packages/manager/src/OAuth/oauth.ts | 2 + .../src/hooks/useInitialRequests.test.ts | 94 +++++++++++++++++++ .../manager/src/hooks/useInitialRequests.ts | 92 +++++++++++++++++- 3 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 packages/manager/src/hooks/useInitialRequests.test.ts diff --git a/packages/manager/src/OAuth/oauth.ts b/packages/manager/src/OAuth/oauth.ts index 3d111dbecbe..551e0259761 100644 --- a/packages/manager/src/OAuth/oauth.ts +++ b/packages/manager/src/OAuth/oauth.ts @@ -328,3 +328,5 @@ export async function handleLoginAsCustomerCallback( expiresIn: params.expires_in, }; } + +export { getLoginURL } from './constants'; diff --git a/packages/manager/src/hooks/useInitialRequests.test.ts b/packages/manager/src/hooks/useInitialRequests.test.ts new file mode 100644 index 00000000000..2650b8d2bc4 --- /dev/null +++ b/packages/manager/src/hooks/useInitialRequests.test.ts @@ -0,0 +1,94 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { storage } from 'src/utilities/storage'; + +import { useInitialRequests } from './useInitialRequests'; + +vi.stubEnv('REACT_APP_CLIENT_ID', 'test-client-id'); +vi.stubEnv('REACT_APP_LOGIN_ROOT', 'https://login.test'); + +const queryClientMock = { + prefetchQuery: vi.fn().mockResolvedValue(undefined), +}; + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useQueryClient: () => queryClientMock, + }; +}); + +const oauthMocks = vi.hoisted(() => ({ + validateTokenAndSession: vi.fn(), +})); + +vi.mock('src/OAuth/oauth', async () => { + const actual = await vi.importActual('src/OAuth/oauth'); + return { + ...actual, + validateTokenAndSession: oauthMocks.validateTokenAndSession, + }; +}); + +describe('OAuth token verification and initial data fetch', () => { + beforeEach(() => { + vi.resetAllMocks(); + storage.authentication.token.clear(); + vi.mocked(require('react-redux')).useSelector = vi.fn().mockReturnValue(false); + }); + + it('redirects to logout when login server reports the token does not match the session', async () => { + storage.authentication.token.set('Bearer faketoken'); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ match: false }), + } as any); + + const { result } = renderHook(() => useInitialRequests()); + + await waitFor(() => expect(oauthMocks.validateTokenAndSession).toHaveBeenCalled()); + }); + + it('runs initial requests when login server confirms the token belongs to the session (USER_MATCH)', async () => { + storage.authentication.token.set('Bearer faketoken'); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ match: true }), + } as any); + + const { result } = renderHook(() => useInitialRequests()); + + await waitFor(() => expect(queryClientMock.prefetchQuery).toHaveBeenCalled()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(oauthMocks.validateTokenAndSession).not.toHaveBeenCalled(); + }); + + it('falls back to running initial requests if token verification fetch fails (network/error)', async () => { + storage.authentication.token.set('Bearer faketoken'); + + global.fetch = vi.fn().mockRejectedValue(new Error('network')); + + const { result } = renderHook(() => useInitialRequests()); + + await waitFor(() => expect(queryClientMock.prefetchQuery).toHaveBeenCalled()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(oauthMocks.validateTokenAndSession).not.toHaveBeenCalled(); + }); + + it('does not call the login server verify endpoint for Admin tokens', async () => { + storage.authentication.token.set('Admin admintoken'); + + global.fetch = vi.fn().mockRejectedValue(new Error('should-not-be-called')); + + const { result } = renderHook(() => useInitialRequests()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/hooks/useInitialRequests.ts b/packages/manager/src/hooks/useInitialRequests.ts index 1d52e78e321..18163e2b9d7 100644 --- a/packages/manager/src/hooks/useInitialRequests.ts +++ b/packages/manager/src/hooks/useInitialRequests.ts @@ -1,20 +1,106 @@ import { accountQueries, profileQueries } from '@linode/queries'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; +import { useSelector } from 'react-redux'; + +import { getClientId } from 'src/OAuth/constants'; +import { + clearStorageAndRedirectToLogout, + getIsAdminToken, + getLoginURL, + redirectToLogin, +} from 'src/OAuth/oauth'; +import { storage } from 'src/utilities/storage'; + +import type { ApplicationState } from 'src/store'; /** * This hook is responsible for making Cloud Manager's initial requests. + * It also verifies that the token in localStorage belongs to the same user + * as the Flask session cookie (via /oauth/verify). + * * It exposes a `isLoading` value so that we can render a loading page - * as we make our inital requests. + * as we make our initial requests. */ export const useInitialRequests = () => { const queryClient = useQueryClient(); + const token = storage.authentication.token.get(); + const tokenExists = Boolean(token); + + const pendingUpload = useSelector( + (state: ApplicationState) => state.pendingUpload + ); + const [isLoading, setIsLoading] = React.useState(true); React.useEffect(() => { - makeInitialRequests(); - }, []); + const isAuthCallback = + window.location.pathname === '/oauth/callback' || + window.location.pathname === '/admin/callback'; + + if (isAuthCallback) { + setIsLoading(false); + return; + } + + if (!tokenExists && !pendingUpload) { + redirectToLogin(); + return; + } + + if (!tokenExists) { + setIsLoading(false); + return; + } + + validateTokenAndSession(); + }, [tokenExists, pendingUpload]); + + const validateTokenAndSession = async () => { + const storedToken = storage.authentication.token.get(); + + if (!storedToken) { + makeInitialRequests(); + return; + } + + if (getIsAdminToken(storedToken)) { + makeInitialRequests(); + return; + } + + try { + const tokenValue = storedToken.replace(/^Bearer\s+/i, ''); + + const response = await fetch( + `${getLoginURL()}/oauth/verify?client_id=${getClientId()}`, + { + credentials: 'include', + headers: { + Authorization: `Bearer ${tokenValue}`, + }, + method: 'POST', + } + ); + + if (response.ok) { + const result = await response.json(); + + if (result.match === true) { + makeInitialRequests(); + return; + } + + clearStorageAndRedirectToLogout(); + return; + } + + clearStorageAndRedirectToLogout(); + } catch (error) { + makeInitialRequests(); + } + }; /** * We make a series of requests for data on app load. The flow is: