diff --git a/web/packages/design/src/CardError/CardError.jsx b/web/packages/design/src/CardError/CardError.jsx index 5ae1ce4d15727..7ae9537f66fb5 100644 --- a/web/packages/design/src/CardError/CardError.jsx +++ b/web/packages/design/src/CardError/CardError.jsx @@ -111,6 +111,20 @@ LoginFailed.propTypes = { loginUrl: PropTypes.string.isRequired, }; +export const LogoutFailed = ({ message, loginUrl }) => ( + +
Login Unsuccessful
+ + Return to login. + + } + /> +
+); + const HyperLink = styled.a` color: ${({ theme }) => theme.colors.buttons.link.default}; `; diff --git a/web/packages/design/src/CardError/index.js b/web/packages/design/src/CardError/index.js index 32416b26dde51..dfb3d91963b94 100644 --- a/web/packages/design/src/CardError/index.js +++ b/web/packages/design/src/CardError/index.js @@ -22,7 +22,8 @@ import CardError, { LoginFailed, AccessDenied, NotFound, + LogoutFailed, } from './CardError'; export default CardError; -export { Failed, LoginFailed, AccessDenied, NotFound, Offline }; +export { Failed, LoginFailed, AccessDenied, NotFound, Offline, LogoutFailed }; diff --git a/web/packages/teleport/src/Console/Console.tsx b/web/packages/teleport/src/Console/Console.tsx index a5464052fd7d7..698c6eebae84e 100644 --- a/web/packages/teleport/src/Console/Console.tsx +++ b/web/packages/teleport/src/Console/Console.tsx @@ -25,6 +25,8 @@ import useAttempt from 'shared/hooks/useAttemptNext'; import AjaxPoller from 'teleport/components/AjaxPoller'; +import { useTeleport } from '..'; + import { useConsoleContext, useStoreDocs } from './consoleContextProvider'; import * as stores from './stores/types'; import Tabs from './Tabs'; @@ -50,6 +52,7 @@ export default function Console() { const activeDoc = documents.find(d => d.id === activeDocId); const hasSshSessions = storeDocs.getSshDocuments().length > 0; const { attempt, run } = useAttempt(); + const teleportCtx = useTeleport(); React.useEffect(() => { run(() => consoleCtx.initStoreUser()); @@ -78,7 +81,7 @@ export default function Console() { } function onLogout() { - consoleCtx.logout(); + consoleCtx.logout(teleportCtx); } const disableNewTab = storeDocs.getNodeDocuments().length > 0; diff --git a/web/packages/teleport/src/Console/consoleContext.tsx b/web/packages/teleport/src/Console/consoleContext.tsx index e875bb557f73c..6c36bd3281b0b 100644 --- a/web/packages/teleport/src/Console/consoleContext.tsx +++ b/web/packages/teleport/src/Console/consoleContext.tsx @@ -37,6 +37,8 @@ import ClustersService from 'teleport/services/clusters'; import { StoreUserContext } from 'teleport/stores'; import usersService from 'teleport/services/user'; +import TeleportContext from 'teleport/teleportContext'; + import { StoreParties, StoreDocs, @@ -208,8 +210,8 @@ export default class ConsoleContext { }); } - logout() { - webSession.logout(); + logout(ctx: TeleportContext) { + webSession.logout(false, ctx.storeUser); } createTty(session: Session, mode?: ParticipantMode): Tty { diff --git a/web/packages/teleport/src/Player/Player.tsx b/web/packages/teleport/src/Player/Player.tsx index 7e5012660098e..d9399945529bb 100644 --- a/web/packages/teleport/src/Player/Player.tsx +++ b/web/packages/teleport/src/Player/Player.tsx @@ -31,6 +31,8 @@ import { getUrlParameter } from 'teleport/services/history'; import { RecordingType } from 'teleport/services/recordings'; +import { useTeleport } from '..'; + import ActionBar from './ActionBar'; import { DesktopPlayer } from './DesktopPlayer'; import SshPlayer from './SshPlayer'; @@ -41,6 +43,7 @@ const validRecordingTypes = ['ssh', 'k8s', 'desktop']; export function Player() { const { sid, clusterId } = useParams(); const { search } = useLocation(); + const ctx = useTeleport(); const recordingType = getUrlParameter( 'recordingType', @@ -54,7 +57,7 @@ export function Player() { document.title = `Play ${sid} • ${clusterId}`; function onLogout() { - session.logout(); + session.logout(false, ctx.storeUser); } if (!validRecordingType) { diff --git a/web/packages/teleport/src/SingleLogoutFailed/SingleLogoutFailed.tsx b/web/packages/teleport/src/SingleLogoutFailed/SingleLogoutFailed.tsx new file mode 100644 index 0000000000000..ff92f79895cf0 --- /dev/null +++ b/web/packages/teleport/src/SingleLogoutFailed/SingleLogoutFailed.tsx @@ -0,0 +1,43 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import { useLocation } from 'react-router'; +import { LogoutFailed } from 'design/CardError'; + +import { LogoHero } from 'teleport/components/LogoHero'; +import cfg from 'teleport/config'; + +export function SingleLogoutFailed() { + const { search } = useLocation(); + const params = new URLSearchParams(search); + const connectorName = params.get('connectorName'); + + const connectorNameText = connectorName + ? connectorName + : 'your SAML identity provider.'; + return ( + <> + + + + ); +} diff --git a/web/packages/teleport/src/SingleLogoutFailed/index.ts b/web/packages/teleport/src/SingleLogoutFailed/index.ts new file mode 100644 index 0000000000000..2ea4b7dadc5df --- /dev/null +++ b/web/packages/teleport/src/SingleLogoutFailed/index.ts @@ -0,0 +1,19 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export { SingleLogoutFailed } from './SingleLogoutFailed'; diff --git a/web/packages/teleport/src/Teleport.tsx b/web/packages/teleport/src/Teleport.tsx index de9525488f507..b5c15090b3d00 100644 --- a/web/packages/teleport/src/Teleport.tsx +++ b/web/packages/teleport/src/Teleport.tsx @@ -39,6 +39,7 @@ import { LoginTerminalRedirect } from './Login/LoginTerminalRedirect'; import { LoginClose } from './Login/LoginClose'; import { Login } from './Login'; import { Welcome } from './Welcome'; +import { SingleLogoutFailed } from './SingleLogoutFailed'; import { ConsoleWithContext as Console } from './Console'; import { Player } from './Player'; @@ -143,6 +144,12 @@ export function getSharedPublicRoutes() { path={cfg.routes.userReset} render={() => } />, + , ]; } diff --git a/web/packages/teleport/src/components/Authenticated/Authenticated.tsx b/web/packages/teleport/src/components/Authenticated/Authenticated.tsx index bd02604e5558b..af8fd1860e683 100644 --- a/web/packages/teleport/src/components/Authenticated/Authenticated.tsx +++ b/web/packages/teleport/src/components/Authenticated/Authenticated.tsx @@ -53,7 +53,7 @@ const Authenticated: React.FC = ({ children }) => { const checkIfUserIsAuthenticated = async () => { if (!session.isValid()) { logger.warn('invalid session'); - session.logout(true /* rememberLocation */); + session.logoutWithoutSlo(true /* rememberLocation */); return; } @@ -66,7 +66,7 @@ const Authenticated: React.FC = ({ children }) => { } catch (e) { if (e instanceof ApiError && e.response?.status == 403) { logger.warn('invalid session'); - session.logout(true /* rememberLocation */); + session.logoutWithoutSlo(true /* rememberLocation */); // No need to update attempt, as `logout` will // redirect user to login page. return; @@ -125,7 +125,7 @@ function startActivityChecker(ttl = 0) { // ie. browser still openend but all app tabs closed. if (isInactive(adjustedTtl)) { logger.warn('inactive session'); - session.logout(); + session.logoutWithoutSlo(); return; } @@ -135,7 +135,7 @@ function startActivityChecker(ttl = 0) { const intervalId = setInterval(() => { if (isInactive(adjustedTtl)) { logger.warn('inactive session'); - session.logout(); + session.logoutWithoutSlo(); } }, ACTIVITY_CHECKER_INTERVAL_MS); diff --git a/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx b/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx index 3760d962dd923..3e88ee5293ffe 100644 --- a/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx +++ b/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx @@ -159,17 +159,6 @@ export function UserMenuNav({ username, iconSize }: UserMenuNavProps) { transitionDelay += INCREMENT_TRANSITION_DELAY; } - function logout() { - if (ctx.storeUser.getIsSamlSloEnabled()) { - const sloUrl = ctx.storeUser.getSamlSloUrl(); - - session.logoutWithoutRedirect(); - window.open(sloUrl); - } else { - session.logout(); - } - } - return ( setOpen(!open)} open={open}> @@ -202,7 +191,9 @@ export function UserMenuNav({ username, iconSize }: UserMenuNavProps) { )} - logout()}> + session.logout(false, ctx.storeUser)} + > diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 72d5388c2d055..3fd67fc8a3e30 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -171,6 +171,7 @@ const cfg = { loginError: '/web/msg/error/login', loginErrorCallback: '/web/msg/error/login/callback', loginErrorUnauthorized: '/web/msg/error/login/auth', + samlSloFailed: '/web/msg/error/slo', userInvite: '/web/invite/:tokenId', userInviteContinue: '/web/invite/:tokenId/continue', userReset: '/web/reset/:tokenId', diff --git a/web/packages/teleport/src/services/websession/websession.ts b/web/packages/teleport/src/services/websession/websession.ts index f661a6a268f6d..26a1862fda9cf 100644 --- a/web/packages/teleport/src/services/websession/websession.ts +++ b/web/packages/teleport/src/services/websession/websession.ts @@ -23,6 +23,8 @@ import history from 'teleport/services/history'; import api from 'teleport/services/api'; import { KeysEnum, storageService } from 'teleport/services/storageService'; +import { StoreUserContext } from 'teleport/stores'; + import makeBearerToken from './makeBearerToken'; import { RenewSessionRequest } from './types'; @@ -35,14 +37,31 @@ const logger = Logger.create('services/session'); let sesstionCheckerTimerId = null; const session = { - logout(rememberLocation = false) { + /** logout logs the user out of Teleport, and if SAML SLO is enabled, also log them out of their IdP. */ + logout(rememberLocation = false, userCtx: StoreUserContext) { api.delete(cfg.api.webSessionPath).finally(() => { - history.goToLogin(rememberLocation); + // If SAML SLO (single logout) is enabled, the user will be redirected to the IdP's SLO URL, after which they will automatically be redirected back + // to the login page. + if (userCtx?.getIsSamlSloEnabled()) { + const sloUrl = userCtx.getSamlSloUrl(); + window.open(sloUrl, '_self'); + } else { + history.goToLogin(rememberLocation); + } }); this.clear(); }, + /** logoutWithoutSlo logs the user out of Teleport, but not their SAML IdP. */ + logoutWithoutSlo(rememberLocation = false) { + api + .delete(cfg.api.webSessionPath) + .finally(() => history.goToLogin(rememberLocation)); + + this.clear(); + }, + logoutWithoutRedirect() { api.delete(cfg.api.webSessionPath); @@ -254,7 +273,7 @@ function receiveMessage(event) { // check if logout was triggered from other tabs if (storageService.getBearerToken() === null) { - session.logout(); + session.logoutWithoutSlo(); } // check if token is being renewed from another tab