mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-10-28 09:13:58 +00:00
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
This commit is contained in:
parent
011651f078
commit
37e7c489e6
@ -25,6 +25,7 @@ import React, {
|
|||||||
ComponentType,
|
ComponentType,
|
||||||
createContext,
|
createContext,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
@ -92,11 +93,10 @@ export const AuthProvider = ({
|
|||||||
}: AuthProviderProps) => {
|
}: AuthProviderProps) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout>();
|
||||||
const authenticatorRef = useRef<AuthenticatorRef>(null);
|
const authenticatorRef = useRef<AuthenticatorRef>(null);
|
||||||
|
|
||||||
const oidcUserToken = localStorage.getItem(oidcTokenKey);
|
const oidcUserToken = localStorage.getItem(oidcTokenKey);
|
||||||
|
|
||||||
const [isUserAuthenticated, setIsUserAuthenticated] = useState(
|
const [isUserAuthenticated, setIsUserAuthenticated] = useState(
|
||||||
Boolean(oidcUserToken)
|
Boolean(oidcUserToken)
|
||||||
);
|
);
|
||||||
@ -106,6 +106,8 @@ export const AuthProvider = ({
|
|||||||
useState<Record<string, string | boolean>>();
|
useState<Record<string, string | boolean>>();
|
||||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||||
|
|
||||||
|
let silentSignInRetries = 0;
|
||||||
|
|
||||||
const onLoginHandler = () => {
|
const onLoginHandler = () => {
|
||||||
authenticatorRef.current?.invokeLogin();
|
authenticatorRef.current?.invokeLogin();
|
||||||
};
|
};
|
||||||
@ -134,6 +136,9 @@ export const AuthProvider = ({
|
|||||||
setLoading(value);
|
setLoading(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores redirect URL for successful login
|
||||||
|
*/
|
||||||
function storeRedirectPath() {
|
function storeRedirectPath() {
|
||||||
const redirectPathExists = Boolean(
|
const redirectPathExists = Boolean(
|
||||||
cookieStorage.getItem(REDIRECT_PATHNAME)
|
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<string> => {
|
||||||
|
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<JwtPayload>(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 = () => {
|
const handleFailedLogin = () => {
|
||||||
setIsSigningIn(false);
|
setIsSigningIn(false);
|
||||||
setIsUserAuthenticated(false);
|
setIsUserAuthenticated(false);
|
||||||
@ -264,6 +367,8 @@ export const AuthProvider = ({
|
|||||||
getUserPermissions();
|
getUserPermissions();
|
||||||
fetchAllUsers();
|
fetchAllUsers();
|
||||||
handledVerifiedUser();
|
handledVerifiedUser();
|
||||||
|
// Start expiry timer on successful login
|
||||||
|
startTokenExpiryTimer();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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<string> => {
|
|
||||||
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
|
* Initialize Axios interceptors to intercept every request and response
|
||||||
* to handle appropriately. This should be called only when security is enabled.
|
* to handle appropriately. This should be called only when security is enabled.
|
||||||
@ -328,32 +411,8 @@ export const AuthProvider = ({
|
|||||||
const initializeAxiosInterceptors = () => {
|
const initializeAxiosInterceptors = () => {
|
||||||
// Axios Request interceptor to add Bearer tokens in Header
|
// Axios Request interceptor to add Bearer tokens in Header
|
||||||
axiosClient.interceptors.request.use(async function (config) {
|
axiosClient.interceptors.request.use(async function (config) {
|
||||||
let token: string | void = localStorage.getItem(oidcTokenKey) || '';
|
const token: string | void = localStorage.getItem(oidcTokenKey) || '';
|
||||||
if (token) {
|
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<JwtPayload>(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}`;
|
config.headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -88,12 +88,15 @@ const OidcAuthenticator = forwardRef<AuthenticatorRef, Props>(
|
|||||||
const logout = () => {
|
const logout = () => {
|
||||||
setLoadingIndicator(true);
|
setLoadingIndicator(true);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
localStorage.removeItem(
|
userManager.removeUser();
|
||||||
`oidc.user:${userConfig.authority}:${userConfig.client_id}`
|
|
||||||
);
|
|
||||||
onLogoutSuccess();
|
onLogoutSuccess();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Performs silent signIn and returns with IDToken
|
||||||
|
const signInSilently = async () => {
|
||||||
|
return userManager.signinSilent().then((user) => user.id_token);
|
||||||
|
};
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
invokeLogin() {
|
invokeLogin() {
|
||||||
login();
|
login();
|
||||||
@ -102,62 +105,72 @@ const OidcAuthenticator = forwardRef<AuthenticatorRef, Props>(
|
|||||||
logout();
|
logout();
|
||||||
},
|
},
|
||||||
renewIdToken() {
|
renewIdToken() {
|
||||||
return Promise.resolve('');
|
return signInSilently();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const AppWithAuth = getAuthenticator(childComponentType, userManager);
|
const AppWithAuth = getAuthenticator(childComponentType, userManager);
|
||||||
|
|
||||||
return (
|
return !loading ? (
|
||||||
<Fragment>
|
<>
|
||||||
{!loading ? (
|
<Appbar />
|
||||||
<>
|
<Switch>
|
||||||
<Appbar />
|
<Route exact path={ROUTES.HOME}>
|
||||||
<Switch>
|
{!isAuthDisabled && !isAuthenticated && !isSigningIn ? (
|
||||||
<Route exact path={ROUTES.HOME}>
|
<Redirect to={ROUTES.SIGNIN} />
|
||||||
{!isAuthDisabled && !isAuthenticated && !isSigningIn ? (
|
) : (
|
||||||
<Redirect to={ROUTES.SIGNIN} />
|
<Redirect to={ROUTES.MY_DATA} />
|
||||||
) : (
|
)}
|
||||||
<Redirect to={ROUTES.MY_DATA} />
|
</Route>
|
||||||
)}
|
<Route exact component={PageNotFound} path={ROUTES.NOT_FOUND} />
|
||||||
</Route>
|
{!isSigningIn ? (
|
||||||
<Route exact component={PageNotFound} path={ROUTES.NOT_FOUND} />
|
<Route exact component={SigninPage} path={ROUTES.SIGNIN} />
|
||||||
{!isSigningIn ? (
|
) : null}
|
||||||
<Route exact component={SigninPage} path={ROUTES.SIGNIN} />
|
<Route
|
||||||
) : null}
|
path={ROUTES.CALLBACK}
|
||||||
<Route
|
render={() => (
|
||||||
path={ROUTES.CALLBACK}
|
<>
|
||||||
render={() => (
|
<Callback
|
||||||
<>
|
userManager={userManager}
|
||||||
<Callback
|
onError={(error) => {
|
||||||
userManager={userManager}
|
showErrorToast(error?.message);
|
||||||
onError={(error) => {
|
onLoginFailure();
|
||||||
showErrorToast(error?.message);
|
}}
|
||||||
onLoginFailure();
|
onSuccess={(user) => {
|
||||||
}}
|
localStorage.setItem(oidcTokenKey, user.id_token);
|
||||||
onSuccess={(user) => {
|
setIsAuthenticated(true);
|
||||||
localStorage.setItem(oidcTokenKey, user.id_token);
|
onLoginSuccess(user as OidcUser);
|
||||||
setIsAuthenticated(true);
|
}}
|
||||||
onLoginSuccess(user as OidcUser);
|
/>
|
||||||
}}
|
<Loader />
|
||||||
/>
|
</>
|
||||||
<Loader />
|
)}
|
||||||
</>
|
/>
|
||||||
)}
|
<Route
|
||||||
/>
|
path={ROUTES.SILENT_CALLBACK}
|
||||||
{isAuthenticated || isAuthDisabled ? (
|
render={() => (
|
||||||
<Fragment>{children}</Fragment>
|
<>
|
||||||
) : !isSigningIn && isEmpty(userDetails) && isEmpty(newUser) ? (
|
<Callback
|
||||||
<Redirect to={ROUTES.SIGNIN} />
|
userManager={userManager}
|
||||||
) : (
|
onSuccess={(user) => {
|
||||||
<AppWithAuth />
|
localStorage.setItem(oidcTokenKey, user.id_token);
|
||||||
)}
|
}}
|
||||||
</Switch>
|
/>
|
||||||
</>
|
<Loader />
|
||||||
) : (
|
</>
|
||||||
<Loader />
|
)}
|
||||||
)}
|
/>
|
||||||
</Fragment>
|
{isAuthenticated || isAuthDisabled ? (
|
||||||
|
<Fragment>{children}</Fragment>
|
||||||
|
) : !isSigningIn && isEmpty(userDetails) && isEmpty(newUser) ? (
|
||||||
|
<Redirect to={ROUTES.SIGNIN} />
|
||||||
|
) : (
|
||||||
|
<AppWithAuth />
|
||||||
|
)}
|
||||||
|
</Switch>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -142,6 +142,7 @@ export const facetFilterPlaceholder = [
|
|||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
HOME: '/',
|
HOME: '/',
|
||||||
CALLBACK: '/callback',
|
CALLBACK: '/callback',
|
||||||
|
SILENT_CALLBACK: '/silent-callback',
|
||||||
NOT_FOUND: '/404',
|
NOT_FOUND: '/404',
|
||||||
MY_DATA: '/my-data',
|
MY_DATA: '/my-data',
|
||||||
TOUR: '/tour',
|
TOUR: '/tour',
|
||||||
|
|||||||
@ -39,6 +39,12 @@ export const getRedirectUri = (callbackUrl: string) => {
|
|||||||
: `${window.location.origin}/callback`;
|
: `${window.location.origin}/callback`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getSilentRedirectUri = () => {
|
||||||
|
return isDev()
|
||||||
|
? 'http://localhost:3000/silent-callback'
|
||||||
|
: `${window.location.origin}/silent-callback`;
|
||||||
|
};
|
||||||
|
|
||||||
export const getUserManagerConfig = (
|
export const getUserManagerConfig = (
|
||||||
authClient: Record<string, string> = {}
|
authClient: Record<string, string> = {}
|
||||||
): Record<string, string | boolean | WebStorageStateStore> => {
|
): Record<string, string | boolean | WebStorageStateStore> => {
|
||||||
@ -46,13 +52,14 @@ export const getUserManagerConfig = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
authority,
|
authority,
|
||||||
automaticSilentRenew: true,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||||
response_type: responseType,
|
response_type: responseType,
|
||||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||||
redirect_uri: getRedirectUri(callbackUrl),
|
redirect_uri: getRedirectUri(callbackUrl),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||||
|
silent_redirect_uri: getSilentRedirectUri(),
|
||||||
scope,
|
scope,
|
||||||
userStore: new WebStorageStateStore({ store: localStorage }),
|
userStore: new WebStorageStateStore({ store: localStorage }),
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user