From b66a019bc36b3bcec393b2a8db35d0e2684210b5 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Sun, 23 Feb 2025 19:20:21 +0530 Subject: [PATCH] fix(ui): forever loading for oidc auth refresh failure (#19854) * fix(ui): forever loading for oidc auth refresh failure * fix redirect login issue with iframe added tests for iframe vs popup login added tests for clearing localState for refresh api failures --- .../MsalAuthenticator.test.tsx | 176 ++++++++++-- .../AppAuthenticators/MsalAuthenticator.tsx | 13 +- .../OidcAuthenticator.test.tsx | 251 ++++++++++++++++++ .../AppAuthenticators/OidcAuthenticator.tsx | 3 + 4 files changed, 412 insertions(+), 31 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.test.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.test.tsx index ac2036d510e..8ec4da1fa0d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.test.tsx @@ -10,42 +10,160 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { render, screen } from '@testing-library/react'; +import { InteractionStatus } from '@azure/msal-browser'; +import { useMsal } from '@azure/msal-react'; +import { act, render, screen } from '@testing-library/react'; import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; +import { msalLoginRequest } from '../../../utils/AuthProvider.util'; +import { AuthenticatorRef } from '../AuthProviders/AuthProvider.interface'; import MsalAuthenticator from './MsalAuthenticator'; -jest.mock('../../../hooks/useApplicationStore', () => { - return { - useApplicationStore: jest.fn(() => ({ - authConfig: {}, - getOidcToken: jest.fn(), - setOidcToken: jest.fn(), - })), - }; -}); +// Mock MSAL hooks and utilities +jest.mock('@azure/msal-react', () => ({ + useMsal: jest.fn(), + useAccount: jest.fn(), +})); + +jest.mock('../../../utils/AuthProvider.util', () => ({ + msalLoginRequest: { + scopes: ['test.scope'], + }, + parseMSALResponse: jest.fn().mockImplementation((response) => ({ + id_token: 'mock-id-token', + ...response, + })), +})); + +const mockInstance = { + loginPopup: jest.fn(), + loginRedirect: jest.fn(), + handleRedirectPromise: jest.fn(), + ssoSilent: jest.fn(), +}; + +const mockProps = { + children:
Test Children
, + onLoginSuccess: jest.fn(), + onLoginFailure: jest.fn(), + onLogoutSuccess: jest.fn(), +}; + +describe('MsalAuthenticator', () => { + let authenticatorRef: AuthenticatorRef | null = null; + + beforeEach(() => { + jest.clearAllMocks(); + // Default mock implementation for useMsal + (useMsal as jest.Mock).mockReturnValue({ + instance: mockInstance, + accounts: [{ username: 'test@example.com' }], + inProgress: InteractionStatus.None, + }); + }); + + it('should handle login in iframe using popup', async () => { + // Mock window.self !== window.top for iframe detection + Object.defineProperty(window, 'self', { + value: { location: {} }, + writable: true, + }); + Object.defineProperty(window, 'top', { + value: { location: {} }, + writable: true, + }); + + mockInstance.loginPopup.mockResolvedValueOnce({ + account: { username: 'test@example.com' }, + }); -describe('Test MsalAuthenticator component', () => { - it('Checks if the MsalAuthenticator renders', async () => { - const onLogoutMock = jest.fn(); - const onLoginMock = jest.fn(); - const onFailedLoginMock = jest.fn(); - const authenticatorRef = null; render( -

- , - { - wrapper: MemoryRouter, - } + {...mockProps} + ref={(ref) => (authenticatorRef = ref)} + /> ); - const children = screen.getByTestId('children'); - expect(children).toBeInTheDocument(); + await act(async () => { + authenticatorRef?.invokeLogin(); + }); + + expect(mockInstance.loginPopup).toHaveBeenCalledWith(msalLoginRequest); + expect(mockProps.onLoginSuccess).toHaveBeenCalled(); + }); + + it('should handle login in normal window using redirect', async () => { + // Mock window.self === window.top for normal window detection + Object.defineProperty(window, 'self', { + value: window, + writable: true, + }); + Object.defineProperty(window, 'top', { + value: window, + writable: true, + }); + + render( + (authenticatorRef = ref)} + /> + ); + + await act(async () => { + authenticatorRef?.invokeLogin(); + }); + + expect(mockInstance.loginRedirect).toHaveBeenCalledWith(msalLoginRequest); + }); + + it('should handle logout', async () => { + render( + (authenticatorRef = ref)} + /> + ); + + await act(async () => { + authenticatorRef?.invokeLogout(); + }); + + expect(mockProps.onLogoutSuccess).toHaveBeenCalled(); + }); + + it('should handle renewIdToken successfully', async () => { + mockInstance.ssoSilent.mockResolvedValueOnce({ + account: { username: 'test@example.com' }, + idToken: 'new-token', + }); + + render( + (authenticatorRef = ref)} + /> + ); + + const result = await authenticatorRef?.renewIdToken(); + + expect(mockInstance.ssoSilent).toHaveBeenCalled(); + expect(result).toBe('mock-id-token'); + }); + + it('should show loader when interaction is in progress', () => { + (useMsal as jest.Mock).mockReturnValue({ + instance: mockInstance, + accounts: [{ username: 'test@example.com' }], + inProgress: InteractionStatus.Login, + }); + + render( + (authenticatorRef = ref)} + /> + ); + + expect(screen.getByTestId('loader')).toBeInTheDocument(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx index 470af128a46..e3e766e0793 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx @@ -63,8 +63,17 @@ const MsalAuthenticator = forwardRef( const login = async () => { try { - // Use login with redirect to avoid popup issue with maximized browser window - await instance.loginRedirect(); + const isInIframe = window.self !== window.top; + + if (isInIframe) { + // Use popup login when in iframe to avoid redirect issues + const response = await instance.loginPopup(msalLoginRequest); + + onLoginSuccess(parseMSALResponse(response)); + } else { + // Use login with redirect for normal window context + await instance.loginRedirect(msalLoginRequest); + } } catch (error) { onLoginFailure(error as AxiosError); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.test.tsx new file mode 100644 index 00000000000..67d0360e799 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.test.tsx @@ -0,0 +1,251 @@ +/* + * Copyright 2025 Collate. + * 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 { act, render, screen } from '@testing-library/react'; +import { User } from 'oidc-client'; +import React from 'react'; +import { Callback } from 'react-oidc'; +import { MemoryRouter } from 'react-router-dom'; +import { ROUTES } from '../../../constants/constants'; +import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import TokenService from '../../../utils/Auth/TokenService/TokenServiceUtil'; +import { setOidcToken } from '../../../utils/LocalStorageUtils'; +import { + AuthenticatorRef, + OidcUser, +} from '../AuthProviders/AuthProvider.interface'; +import OidcAuthenticator from './OidcAuthenticator'; + +const mockOnLoginSuccess = jest.fn(); +const mockOnLoginFailure = jest.fn(); +const mockOnLogoutSuccess = jest.fn(); + +// Mock dependencies +jest.mock('../../../utils/LocalStorageUtils'); +jest.mock('../../../utils/Auth/TokenService/TokenServiceUtil'); +jest.mock('../../../hooks/useApplicationStore'); +jest.mock('react-oidc', () => ({ + ...jest.requireActual('react-oidc'), + Callback: jest.fn().mockImplementation(() => { + return

Callback
; + }), +})); + +const mockHistoryPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); + +describe('OidcAuthenticator - Silent SignIn Tests', () => { + const mockUpdateAxiosInterceptors = jest.fn(); + const mockTokenServiceInstance = { + clearRefreshInProgress: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (TokenService.getInstance as jest.Mock).mockReturnValue( + mockTokenServiceInstance + ); + (useApplicationStore as unknown as jest.Mock).mockReturnValue({ + updateAxiosInterceptors: mockUpdateAxiosInterceptors, + }); + }); + + it('should handle silent sign-in success', async () => { + const mockUser = { + id_token: 'mock-id-token', + } as User; + + render( + +
Child Component
} + userConfig={{}} + onLoginFailure={mockOnLoginFailure} + onLoginSuccess={mockOnLoginSuccess} + onLogoutSuccess={mockOnLogoutSuccess}> +
Protected Content
+
+
+ ); + + expect(Callback).toHaveBeenCalled(); + + // Get the Callback component's onSuccess prop and call it + const callbackProps = (Callback as jest.Mock).mock.calls[0][0]; + await act(async () => { + callbackProps.onSuccess(mockUser); + }); + + // Verify the success flow + expect(setOidcToken).toHaveBeenCalledWith('mock-id-token'); + expect(mockUpdateAxiosInterceptors).toHaveBeenCalled(); + expect(mockTokenServiceInstance.clearRefreshInProgress).toHaveBeenCalled(); + }); + + it('should handle silent sign-in failure', async () => { + const mockError = new Error('Silent sign-in failed'); + + render( + +
Child Component
} + userConfig={{}} + onLoginFailure={mockOnLoginFailure} + onLoginSuccess={mockOnLoginSuccess} + onLogoutSuccess={mockOnLogoutSuccess}> +
Protected Content
+
+
+ ); + + // Get the Callback component's onError prop and call it + const callbackProps = (Callback as jest.Mock).mock.calls[0][0]; + await act(async () => { + callbackProps.onError(mockError); + }); + + // Verify the failure flow + expect(mockTokenServiceInstance.clearRefreshInProgress).toHaveBeenCalled(); + expect(mockOnLogoutSuccess).toHaveBeenCalled(); + expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.SIGNIN); + }); +}); + +describe('OidcAuthenticator - Login Tests', () => { + const mockUpdateAxiosInterceptors = jest.fn(); + const mockSetIsSigningUp = jest.fn(); + const mockOnLoginSuccess = jest.fn(); + const mockOnLoginFailure = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useApplicationStore as unknown as jest.Mock).mockReturnValue({ + updateAxiosInterceptors: mockUpdateAxiosInterceptors, + setIsSigningUp: mockSetIsSigningUp, + }); + }); + + it('should handle login success', async () => { + const mockUser = { + id_token: 'mock-id-token', + profile: { + email: 'test@test.com', + name: 'Test User', + }, + } as OidcUser; + + render( + +
Child Component
} + userConfig={{}} + onLoginFailure={mockOnLoginFailure} + onLoginSuccess={mockOnLoginSuccess} + onLogoutSuccess={mockOnLogoutSuccess}> +
Protected Content
+
+
+ ); + + // Get the Callback component's onSuccess prop and call it + const callbackProps = (Callback as jest.Mock).mock.calls[0][0]; + await act(async () => { + callbackProps.onSuccess(mockUser); + }); + + // Verify the success flow + expect(setOidcToken).toHaveBeenCalledWith('mock-id-token'); + expect(mockOnLoginSuccess).toHaveBeenCalledWith(mockUser); + }); + + it('should handle login failure', async () => { + const mockError = new Error('Login failed'); + + render( + +
Child Component
} + userConfig={{}} + onLoginFailure={mockOnLoginFailure} + onLoginSuccess={mockOnLoginSuccess} + onLogoutSuccess={mockOnLogoutSuccess}> +
Protected Content
+
+
+ ); + + // Get the Callback component's onError prop and call it + const callbackProps = (Callback as jest.Mock).mock.calls[0][0]; + await act(async () => { + callbackProps.onError(mockError); + }); + + // Verify the failure flow + expect(mockOnLoginFailure).toHaveBeenCalled(); + }); + + it('should handle login initiation', async () => { + const ref = React.createRef(); + + render( + +
Child Component
} + ref={ref} + userConfig={{}} + onLoginFailure={mockOnLoginFailure} + onLoginSuccess={mockOnLoginSuccess} + onLogoutSuccess={mockOnLogoutSuccess}> +
Protected Content
+
+
+ ); + + // Trigger login + await act(async () => { + ref.current?.invokeLogin(); + }); + + // Verify login initialization + expect(mockSetIsSigningUp).toHaveBeenCalledWith(true); + }); + + it('should render SignInPage when not authenticated and not signing up', () => { + (useApplicationStore as unknown as jest.Mock).mockReturnValue({ + isAuthenticated: false, + isSigningUp: false, + currentUser: {}, + newUser: {}, + }); + + render( + +
Child Component
} + userConfig={{}} + onLoginFailure={mockOnLoginFailure} + onLoginSuccess={mockOnLoginSuccess} + onLogoutSuccess={mockOnLogoutSuccess}> +
Protected Content
+
+
+ ); + + // Verify SignInPage is rendered + expect(screen.getByTestId('signin-page')).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx index 74eb1f3597a..90b4ceb6e82 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx @@ -115,6 +115,9 @@ const OidcAuthenticator = forwardRef( // eslint-disable-next-line no-console console.error(error); + // Clear the refresh token in progress flag + // Since refresh token request completes with a callback + TokenService.getInstance().clearRefreshInProgress(); onLogoutSuccess(); history.push(ROUTES.SIGNIN); };