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 (
+
+ );
+}
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();