Skip to content
Draft
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
2 changes: 2 additions & 0 deletions packages/manager/src/OAuth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,5 @@ export async function handleLoginAsCustomerCallback(
expiresIn: params.expires_in,
};
}

export { getLoginURL } from './constants';
94 changes: 94 additions & 0 deletions packages/manager/src/hooks/useInitialRequests.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
92 changes: 89 additions & 3 deletions packages/manager/src/hooks/useInitialRequests.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand Down