From 37e7c489e6094152838bdef05d07769d17f99f2d Mon Sep 17 00:00:00 2001 From: Chirag Madlani Date: Fri, 8 Jul 2022 18:12:22 +0530 Subject: [PATCH] feat(ui): refresh token support for OIDC client login (#5923) * feat(ui): refresh token support for OIDC client login * fix retrying stopped after one attempt * update comments * fix eslint * retry logic verified --- .../auth-provider/AuthProvider.tsx | 157 ++++++++++++------ .../authenticators/OidcAuthenticator.tsx | 121 ++++++++------ .../resources/ui/src/constants/constants.ts | 1 + .../ui/src/utils/AuthProvider.util.ts | 9 +- 4 files changed, 184 insertions(+), 104 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/authentication/auth-provider/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/authentication/auth-provider/AuthProvider.tsx index 9ed992b0c56..8007b1f1acb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/authentication/auth-provider/AuthProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/authentication/auth-provider/AuthProvider.tsx @@ -25,6 +25,7 @@ import React, { ComponentType, createContext, ReactNode, + useCallback, useContext, useEffect, useRef, @@ -92,11 +93,10 @@ export const AuthProvider = ({ }: AuthProviderProps) => { const location = useLocation(); const history = useHistory(); - + const [timeoutId, setTimeoutId] = useState(); const authenticatorRef = useRef(null); const oidcUserToken = localStorage.getItem(oidcTokenKey); - const [isUserAuthenticated, setIsUserAuthenticated] = useState( Boolean(oidcUserToken) ); @@ -106,6 +106,8 @@ export const AuthProvider = ({ useState>(); const [isSigningIn, setIsSigningIn] = useState(false); + let silentSignInRetries = 0; + const onLoginHandler = () => { authenticatorRef.current?.invokeLogin(); }; @@ -134,6 +136,9 @@ export const AuthProvider = ({ setLoading(value); }; + /** + * Stores redirect URL for successful login + */ function storeRedirectPath() { const redirectPathExists = Boolean( cookieStorage.getItem(REDIRECT_PATHNAME) @@ -242,6 +247,104 @@ export const AuthProvider = ({ }); }; + /** + * Renew Id Token handler for all the SSOs. + * This method will be called when the id token is about to expire. + */ + const renewIdToken = (): Promise => { + const onRenewIdTokenHandlerPromise = onRenewIdTokenHandler(); + + return new Promise((resolve, reject) => { + if (onRenewIdTokenHandlerPromise) { + onRenewIdTokenHandlerPromise + .then(() => { + resolve(localStorage.getItem(oidcTokenKey) || ''); + }) + .catch((error) => { + if (error.message !== 'Frame window timed out') { + reject(error); + } else { + resolve(localStorage.getItem(oidcTokenKey) || ''); + } + }); + } else { + reject('RenewIdTokenHandler is undefined'); + } + }); + }; + + /** + * This method will try to signIn silently when token is about to expire + * It will try for max 3 times if it's not succeed then it will proceed for logout + */ + const trySilentSignIn = () => { + // Try to renew token + silentSignInRetries < 3 + ? renewIdToken() + .then(() => { + silentSignInRetries = 0; + // eslint-disable-next-line @typescript-eslint/no-use-before-define + startTokenExpiryTimer(); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('Error while attempting for silent signIn. ', err); + silentSignInRetries += 1; + trySilentSignIn(); + }) + : onLogoutHandler(); // Logout if we reaches max silent signIn limit; + }; + + /** + * It will set an timer for 50 secs before Token will expire + * If time if less then 50 secs then it will try to SilentSignIn + * It will also ensure that we have time left for token expiry + * This method will be call upon successful signIn + */ + const startTokenExpiryTimer = () => { + const token: string | void = localStorage.getItem(oidcTokenKey) || ''; + // If token is not present do nothing + if (token) { + try { + // Extract expiry + const { exp } = jwtDecode(token); + if (exp && exp * 1000 > Date.now()) { + // Check if token isn't expired yet + const diff = exp * 1000 - Date.now(); /* Convert to MS */ + + // Have 50s buffer before start trying for silent signIn + // If token is about to expire then start silentSignIn + // else just set timer to try for silentSignIn before token expires + if (diff > 50000) { + const timerId = setTimeout(() => { + trySilentSignIn(); + }, diff); + setTimeoutId(timerId); + } else { + trySilentSignIn(); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error parsing id token.', error); + } + } + }; + + /** + * Performs cleanup around timers + * Clean silentSignIn activities if going on + */ + const cleanup = useCallback(() => { + clearTimeout(timeoutId as NodeJS.Timeout); + }, [timeoutId]); + + useEffect(() => { + startTokenExpiryTimer(); + + return cleanup; + }, []); + const handleFailedLogin = () => { setIsSigningIn(false); setIsUserAuthenticated(false); @@ -264,6 +367,8 @@ export const AuthProvider = ({ getUserPermissions(); fetchAllUsers(); handledVerifiedUser(); + // Start expiry timer on successful login + startTokenExpiryTimer(); } }) .catch((err) => { @@ -299,28 +404,6 @@ export const AuthProvider = ({ } }; - /** - * Renew Id Token handler for all the SSOs. - * This method will be called when the id token is about to expire. - */ - const renewIdToken = (): Promise => { - const onRenewIdTokenHandlerPromise = onRenewIdTokenHandler(); - - return new Promise((resolve, reject) => { - if (onRenewIdTokenHandlerPromise) { - onRenewIdTokenHandlerPromise - .then(() => { - resolve(localStorage.getItem(oidcTokenKey) || ''); - }) - .catch((error) => { - reject(error); - }); - } else { - reject('RenewIdTokenHandler is undefined'); - } - }); - }; - /** * Initialize Axios interceptors to intercept every request and response * to handle appropriately. This should be called only when security is enabled. @@ -328,32 +411,8 @@ export const AuthProvider = ({ const initializeAxiosInterceptors = () => { // Axios Request interceptor to add Bearer tokens in Header axiosClient.interceptors.request.use(async function (config) { - let token: string | void = localStorage.getItem(oidcTokenKey) || ''; + const token: string | void = localStorage.getItem(oidcTokenKey) || ''; if (token) { - // Before adding token to the Header, check its expiry - // If the token will expire within the next time or has already expired - // renew the token using silent renewal for a smooth UX - try { - const { exp } = jwtDecode(token); - if (exp) { - // Renew token 50 seconds before expiry - if (Date.now() >= (exp - 50) * 1000) { - // Token expired, renew it before sending request - token = await renewIdToken().catch((error) => { - showErrorToast(error); - }); - } - } else { - // Renew token since expiry is not set - token = await renewIdToken().catch((error) => { - showErrorToast(error); - }); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error parsing id token.', error); - } - config.headers['Authorization'] = `Bearer ${token}`; } diff --git a/openmetadata-ui/src/main/resources/ui/src/authentication/authenticators/OidcAuthenticator.tsx b/openmetadata-ui/src/main/resources/ui/src/authentication/authenticators/OidcAuthenticator.tsx index affcfc1aa5b..7fc96c032bc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/authentication/authenticators/OidcAuthenticator.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/authentication/authenticators/OidcAuthenticator.tsx @@ -88,12 +88,15 @@ const OidcAuthenticator = forwardRef( const logout = () => { setLoadingIndicator(true); setIsAuthenticated(false); - localStorage.removeItem( - `oidc.user:${userConfig.authority}:${userConfig.client_id}` - ); + userManager.removeUser(); onLogoutSuccess(); }; + // Performs silent signIn and returns with IDToken + const signInSilently = async () => { + return userManager.signinSilent().then((user) => user.id_token); + }; + useImperativeHandle(ref, () => ({ invokeLogin() { login(); @@ -102,62 +105,72 @@ const OidcAuthenticator = forwardRef( logout(); }, renewIdToken() { - return Promise.resolve(''); + return signInSilently(); }, })); const AppWithAuth = getAuthenticator(childComponentType, userManager); - return ( - - {!loading ? ( - <> - - - - {!isAuthDisabled && !isAuthenticated && !isSigningIn ? ( - - ) : ( - - )} - - - {!isSigningIn ? ( - - ) : null} - ( - <> - { - showErrorToast(error?.message); - onLoginFailure(); - }} - onSuccess={(user) => { - localStorage.setItem(oidcTokenKey, user.id_token); - setIsAuthenticated(true); - onLoginSuccess(user as OidcUser); - }} - /> - - - )} - /> - {isAuthenticated || isAuthDisabled ? ( - {children} - ) : !isSigningIn && isEmpty(userDetails) && isEmpty(newUser) ? ( - - ) : ( - - )} - - - ) : ( - - )} - + return !loading ? ( + <> + + + + {!isAuthDisabled && !isAuthenticated && !isSigningIn ? ( + + ) : ( + + )} + + + {!isSigningIn ? ( + + ) : null} + ( + <> + { + showErrorToast(error?.message); + onLoginFailure(); + }} + onSuccess={(user) => { + localStorage.setItem(oidcTokenKey, user.id_token); + setIsAuthenticated(true); + onLoginSuccess(user as OidcUser); + }} + /> + + + )} + /> + ( + <> + { + localStorage.setItem(oidcTokenKey, user.id_token); + }} + /> + + + )} + /> + {isAuthenticated || isAuthDisabled ? ( + {children} + ) : !isSigningIn && isEmpty(userDetails) && isEmpty(newUser) ? ( + + ) : ( + + )} + + + ) : ( + ); } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts index 7f3e1b102b1..3f029329883 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts @@ -142,6 +142,7 @@ export const facetFilterPlaceholder = [ export const ROUTES = { HOME: '/', CALLBACK: '/callback', + SILENT_CALLBACK: '/silent-callback', NOT_FOUND: '/404', MY_DATA: '/my-data', TOUR: '/tour', diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts index 986674a11ba..a6ee752714e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AuthProvider.util.ts @@ -39,6 +39,12 @@ export const getRedirectUri = (callbackUrl: string) => { : `${window.location.origin}/callback`; }; +export const getSilentRedirectUri = () => { + return isDev() + ? 'http://localhost:3000/silent-callback' + : `${window.location.origin}/silent-callback`; +}; + export const getUserManagerConfig = ( authClient: Record = {} ): Record => { @@ -46,13 +52,14 @@ export const getUserManagerConfig = ( return { authority, - automaticSilentRenew: true, // eslint-disable-next-line @typescript-eslint/camelcase client_id: clientId, // eslint-disable-next-line @typescript-eslint/camelcase response_type: responseType, // eslint-disable-next-line @typescript-eslint/camelcase redirect_uri: getRedirectUri(callbackUrl), + // eslint-disable-next-line @typescript-eslint/camelcase + silent_redirect_uri: getSilentRedirectUri(), scope, userStore: new WebStorageStateStore({ store: localStorage }), };