diff --git a/web/packages/teleport/src/Login/Login.test.tsx b/web/packages/teleport/src/Login/Login.test.tsx
index 4a3247783f7d0..eebc20c607fe3 100644
--- a/web/packages/teleport/src/Login/Login.test.tsx
+++ b/web/packages/teleport/src/Login/Login.test.tsx
@@ -23,6 +23,7 @@ import { render, fireEvent, screen, waitFor } from 'design/utils/testing';
import auth from 'teleport/services/auth/auth';
import history from 'teleport/services/history';
+import session from 'teleport/services/websession';
import cfg from 'teleport/config';
import { Login } from './Login';
@@ -32,6 +33,7 @@ let user: UserEvent;
beforeEach(() => {
jest.restoreAllMocks();
jest.spyOn(history, 'push').mockImplementation();
+ jest.spyOn(history, 'replace').mockImplementation();
jest.spyOn(history, 'getRedirectParam').mockImplementation(() => '/');
user = userEvent.setup();
});
@@ -185,3 +187,25 @@ describe('test MOTD', () => {
).not.toBeInTheDocument();
});
});
+
+test('redirect to root if session is valid and path is not "/enterprise/saml-idp/sso"', () => {
+ jest.spyOn(session, 'isValid').mockImplementation(() => true);
+ jest
+ .spyOn(history, 'getRedirectParam')
+ .mockReturnValue(
+ 'http://localhost/web/login?redirect_url=http://localhost/web/cluster/localhost/resources'
+ );
+ render();
+
+ expect(history.replace).toHaveBeenCalledWith('/web');
+});
+
+test('redirect if session is valid and path matches "/enterprise/saml-idp/sso"', () => {
+ const samlIdPPath = new URL('http://localhost' + cfg.routes.samlIdpSso);
+ jest.spyOn(session, 'isValid').mockImplementation(() => true);
+ jest
+ .spyOn(history, 'getRedirectParam')
+ .mockReturnValue(samlIdPPath.toString());
+ render();
+ expect(history.push).toHaveBeenCalledWith(samlIdPPath, true);
+});
diff --git a/web/packages/teleport/src/Login/useLogin.test.tsx b/web/packages/teleport/src/Login/useLogin.test.tsx
new file mode 100644
index 0000000000000..d47f1b916599b
--- /dev/null
+++ b/web/packages/teleport/src/Login/useLogin.test.tsx
@@ -0,0 +1,100 @@
+/**
+ * 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 { renderHook } from '@testing-library/react';
+
+import history from 'teleport/services/history';
+import session from 'teleport/services/websession';
+import cfg from 'teleport/config';
+
+import useLogin from './useLogin';
+
+beforeEach(() => {
+ jest.restoreAllMocks();
+ jest.spyOn(session, 'isValid').mockImplementation(() => true);
+ jest.spyOn(history, 'push').mockImplementation();
+ jest.spyOn(history, 'replace').mockImplementation();
+ jest.mock('shared/hooks', () => ({
+ useAttempt: () => {
+ return [
+ { status: 'success', statusText: 'Success Text' },
+ {
+ clear: jest.fn(),
+ },
+ ];
+ },
+ }));
+});
+
+afterEach(() => {
+ jest.resetAllMocks();
+});
+
+it('redirect to root on path not matching "/enterprise/saml-idp/sso"', () => {
+ jest.spyOn(history, 'getRedirectParam').mockReturnValue('http://localhost');
+ renderHook(() => useLogin());
+ expect(history.replace).toHaveBeenCalledWith('/web');
+
+ jest
+ .spyOn(history, 'getRedirectParam')
+ .mockReturnValue('http://localhost/web/cluster/name/resources');
+ renderHook(() => useLogin());
+ expect(history.replace).toHaveBeenCalledWith('/web');
+});
+
+it('redirect to SAML SSO path on matching "/enterprise/saml-idp/sso"', () => {
+ const samlIdpPath = new URL('http://localhost' + cfg.routes.samlIdpSso);
+ cfg.baseUrl = 'http://localhost';
+ jest
+ .spyOn(history, 'getRedirectParam')
+ .mockReturnValue(samlIdpPath.toString());
+ renderHook(() => useLogin());
+ expect(history.push).toHaveBeenCalledWith(samlIdpPath, true);
+});
+
+it('non-base domain redirects with base domain for a matching "/enterprise/saml-idp/sso"', async () => {
+ const samlIdpPath = new URL('http://different-base' + cfg.routes.samlIdpSso);
+ jest
+ .spyOn(history, 'getRedirectParam')
+ .mockReturnValue(samlIdpPath.toString());
+ renderHook(() => useLogin());
+ const expectedPath = new URL('http://localhost' + cfg.routes.samlIdpSso);
+ expect(history.push).toHaveBeenCalledWith(expectedPath, true);
+});
+
+it('base domain with different path is redirected to root', async () => {
+ const nonSamlIdpPath = new URL('http://localhost/web/cluster/name/resources');
+ jest
+ .spyOn(history, 'getRedirectParam')
+ .mockReturnValue(nonSamlIdpPath.toString());
+ renderHook(() => useLogin());
+ expect(history.replace).toHaveBeenCalledWith('/web');
+});
+
+it('invalid session does nothing', async () => {
+ const samlIdpPathWithDifferentBase = new URL(
+ 'http://different-base' + cfg.routes.samlIdpSso
+ );
+ jest
+ .spyOn(history, 'getRedirectParam')
+ .mockReturnValue(samlIdpPathWithDifferentBase.toString());
+ jest.spyOn(session, 'isValid').mockImplementation(() => false);
+ renderHook(() => useLogin());
+ expect(history.replace).not.toHaveBeenCalled();
+ expect(history.push).not.toHaveBeenCalled();
+});
diff --git a/web/packages/teleport/src/Login/useLogin.ts b/web/packages/teleport/src/Login/useLogin.ts
index 17aed626c158e..1b19d7997425b 100644
--- a/web/packages/teleport/src/Login/useLogin.ts
+++ b/web/packages/teleport/src/Login/useLogin.ts
@@ -17,6 +17,7 @@
*/
import { useState, useEffect } from 'react';
+import { matchPath } from 'react-router';
import { useAttempt } from 'shared/hooks';
import { AuthProvider } from 'shared/services';
@@ -48,8 +49,25 @@ export default function useLogin() {
useEffect(() => {
if (session.isValid()) {
- history.replace(cfg.routes.root);
- return;
+ try {
+ const redirectUrlWithBase = new URL(getEntryRoute());
+ const matched = matchPath(redirectUrlWithBase.pathname, {
+ path: cfg.routes.samlIdpSso,
+ strict: true,
+ exact: true,
+ });
+ if (matched) {
+ history.push(redirectUrlWithBase, true);
+ return;
+ } else {
+ history.replace(cfg.routes.root);
+ return;
+ }
+ } catch (e) {
+ console.error(e);
+ history.replace(cfg.routes.root);
+ return;
+ }
}
setCheckingValidSession(false);
}, []);
@@ -106,6 +124,11 @@ function onSuccess() {
history.push(redirect, withPageRefresh);
}
+/**
+ * getEntryRoute returns a base ensured redirect URL value that is safe
+ * for redirect.
+ * @returns base ensured URL string.
+ */
function getEntryRoute() {
let entryUrl = history.getRedirectParam();
if (entryUrl) {
diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts
index 2fbe5d00c0d80..27c149920eeab 100644
--- a/web/packages/teleport/src/config.ts
+++ b/web/packages/teleport/src/config.ts
@@ -189,6 +189,9 @@ const cfg = {
oidcHandler: '/v1/webapi/oidc/*',
samlHandler: '/v1/webapi/saml/*',
githubHandler: '/v1/webapi/github/*',
+
+ /** samlIdpSso is an exact path of the service provider initiated SAML SSO endpoint. */
+ samlIdpSso: '/enterprise/saml-idp/sso',
},
api: {