diff --git a/web/packages/teleport/src/components/Authenticated/Authenticated.test.tsx b/web/packages/teleport/src/components/Authenticated/Authenticated.test.tsx new file mode 100644 index 0000000000000..f5c3458fe21b5 --- /dev/null +++ b/web/packages/teleport/src/components/Authenticated/Authenticated.test.tsx @@ -0,0 +1,130 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { render, screen, waitFor } from 'design/utils/testing'; + +import session from 'teleport/services/websession'; +import { ApiError } from 'teleport/services/api/parseError'; +import api from 'teleport/services/api'; +import history from 'teleport/services/history'; + +import Authenticated from './Authenticated'; + +jest.mock('shared/libs/logger', () => { + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + }; + + return { + create: () => mockLogger, + }; +}); + +describe('session', () => { + beforeEach(() => { + jest.spyOn(session, 'isValid').mockImplementation(() => true); + jest.spyOn(session, 'validateCookieAndSession').mockResolvedValue(null); + jest.spyOn(session, 'ensureSession').mockImplementation(); + jest.spyOn(session, 'getInactivityTimeout').mockImplementation(() => 0); + jest.spyOn(session, 'clear').mockImplementation(); + jest.spyOn(api, 'get').mockResolvedValue(null); + jest.spyOn(api, 'delete').mockResolvedValue(null); + jest.spyOn(history, 'goToLogin').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('valid session and valid cookie', async () => { + render( + +
hello world
+
+ ); + + const targetEl = await screen.findByText(/hello world/i); + + expect(targetEl).toBeInTheDocument(); + expect(session.isValid).toHaveBeenCalledTimes(1); + expect(session.validateCookieAndSession).toHaveBeenCalledTimes(1); + expect(session.ensureSession).toHaveBeenCalledTimes(1); + expect(history.goToLogin).not.toHaveBeenCalled(); + }); + + test('valid session and invalid cookie', async () => { + const mockForbiddenError = new ApiError('some error', { + status: 403, + } as Response); + + jest + .spyOn(session, 'validateCookieAndSession') + .mockRejectedValue(mockForbiddenError); + + render( + +
hello world
+
+ ); + + await waitFor(() => expect(history.goToLogin).toHaveBeenCalledTimes(1)); + expect(session.clear).toHaveBeenCalledTimes(1); + + expect(screen.queryByText(/hello world/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/go to login/i)).not.toBeInTheDocument(); + expect(session.ensureSession).not.toHaveBeenCalled(); + }); + + test('invalid session', async () => { + jest.spyOn(session, 'isValid').mockImplementation(() => false); + + render( + +
hello world
+
+ ); + + await waitFor(() => expect(session.clear).toHaveBeenCalledTimes(1)); + expect(history.goToLogin).toHaveBeenCalledTimes(1); + + expect(screen.queryByText(/hello world/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/go to login/i)).not.toBeInTheDocument(); + expect(session.validateCookieAndSession).not.toHaveBeenCalled(); + expect(session.ensureSession).not.toHaveBeenCalled(); + }); + + test('non-authenticated related error', async () => { + jest + .spyOn(session, 'validateCookieAndSession') + .mockRejectedValue(new Error('some network error')); + + render( + +
hello world
+
+ ); + + const targetEl = await screen.findByText('some network error'); + expect(targetEl).toBeInTheDocument(); + + expect(screen.queryByText(/hello world/i)).not.toBeInTheDocument(); + expect(session.ensureSession).not.toHaveBeenCalled(); + expect(history.goToLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/web/packages/teleport/src/components/Authenticated/Authenticated.tsx b/web/packages/teleport/src/components/Authenticated/Authenticated.tsx index cc5ad9f0d8851..688c242567c9c 100644 --- a/web/packages/teleport/src/components/Authenticated/Authenticated.tsx +++ b/web/packages/teleport/src/components/Authenticated/Authenticated.tsx @@ -14,13 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { throttle } from 'shared/utils/highbar'; import Logger from 'shared/libs/logger'; +import useAttempt from 'shared/hooks/useAttemptNext'; +import { getErrMessage } from 'shared/utils/errorType'; +import { Box, Indicator } from 'design'; import session from 'teleport/services/websession'; -import history from 'teleport/services/history'; import localStorage from 'teleport/services/localStorage'; +import { ApiError } from 'teleport/services/api/parseError'; + +import { ErrorDialog } from './ErrorDialogue'; const logger = Logger.create('/components/Authenticated'); const ACTIVITY_CHECKER_INTERVAL_MS = 30 * 1000; @@ -39,11 +44,37 @@ const events = [ ]; const Authenticated: React.FC = ({ children }) => { - React.useEffect(() => { - if (!session.isValid()) { - logger.warn('invalid session'); - session.clear(); - history.goToLogin(true); + const { attempt, setAttempt } = useAttempt('processing'); + + useEffect(() => { + const checkIfUserIsAuthenticated = async () => { + if (!session.isValid()) { + logger.warn('invalid session'); + session.logout(true /* rememberLocation */); + return; + } + + try { + await session.validateCookieAndSession(); + setAttempt({ status: 'success' }); + } catch (e) { + if (e instanceof ApiError && e.response?.status == 403) { + logger.warn('invalid session'); + session.logout(true /* rememberLocation */); + // No need to update attempt, as `logout` will + // redirect user to login page. + return; + } + // Error unrelated to authentication failure (network blip). + setAttempt({ status: 'failed', statusText: getErrMessage(e) }); + } + }; + + checkIfUserIsAuthenticated(); + }, []); + + useEffect(() => { + if (attempt.status !== 'success') { return; } @@ -55,13 +86,21 @@ const Authenticated: React.FC = ({ children }) => { } return startActivityChecker(inactivityTtl); - }, []); + }, [attempt.status]); + + if (attempt.status === 'success') { + return <>{children}; + } - if (!session.isValid()) { - return null; + if (attempt.status === 'failed') { + return ; } - return <>{children}; + return ( + + + + ); }; export default Authenticated; diff --git a/web/packages/teleport/src/components/Authenticated/ErrorDialogue.story.tsx b/web/packages/teleport/src/components/Authenticated/ErrorDialogue.story.tsx new file mode 100644 index 0000000000000..1ecdc1d25ce4a --- /dev/null +++ b/web/packages/teleport/src/components/Authenticated/ErrorDialogue.story.tsx @@ -0,0 +1,25 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { ErrorDialog } from './ErrorDialogue'; + +export default { + title: 'Teleport/Authenticate/ErrorDialogue', +}; + +export const Story = () => ; diff --git a/web/packages/teleport/src/components/Authenticated/ErrorDialogue.tsx b/web/packages/teleport/src/components/Authenticated/ErrorDialogue.tsx new file mode 100644 index 0000000000000..05cdc278af90c --- /dev/null +++ b/web/packages/teleport/src/components/Authenticated/ErrorDialogue.tsx @@ -0,0 +1,50 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Text, Alert, ButtonSecondary } from 'design'; +import Dialog, { + DialogHeader, + DialogTitle, + DialogContent, + DialogFooter, +} from 'design/Dialog'; + +import history from 'teleport/services/history'; + +export function ErrorDialog({ errMsg }: { errMsg: string }) { + return ( + ({ maxWidth: '500px', width: '100%' })} + open={true} + > + + An error has occured + + + + Try again by refreshing the page. + + + history.goToLogin(true /* rememberLocation */)} + > + Go to Login + + + + ); +} diff --git a/web/packages/teleport/src/services/websession/websession.ts b/web/packages/teleport/src/services/websession/websession.ts index 54eb5daec8d09..569033663ef7d 100644 --- a/web/packages/teleport/src/services/websession/websession.ts +++ b/web/packages/teleport/src/services/websession/websession.ts @@ -33,9 +33,9 @@ const logger = Logger.create('services/session'); let sesstionCheckerTimerId = null; const session = { - logout() { + logout(rememberLocation = false) { api.delete(cfg.api.webSessionPath).finally(() => { - history.goToLogin(); + history.goToLogin(rememberLocation); }); this.clear(); @@ -75,6 +75,11 @@ const session = { return this._renewToken(req).then(token => token.sessionExpires); }, + /** + * isValid first extracts bearer token from HTML if + * not already extracted and sets in the local storage. + * Then checks if token is not expired. + */ isValid() { return this._timeLeft() > 0; }, @@ -190,7 +195,7 @@ const session = { }, _fetchStatus() { - api.get(cfg.api.userStatusPath).catch(err => { + this.validateCookieAndSession().catch(err => { // this indicates that session is no longer valid (caused by server restarts or updates) if (err.response.status == 403) { this.logout(); @@ -198,6 +203,14 @@ const session = { }); }, + /** + * validateCookieAndSessionFromBackend makes an authenticated request + * which checks if the cookie and the user session are still valid. + */ + validateCookieAndSession() { + return api.get(cfg.api.userStatusPath); + }, + _startTokenChecker() { this._stopTokenChecker();