From 01e023aeafd4548c9937f551e1fccc51fa65e3ed Mon Sep 17 00:00:00 2001 From: Martin Kelnar Date: Wed, 20 Nov 2024 12:41:12 +0100 Subject: [PATCH] NCL-8952 Refactor the auth service to use manual init --- src/AppLayout.tsx | 6 +- .../KeycloakStatusPage/KeycloakStatusPage.tsx | 2 +- .../ProtectedContent/ProtectedContent.tsx | 2 +- src/index.tsx | 6 +- src/services/__mocks__/keycloakService.ts | 66 +++---- .../__tests__/keycloakService.test.ts | 78 -------- src/services/keycloakService.ts | 175 ++++++++++-------- src/services/pncClient.ts | 2 +- 8 files changed, 137 insertions(+), 200 deletions(-) delete mode 100644 src/services/__tests__/keycloakService.test.ts diff --git a/src/AppLayout.tsx b/src/AppLayout.tsx index 821fdc61..610a4943 100644 --- a/src/AppLayout.tsx +++ b/src/AppLayout.tsx @@ -56,7 +56,7 @@ import pncLogoText from './pnc-logo-text.svg'; export const AppLayout = () => { const webConfig = webConfigService.getWebConfig(); - const [user, setUser] = useState(keycloakService.isKeycloakAvailable ? keycloakService.getUser() : null); + const [user, setUser] = useState(keycloakService.isKeycloakAvailable() ? keycloakService.getUser() : null); const serviceContainerPncStatus = useServiceContainer(genericSettingsApi.getPncStatus); const serviceContainerPncStatusRunner = serviceContainerPncStatus.run; @@ -247,14 +247,14 @@ export const AppLayout = () => { isExpanded={isHeaderUserOpen} onClick={() => setIsHeaderUserOpen((isHeaderUserOpen) => !isHeaderUserOpen)} > - {keycloakService.isKeycloakAvailable ? <>{user ? user : 'Not logged in'} : <>KEYCLOAK UNAVAILABLE} + {keycloakService.isKeycloakAvailable() ? <>{user ? user : 'Not logged in'} : <>KEYCLOAK UNAVAILABLE} )} isOpen={isHeaderUserOpen} onOpenChange={(isOpen: boolean) => setIsHeaderUserOpen(isOpen)} > - {keycloakService.isKeycloakAvailable ? headerUserDropdownItems : headerKeycloakUnavailableDropdownItems} + {keycloakService.isKeycloakAvailable() ? headerUserDropdownItems : headerKeycloakUnavailableDropdownItems} diff --git a/src/components/KeycloakStatusPage/KeycloakStatusPage.tsx b/src/components/KeycloakStatusPage/KeycloakStatusPage.tsx index 0f29c35a..16c6eaaf 100644 --- a/src/components/KeycloakStatusPage/KeycloakStatusPage.tsx +++ b/src/components/KeycloakStatusPage/KeycloakStatusPage.tsx @@ -47,7 +47,7 @@ export const KeycloakStatusPage = ({ errorPageTitle }: IKeycloakStatusPageProps) ); - if (!keycloakService.isKeycloakAvailable) { + if (!keycloakService.isKeycloakAvailable()) { return errorPageTitle ? ( // Error page - requested page (for example projects/create) could not be displayed due to Keycloak { const [isKeycloakInitiated, setIsKeycloakInitiated] = useState(false); const [isKeycloakInitFail, setIsKeycloakInitFail] = useState(false); diff --git a/src/services/__mocks__/keycloakService.ts b/src/services/__mocks__/keycloakService.ts index 2c42fa0b..16e5dd89 100644 --- a/src/services/__mocks__/keycloakService.ts +++ b/src/services/__mocks__/keycloakService.ts @@ -5,53 +5,55 @@ export enum AUTH_ROLE { Power = 'power-user', } -class KeycloakServiceMock { - private initialized: boolean = false; - private user: any; - private isLogin: boolean = false; - private roles: String[] = ['Admin']; +const createKeycloakServiceMock = () => { + const initialized: boolean = false; + let user: any; + let isLogin: boolean = false; + const roles: String[] = ['Admin']; - constructor() { - this.initialized = true; - } + const isKeycloakAvailable = (): boolean => { + return initialized; + }; - public isInitialized(): Promise { + const isInitialized = (): Promise => { return new Promise((resolve) => { - resolve(this.initialized); + resolve(initialized); }); - } + }; - public isAuthenticated(): boolean { - return this.isLogin; - } + const isAuthenticated = (): boolean => { + return isLogin; + }; - public login(user: String): Promise { - this.user = user; - this.isLogin = true; + const login = (userNew: String): Promise => { + user = userNew; + isLogin = true; return new Promise((resolve) => { resolve(true); }); - } + }; - public logout(): void { - this.isLogin = false; - this.user = undefined; - } + const logout = (): void => { + isLogin = false; + user = undefined; + }; - public getToken(): String { + const getToken = (): String => { return 'example_token'; - } + }; - public getUser(): String { - return this.user; - } + const getUser = (): String => { + return user; + }; - public hasRealmRole(role: String): boolean { - if (this.roles.includes(role)) { + const hasRealmRole = (role: String): boolean => { + if (roles.includes(role)) { return true; } return false; - } -} + }; + + return { isKeycloakAvailable, isInitialized, isAuthenticated, login, logout, getToken, getUser, hasRealmRole }; +}; -export const keycloakService = new KeycloakServiceMock(); +export const keycloakService = createKeycloakServiceMock(); diff --git a/src/services/__tests__/keycloakService.test.ts b/src/services/__tests__/keycloakService.test.ts deleted file mode 100644 index 79f0730e..00000000 --- a/src/services/__tests__/keycloakService.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -jest.mock('services/userService'); -jest.mock('services/webConfigService'); -jest.mock('services/broadcastService'); - -const mockInit = jest.fn(() => { - return Promise.resolve(); -}); - -const mockLogout = jest.fn(); -const mockLogin = jest.fn(); -const mockIsTokenExpired = jest.fn(); -const mockUpdateToken = jest.fn(); -const mockHasRealmRole = jest.fn(); - -jest.mock('services/keycloakHolder', () => { - return { - Keycloak: jest.fn().mockImplementation(() => ({ - init: mockInit, - login: mockLogin, - logout: mockLogout, - authenticated: true, - idTokenParsed: { - preferred_username: 'username123test', - }, - token: 'token', - isTokenExpired: mockIsTokenExpired, - updateToken: mockUpdateToken, - hasRealmRole: mockHasRealmRole, - })), - }; -}); - -describe('keycloakService works properly', () => { - const { keycloakService } = require('services/keycloakService'); - - test('isInitialized() returns correct value', () => { - expect(keycloakService.isInitialized()).resolves.toBe('success'); - }); - - test('isAuthenticated() returns correct value', () => { - expect(keycloakService.isAuthenticated()).toBe(true); - }); - - test('login() calls keycloak login function correctly', async () => { - keycloakService.login(); - expect(mockLogin).toHaveBeenCalledTimes(1); - }); - - test('logout() calls keycloak logout function correctly', () => { - keycloakService.logout(); - expect(mockLogout).toHaveBeenCalledTimes(1); - }); - - test('isTokenExpired() returns correct value', () => { - mockIsTokenExpired.mockReturnValue(true); - expect(keycloakService.isTokenExpired()).toBe(true); - mockIsTokenExpired.mockReturnValue(false); - expect(keycloakService.isTokenExpired()).toBe(false); - expect(mockIsTokenExpired).toHaveBeenCalledTimes(2); - }); - - test('updateToken() updates token', () => { - keycloakService.updateToken(); - expect(mockUpdateToken).toHaveBeenCalledTimes(1); - }); - - test('getUser() returns correct value', () => { - expect(keycloakService.getUser()).toBe('username123test'); - }); - - test('hasRealmRole() returns correct value', () => { - mockHasRealmRole.mockReturnValue(false); - expect(keycloakService.hasRealmRole()).toBe(false); - expect(mockHasRealmRole).toHaveBeenCalledTimes(1); - }); -}); - -export {}; diff --git a/src/services/keycloakService.ts b/src/services/keycloakService.ts index e10f034e..757299db 100644 --- a/src/services/keycloakService.ts +++ b/src/services/keycloakService.ts @@ -13,22 +13,22 @@ export enum AUTH_ROLE { } /** - * Class managing authentication functionality. + * Authentication manager. */ -class KeycloakService { +const createKeycloakService = () => { // We can't get KeycloakInstance type because of dynamic loading of Keycloak library - private keycloakAuth: any = null; + let keycloakAuth: any = null; - private authenticated: boolean | null = null; + let authenticated: boolean | null = null; /** * Variable from config (webConfigService.getWebConfig().ssoTokenLifespan) is used differently in * Angular UI, we're here setting minimal validity for token. * 5 = if token has less than 5 seconds of validity left then refresh. */ - private KEYCLOAK_TOKEN_MIN_EXP = 5; + const KEYCLOAK_TOKEN_MIN_EXP = 5; - private isKeycloakInitialized; + let isKeycloakInitialized: Promise; /* false if: @@ -38,39 +38,33 @@ class KeycloakService { - is initializing - initialization failed */ - private _isKeycloakAvailable: boolean = false; + let _isKeycloakAvailable: boolean = false; - constructor() { - this.isKeycloakInitialized = this.init(); - } - - public get isKeycloakAvailable(): boolean { - return this._isKeycloakAvailable; - } + const isKeycloakAvailable = (): boolean => { + return _isKeycloakAvailable; + }; /** * Initialize Keycloak and create instance. - * - * @returns Promise. */ - private init(): Promise { + const init = (): void => { console.log('keycloakService init'); const keycloakConfig = webConfigService.getWebConfig().keycloak; if (Keycloak) { - this.keycloakAuth = new Keycloak({ + keycloakAuth = new Keycloak({ url: keycloakConfig.url, realm: keycloakConfig.realm, clientId: keycloakConfig.clientId, }); - return new Promise((resolve, reject) => { + isKeycloakInitialized = new Promise((resolve, reject) => { console.log('G1: keycloakAuth init'); - this.keycloakAuth + keycloakAuth .init({ onLoad: 'check-sso' }) .then(() => { - this._isKeycloakAvailable = true; - if (this.isAuthenticated()) { + _isKeycloakAvailable = true; + if (isAuthenticated()) { userService.fetchUser().finally(() => { resolve('success'); }); @@ -83,17 +77,17 @@ class KeycloakService { }); }); } else { - return Promise.reject('Keycloak library not available'); + isKeycloakInitialized = Promise.reject('Keycloak library not available'); } - } + }; /** * Returns promise of Keycloak initialization. * * @returns Promise. */ - public isInitialized(): Promise { - return this.isKeycloakInitialized; - } + const isInitialized = (): Promise => { + return isKeycloakInitialized; + }; /** * Returns if user is authenticated. * @@ -101,17 +95,17 @@ class KeycloakService { * * @returns True if user is authenticated, false otherwise. */ - public isAuthenticated(): boolean { - this.checkKeycloakAvailability(); - const authenticated = this.keycloakAuth.authenticated!; - const user = this.getUser(); - if (this.authenticated !== authenticated) { - authBroadcastService.send(authenticated, user ? user : null); - this.authenticated = authenticated; + const isAuthenticated = (): boolean => { + checkKeycloakAvailability(); + const authenticatedResult = keycloakAuth.authenticated!; + const user = getUser(); + if (authenticated !== authenticatedResult) { + authBroadcastService.send(authenticatedResult, user ? user : null); + authenticated = authenticatedResult; } - return authenticated; - } + return authenticatedResult; + }; /** * Initiate login process in keycloak. @@ -120,11 +114,11 @@ class KeycloakService { * * @returns Promise. */ - public login(): Promise { - this.checkKeycloakAvailability(); + const login = (): Promise => { + checkKeycloakAvailability(); - return this.keycloakAuth.login(); - } + return keycloakAuth.login(); + }; /** * Initiate logout process in keycloak. @@ -133,11 +127,11 @@ class KeycloakService { * * @param redirectUri URI to redirect after logout. */ - public logout(redirectUri?: string): void { - this.checkKeycloakAvailability(); + const logout = (redirectUri?: string): void => { + checkKeycloakAvailability(); - this.keycloakAuth.logout({ redirectUri }); - } + keycloakAuth.logout({ redirectUri }); + }; /** * Gets keycloak token. @@ -146,15 +140,15 @@ class KeycloakService { * * @returns String with token if user is logged in, undefined otherwise. */ - async getToken(): Promise { - this.checkKeycloakAvailability(); + const getToken = async (): Promise => { + checkKeycloakAvailability(); - await this.updateToken().catch(() => { + await updateToken().catch(() => { throw new Error('Failed to refresh token'); }); - return this.keycloakAuth.token; - } + return keycloakAuth.token; + }; /** * Returns token validity string. @@ -163,35 +157,33 @@ class KeycloakService { * * @returns Token validity string. */ - public getTokenValidity(): string { - this.checkKeycloakAvailability(); + const getTokenValidity = (): string => { + checkKeycloakAvailability(); - if (!this.keycloakAuth.tokenParsed) { + if (!keycloakAuth.tokenParsed) { return 'Not authenticated'; } let validity = - 'Token Expires:\t\t' + - new Date((this.keycloakAuth.tokenParsed.exp + this.keycloakAuth.timeSkew) * 1000).toLocaleString() + - '\n'; + 'Token Expires:\t\t' + new Date((keycloakAuth.tokenParsed.exp + keycloakAuth.timeSkew) * 1000).toLocaleString() + '\n'; validity += 'Token Expires in:\t' + - Math.round(this.keycloakAuth.tokenParsed.exp + this.keycloakAuth.timeSkew - new Date().getTime() / 1000) + + Math.round(keycloakAuth.tokenParsed.exp + keycloakAuth.timeSkew - new Date().getTime() / 1000) + ' seconds\n'; - if (this.keycloakAuth.refreshTokenParsed) { + if (keycloakAuth.refreshTokenParsed) { validity += 'Refresh Token Expires:\t' + - new Date((this.keycloakAuth.refreshTokenParsed.exp + this.keycloakAuth.timeSkew) * 1000).toLocaleString() + + new Date((keycloakAuth.refreshTokenParsed.exp + keycloakAuth.timeSkew) * 1000).toLocaleString() + '\n'; validity += 'Refresh Expires in:\t' + - Math.round(this.keycloakAuth.refreshTokenParsed.exp + this.keycloakAuth.timeSkew - new Date().getTime() / 1000) + + Math.round(keycloakAuth.refreshTokenParsed.exp + keycloakAuth.timeSkew - new Date().getTime() / 1000) + ' seconds'; } return validity; - } + }; /** * Returns whether is token expired. @@ -200,11 +192,11 @@ class KeycloakService { * * @returns True if token is expired, false otherwise. */ - public isTokenExpired(): boolean { - this.checkKeycloakAvailability(); + const isTokenExpired = (): boolean => { + checkKeycloakAvailability(); - return this.keycloakAuth.isTokenExpired(this.KEYCLOAK_TOKEN_MIN_EXP); - } + return keycloakAuth.isTokenExpired(KEYCLOAK_TOKEN_MIN_EXP); + }; /** * Updates token. @@ -213,11 +205,11 @@ class KeycloakService { * * @returns Promise. */ - public updateToken(): Promise { - this.checkKeycloakAvailability(); + const updateToken = (): Promise => { + checkKeycloakAvailability(); - return this.keycloakAuth.updateToken(this.KEYCLOAK_TOKEN_MIN_EXP); - } + return keycloakAuth.updateToken(KEYCLOAK_TOKEN_MIN_EXP); + }; /** * Gets user name from keycloak. @@ -226,11 +218,11 @@ class KeycloakService { * * @returns String with username if user is logged in, undefined otherwise. */ - public getUser(): string | null { - this.checkKeycloakAvailability(); + const getUser = (): string | null => { + checkKeycloakAvailability(); - return this.keycloakAuth.idTokenParsed?.preferred_username; - } + return keycloakAuth.idTokenParsed?.preferred_username; + }; /** * Checks if user has required auth role. @@ -240,23 +232,42 @@ class KeycloakService { * @param role AUTH_ROLE * @returns True when user is logged in and has required role for access, false otherwise. */ - public hasRealmRole(role: AUTH_ROLE): boolean { - this.checkKeycloakAvailability(); + const hasRealmRole = (role: AUTH_ROLE): boolean => { + checkKeycloakAvailability(); - return this.keycloakAuth.hasRealmRole(role); - } + return keycloakAuth.hasRealmRole(role); + }; /** * Checks Keycloak availability and throws exception if Keycloak is not available. */ - private checkKeycloakAvailability() { - if (!this.isKeycloakAvailable) { + const checkKeycloakAvailability = () => { + if (!isKeycloakAvailable) { throw new Error('Keycloak not available! Please check Keycloak availability before using Keycloak service method.'); } - } -} + }; + + /** + * API + */ + return { + init, + login, + isInitialized, + isKeycloakAvailable, + isAuthenticated, + getToken, + hasRealmRole, + logout, + getUser, + + // Not used yet + getTokenValidity, // For testing purposes + isTokenExpired, + }; +}; /** - * Instance of KeycloakService providing group of Keycloak related API operations. + * Instance providing group of Keycloak related API operations. */ -export const keycloakService = new KeycloakService(); +export const keycloakService = createKeycloakService(); diff --git a/src/services/pncClient.ts b/src/services/pncClient.ts index c3b6b629..d6967d7b 100644 --- a/src/services/pncClient.ts +++ b/src/services/pncClient.ts @@ -26,7 +26,7 @@ class PncClient { // perform actions before request is sent httpClient.interceptors.request.use(async (config) => { - if (keycloakService.isKeycloakAvailable && keycloakService.isAuthenticated()) { + if (keycloakService.isKeycloakAvailable() && keycloakService.isAuthenticated()) { config.headers = config.headers ?? {}; config.headers.Authorization = `Bearer ` + (await keycloakService.getToken()); }