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:
Chirag Madlani 2022-07-08 18:12:22 +05:30 committed by GitHub
parent 011651f078
commit 37e7c489e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 184 additions and 104 deletions

View File

@ -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<NodeJS.Timeout>();
const authenticatorRef = useRef<AuthenticatorRef>(null);
const oidcUserToken = localStorage.getItem(oidcTokenKey);
const [isUserAuthenticated, setIsUserAuthenticated] = useState(
Boolean(oidcUserToken)
);
@ -106,6 +106,8 @@ export const AuthProvider = ({
useState<Record<string, string | boolean>>();
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<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 = () => {
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<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
* 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<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}`;
}

View File

@ -88,12 +88,15 @@ const OidcAuthenticator = forwardRef<AuthenticatorRef, Props>(
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<AuthenticatorRef, Props>(
logout();
},
renewIdToken() {
return Promise.resolve('');
return signInSilently();
},
}));
const AppWithAuth = getAuthenticator(childComponentType, userManager);
return (
<Fragment>
{!loading ? (
<>
<Appbar />
<Switch>
<Route exact path={ROUTES.HOME}>
{!isAuthDisabled && !isAuthenticated && !isSigningIn ? (
<Redirect to={ROUTES.SIGNIN} />
) : (
<Redirect to={ROUTES.MY_DATA} />
)}
</Route>
<Route exact component={PageNotFound} path={ROUTES.NOT_FOUND} />
{!isSigningIn ? (
<Route exact component={SigninPage} path={ROUTES.SIGNIN} />
) : null}
<Route
path={ROUTES.CALLBACK}
render={() => (
<>
<Callback
userManager={userManager}
onError={(error) => {
showErrorToast(error?.message);
onLoginFailure();
}}
onSuccess={(user) => {
localStorage.setItem(oidcTokenKey, user.id_token);
setIsAuthenticated(true);
onLoginSuccess(user as OidcUser);
}}
/>
<Loader />
</>
)}
/>
{isAuthenticated || isAuthDisabled ? (
<Fragment>{children}</Fragment>
) : !isSigningIn && isEmpty(userDetails) && isEmpty(newUser) ? (
<Redirect to={ROUTES.SIGNIN} />
) : (
<AppWithAuth />
)}
</Switch>
</>
) : (
<Loader />
)}
</Fragment>
return !loading ? (
<>
<Appbar />
<Switch>
<Route exact path={ROUTES.HOME}>
{!isAuthDisabled && !isAuthenticated && !isSigningIn ? (
<Redirect to={ROUTES.SIGNIN} />
) : (
<Redirect to={ROUTES.MY_DATA} />
)}
</Route>
<Route exact component={PageNotFound} path={ROUTES.NOT_FOUND} />
{!isSigningIn ? (
<Route exact component={SigninPage} path={ROUTES.SIGNIN} />
) : null}
<Route
path={ROUTES.CALLBACK}
render={() => (
<>
<Callback
userManager={userManager}
onError={(error) => {
showErrorToast(error?.message);
onLoginFailure();
}}
onSuccess={(user) => {
localStorage.setItem(oidcTokenKey, user.id_token);
setIsAuthenticated(true);
onLoginSuccess(user as OidcUser);
}}
/>
<Loader />
</>
)}
/>
<Route
path={ROUTES.SILENT_CALLBACK}
render={() => (
<>
<Callback
userManager={userManager}
onSuccess={(user) => {
localStorage.setItem(oidcTokenKey, user.id_token);
}}
/>
<Loader />
</>
)}
/>
{isAuthenticated || isAuthDisabled ? (
<Fragment>{children}</Fragment>
) : !isSigningIn && isEmpty(userDetails) && isEmpty(newUser) ? (
<Redirect to={ROUTES.SIGNIN} />
) : (
<AppWithAuth />
)}
</Switch>
</>
) : (
<Loader />
);
}
);

View File

@ -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',

View File

@ -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<string, string> = {}
): Record<string, string | boolean | WebStorageStateStore> => {
@ -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 }),
};