From d395a13f2387d1e9e2bfcdfd1490b0fba6e106bf Mon Sep 17 00:00:00 2001 From: Tristan Huet Date: Thu, 8 Aug 2024 10:40:51 +0200 Subject: [PATCH 1/2] build: add new dependency @azure/msal-browser --- package.json | 1 + yarn.lock | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/package.json b/package.json index f9836bd..4e76f3c 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "timezone-mock": "^1.3.6" }, "dependencies": { + "@azure/msal-browser": "^3.20.0", "csv-string": "^4.1.1", "date-fns": "^2.30.0", "js-file-download": "^0.4.12", diff --git a/yarn.lock b/yarn.lock index 310052b..eb29023 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22,6 +22,22 @@ __metadata: languageName: node linkType: hard +"@azure/msal-browser@npm:^3.20.0": + version: 3.20.0 + resolution: "@azure/msal-browser@npm:3.20.0" + dependencies: + "@azure/msal-common": "npm:14.14.0" + checksum: 10c0/3a32d80b57287fd24bba3e283a8a07feccaadeaf124827a3430f4f0dd07bf05307d534ab067ce595e34d6c07916771d6623124b1ba4f578351a3a3ed405935ee + languageName: node + linkType: hard + +"@azure/msal-common@npm:14.14.0": + version: 14.14.0 + resolution: "@azure/msal-common@npm:14.14.0" + checksum: 10c0/90e4fdc0b8b074e548dd1e761fe07e591f93d39894e796e326e7292d2e1eddc9bbb4cba7fb722a531e6687434fbe7a0cc0e2cbe17173f34c1f21b5684ee01c89 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.22.13, @babel/code-frame@npm:^7.23.5": version: 7.23.5 resolution: "@babel/code-frame@npm:7.23.5" @@ -1977,6 +1993,7 @@ __metadata: version: 0.0.0-use.local resolution: "@cosmotech/core@workspace:." dependencies: + "@azure/msal-browser": "npm:^3.20.0" "@babel/core": "npm:^7.25.2" "@babel/preset-env": "npm:^7.25.2" "@rollup/plugin-babel": "npm:^6.0.4" From 2cb7fb9025d0cfc824750ff8107aa189caf10cd6 Mon Sep 17 00:00:00 2001 From: Tristan Huet Date: Thu, 8 Aug 2024 10:38:57 +0200 Subject: [PATCH 2/2] feat: add new provider AuthKeycloakExperimental --- .../AuthKeycloakRedirect.js | 223 ++++++++++++++++++ src/AuthKeycloakRedirect/index.js | 4 + src/index.js | 1 + 3 files changed, 228 insertions(+) create mode 100644 src/AuthKeycloakRedirect/AuthKeycloakRedirect.js create mode 100644 src/AuthKeycloakRedirect/index.js diff --git a/src/AuthKeycloakRedirect/AuthKeycloakRedirect.js b/src/AuthKeycloakRedirect/AuthKeycloakRedirect.js new file mode 100644 index 0000000..2ee1544 --- /dev/null +++ b/src/AuthKeycloakRedirect/AuthKeycloakRedirect.js @@ -0,0 +1,223 @@ +// Copyright (c) Cosmo Tech. +// Licensed under the MIT license. +import * as msal from '@azure/msal-browser'; + +// Note: local storage works on Chromium but not on Firefox if "Delete cookies and site data when Firefox is closed" is +// selected (for more details, see https://bugzilla.mozilla.org/show_bug.cgi?id=1453699) +const writeToStorage = (key, value) => localStorage.setItem(key, value); +const readFromStorage = (key) => localStorage.getItem(key); +const clearFromStorage = (key) => localStorage.removeItem(key); + +export const name = 'auth-keycloakRedirect'; +const authData = { + authenticated: readFromStorage('authAuthenticated') === 'true', + accountId: undefined, + userEmail: undefined, + username: undefined, + userId: undefined, + roles: [], +}; +let config = null; +let msalApp = null; + +export const setConfig = async (newConfig) => { + config = newConfig; + msalApp = new msal.PublicClientApplication(config.msalConfig); + await msalApp.initialize(); +}; + +const checkInit = () => { + if (msalApp === null) { + console.error( + 'AuthKeycloakRedirect module has not been initialized. Make sure you ' + + 'call the setConfig function when you add the AuthKeycloakRedirect provider.' + ); + return false; + } + return true; +}; + +const redirectOnAuthSuccess = () => { + window.location.href = config?.msalConfig?.auth?.redirectUri ?? '/'; +}; + +const _acquireTokensByRequestAndAccount = async (tokenReq, account) => { + if (!tokenReq) { + console.warn('No base access token request provided'); + tokenReq = {}; + } + + tokenReq.account = account; + return await msalApp + .acquireTokenSilent(tokenReq) + .then((tokenRes) => tokenRes) + .catch((silentTokenFetchError) => { + if (silentTokenFetchError.errorCode === 'no_tokens_found') { + // No token found during acquireTokenSilent, ignore this error, nothing to do + return; + } else if (silentTokenFetchError.errorCode === 'login_required') { + console.warn( + 'Silent authentication not possible, user is not logged in. This usually happens when the user session ' + + 'has expired. Please try to log in again.' + ); + return; + } else if (silentTokenFetchError.errorMessage?.indexOf('interaction_required') !== -1) { + msalApp + .acquireTokenRedirect(tokenReq) + .then((tokenRes) => tokenRes) // Token acquired with interaction + .catch((tokenRedirectError) => tokenRedirectError); // Token retrieval failed + } + throw silentTokenFetchError; + }); +}; + +export const acquireTokens = async () => { + if (!checkInit()) return; + + const idToken = readFromStorage('authIdToken'); + const accessToken = readFromStorage('authAccessToken'); + const authenticated = readFromStorage('authAuthenticated') === 'true'; + if (authenticated && idToken != null && accessToken != null) { + return { accessToken, idToken }; + } + + const account = msalApp.getAllAccounts()?.[0]; + const tokenReq = config.accessRequest; + if (account === undefined) { + return undefined; + } + + return await _acquireTokensByRequestAndAccount(tokenReq, account); +}; + +const handleResponse = (response) => { + if (response != null) { + const account = response.account; + writeToStorage('authIdTokenPopup', response.idToken); + writeToStorage('authIdToken', response.idToken); + writeToStorage('authAccessToken', response.accessToken); + writeToStorage('authAuthenticated', 'true'); + writeToStorage('authAccountId', account.homeAccountId); + authData.authenticated = true; + authData.accountId = account.homeAccountId; + authData.userEmail = account.username; // In MSAL account data, username property contains user email + authData.username = account.name; + authData.userId = account.localAccountId; + + redirectOnAuthSuccess(); + return; + } + + msalApp.loginRedirect(config.loginRequest); +}; + +export const signIn = () => { + if (!checkInit()) return; + + // Set auth provider name in storage to declare that it has an interaction in progress + setTimeout(() => { + writeToStorage('authInteractionInProgress', name); + }, 50); + return msalApp.handleRedirectPromise().then(handleResponse); +}; + +export const signOut = () => { + if (!checkInit()) return; + + const accountId = readFromStorage('authAccountId'); + const idToken = readFromStorage('authIdToken'); + clearFromStorage('authIdTokenPopup'); + clearFromStorage('authIdToken'); + clearFromStorage('authAccessToken'); + clearFromStorage('authAccountId'); + writeToStorage('authAuthenticated', 'false'); + + const logoutRequest = { + account: msalApp.getAccountByHomeId(authData.accountId ?? accountId), + idTokenHint: idToken, + }; + msalApp.logoutRedirect(logoutRequest); +}; + +// Returns a boolean value, stating whether the isUserSignedIn must be provided a callback +export const isAsync = () => { + return false; +}; + +const _extractRolesFromAccessToken = (accessToken) => { + let result = []; + if (accessToken) { + const decodedToken = JSON.parse(atob(accessToken.split('.')[1])); + if (decodedToken?.roles) { + result = decodedToken?.roles; + } + } + return result; +}; + +export const isUserSignedIn = async () => { + if (authData.authenticated) return true; + if (readFromStorage('authAuthenticated') === 'true') { + authData.authenticated = true; + return true; + } + + // Resume interaction if one is already in progress + if (readFromStorage('authInteractionInProgress') === name) { + clearFromStorage('authInteractionInProgress'); + + const locationHashParameters = new URLSearchParams(window.location.hash.substring(1)); + if (locationHashParameters.has('state')) { + if (locationHashParameters.has('iss', config?.msalConfig?.auth?.authorityMetadata?.issuer)) { + // Resume redirect workflow process + msalApp.handleRedirectPromise().then(handleResponse); + } else if (locationHashParameters.has('iss')) { + console.warn( + 'Issuer found in url ("' + + config?.msalConfig?.auth?.authorityMetadata?.issuer + + '") does not match the keycloak configuration ("' + + locationHashParameters.get('iss') + + '")' + ); + } + } + } + + // Otherwise, try to acquire a token silently to implement SSO + const tokens = await acquireTokens(); + if (tokens?.idToken !== undefined) { + writeToStorage('authIdToken', tokens.idToken); + } + if (tokens?.accessToken !== undefined) { + const accessToken = tokens.accessToken; + authData.roles = _extractRolesFromAccessToken(accessToken); + writeToStorage('authAccessToken', accessToken); + return true; + } + return false; +}; + +export const getUserEmail = () => { + if (!checkInit()) return; + // Note: account data from MSAL seems to contain user email in the 'username' property + return authData?.userEmail ?? msalApp.getAllAccounts()?.[0]?.username; +}; + +export const getUserName = () => { + if (!checkInit()) return; + return authData?.name ?? msalApp.getAllAccounts()?.[0]?.name; +}; + +export const getUserId = () => { + if (!checkInit()) return; + return authData?.userId ?? msalApp.getAllAccounts()?.[0]?.localAccountId; +}; + +export const getUserRoles = () => { + if (!checkInit()) return; + return authData.roles; +}; + +export const getUserPicUrl = () => { + return undefined; +}; diff --git a/src/AuthKeycloakRedirect/index.js b/src/AuthKeycloakRedirect/index.js new file mode 100644 index 0000000..b359247 --- /dev/null +++ b/src/AuthKeycloakRedirect/index.js @@ -0,0 +1,4 @@ +// Copyright (c) Cosmo Tech. +// Licensed under the MIT license. + +export * as AuthKeycloakRedirect from './AuthKeycloakRedirect'; diff --git a/src/index.js b/src/index.js index 6f8e016..01b8917 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ export { AgGridUtils, CSVUtils, FileBlobUtils, PathUtils, XLSXUtils } from './FileUtils'; export { default as Auth } from './Auth'; export { default as AuthDev } from './AuthDev'; +export { AuthKeycloakRedirect } from './AuthKeycloakRedirect'; export { default as DatasetUtils } from './DatasetUtils'; export { DateUtils } from './DateUtils'; export { default as ScenarioUtils } from './ScenarioUtils';