Skip to content

Commit

Permalink
fix: handle token refresh failure (#968)
Browse files Browse the repository at this point in the history
* fix: improved mounted/unmounted tracking

* fix: correct automaticSilentRenew docs

fixes #965

* fix: allow SignInRedirectArgs on autoSignIn

* fix: signout redirect on token refresh failure

fixes #966

* chore: yarn format
  • Loading branch information
jamesdh committed Mar 12, 2023
1 parent 2117b02 commit 751732b
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 31 deletions.
46 changes: 28 additions & 18 deletions src/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
SigninRedirectArgs,
SignoutRedirectArgs,
UserLoadedCallback,
SilentRenewErrorCallback,
} from 'oidc-client-ts';
import {
Location,
Expand Down Expand Up @@ -42,7 +43,6 @@ export const hasCodeInUrl = (location: Location): boolean => {
hashParams.get('session_state'),
);
};

/**
* @private
* @hidden
Expand Down Expand Up @@ -91,6 +91,9 @@ export const initUserManager = (props: AuthProviderProps): UserManager => {
export const AuthProvider: FC<PropsWithChildren<AuthProviderProps>> = ({
children,
autoSignIn = true,
autoSignInArgs,
autoSignOut = true,
autoSignOutArgs,
onBeforeSignIn,
onSignIn,
onSignOut,
Expand All @@ -100,30 +103,22 @@ export const AuthProvider: FC<PropsWithChildren<AuthProviderProps>> = ({
const [isLoading, setIsLoading] = useState(true);
const [userData, setUserData] = useState<User | null>(null);
const [userManager] = useState<UserManager>(() => initUserManager(props));
const isMountedRef = useRef<boolean>(false);

const signOutHooks = useCallback(async (): Promise<void> => {
setUserData(null);
onSignOut && onSignOut();
}, [onSignOut]);

const signInPopupHooks = useCallback(async (): Promise<void> => {
const userFromPopup = await userManager.signinPopup();
setUserData(userFromPopup);
onSignIn && onSignIn(userFromPopup);
await userManager.signinPopupCallback();
}, [userManager, onSignIn]);

const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);

useEffect(() => {
isMountedRef.current = true;
(async () => {
// Store current isMounted since this could change while awaiting async operations below
const isMounted = isMountedRef.current;
const user = await userManager!.getUser();
/**
* Check if the user is returning back from OIDC.
Expand All @@ -138,23 +133,38 @@ export const AuthProvider: FC<PropsWithChildren<AuthProviderProps>> = ({

if ((!user || user.expired) && autoSignIn) {
const state = onBeforeSignIn ? onBeforeSignIn() : undefined;
userManager.signinRedirect({ state });
} else if (isMounted) {
await userManager.signinRedirect({ ...autoSignInArgs, state });
} else if (isMountedRef.current) {
setUserData(user);
setIsLoading(false);
}
})();
return () => {
isMountedRef.current = false;
};
}, [location, userManager, autoSignIn, onBeforeSignIn, onSignIn]);

/**
* Registers a UserLoadedCallback to update the userData state on a userLoaded event
* Registers UserManager event callbacks for handling changes to user state due to automaticSilentRenew, session expiry, etc.
*/
useEffect(() => {
const updateUserData: UserLoadedCallback = (user: User): void => {
isMountedRef.current && setUserData(user);
setUserData(user);
};
const onSilentRenewError: SilentRenewErrorCallback = async (
error: Error,
): Promise<void> => {
if (autoSignOut) {
await signOutHooks();
await userManager.signoutRedirect(autoSignOutArgs);
}
};
userManager.events.addUserLoaded(updateUserData);
return () => userManager.events.removeUserLoaded(updateUserData);
userManager.events.addSilentRenewError(onSilentRenewError);
return () => {
userManager.events.removeUserLoaded(updateUserData);
userManager.events.removeSilentRenewError(onSilentRenewError);
};
}, [userManager]);

const value = useMemo<AuthContextProps>(() => {
Expand All @@ -166,11 +176,11 @@ export const AuthProvider: FC<PropsWithChildren<AuthProviderProps>> = ({
await signInPopupHooks();
},
signOut: async (): Promise<void> => {
await userManager!.removeUser();
await userManager.removeUser();
await signOutHooks();
},
signOutRedirect: async (args?: SignoutRedirectArgs): Promise<void> => {
await userManager!.signoutRedirect(args);
await userManager.signoutRedirect(args);
await signOutHooks();
},
userManager,
Expand Down
28 changes: 21 additions & 7 deletions src/AuthContextInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,35 @@ export interface AuthProviderProps {
*/
location?: Location;
/**
* defaults to true
* Flag to control automatic redirection to the OIDC/OAuth2 provider when not signed in.
*
* Defaults to true.
*/
autoSignIn?: boolean;
/**
* Optional sign in arguments to be used when `autoSignIn` is enabled.
*/
autoSignInArgs?: SigninRedirectArgs;
/**
* Flag to control automatic sign out redirection to the OIDC/OAuth2 provider when silent renewal fails.
*
* Defaults to true.
*/
autoSignOut?: boolean;
/**
* Optional sign out arguments to be used when `autoSignOut` is enabled.
*/
autoSignOutArgs?: SignoutRedirectArgs;
/**
* Flag to indicate if there should be an automatic attempt to renew the access token prior to its expiration.
*
* defaults to false
* Defaults to true.
*/
automaticSilentRenew?: boolean;
/**
* Flag to control if additional identity data is loaded from the user info endpoint in order to populate the user's profile.
*
* defaults to true
* Defaults to true.
*/
loadUserInfo?: boolean;
/**
Expand All @@ -104,16 +120,14 @@ export interface AuthProviderProps {
*/
popupRedirectUri?: string;
/**
* The target parameter to window.open for the popup signin window.
*
* The target parameter to window.open for the popup signin window. *
* defaults to '_blank'
*/
popupWindowTarget?: string;
/**
* On before sign in hook. Can be use to store the current url for use after signing in.
*
* This only gets called if autoSignIn is true
*/
* This only gets called if autoSignIn is true */
onBeforeSignIn?: () => string;
/**
* On sign out hook. Can be a async function.
Expand Down
58 changes: 52 additions & 6 deletions src/__tests__/AuthContext.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/* eslint @typescript-eslint/no-explicit-any: 0 */
/* eslint @typescript-eslint/explicit-function-return-type: 0 */
import React from 'react';
import { UserManager } from 'oidc-client-ts';
import { SilentRenewErrorCallback, UserManager } from 'oidc-client-ts';
import { AuthProvider, AuthContext } from '../AuthContext';
import { render, act, waitFor } from '@testing-library/react';
import { render, act, waitFor, RenderResult } from '@testing-library/react';

const events = {
addUserLoaded: () => undefined,
removeUserLoaded: () => undefined,
addSilentRenewError: () => undefined,
removeSilentRenewError: () => undefined,
};

jest.mock('oidc-client-ts', () => {
Expand Down Expand Up @@ -192,10 +194,8 @@ describe('AuthContext', () => {
access_token: 'token',
}),
signinCallback: jest.fn(),
events: {
addUserLoaded: (fn: () => void) => fn(),
removeUserLoaded: () => undefined,
},
signoutRedirect: jest.fn(),
events,
} as any;
const { getByText } = render(
<AuthProvider userManager={userManager}>
Expand Down Expand Up @@ -321,4 +321,50 @@ describe('AuthContext', () => {
}),
);
});

it('should sign out redirect on silent renew failure', async () => {
// given: a signed in UserManager that stashes silentRenewError callbacks
const callbacks: SilentRenewErrorCallback[] = [];
const u = {
getUser: async () => ({
access_token: 'token',
}),
signinCallback: jest.fn(),
signoutRedirect: jest.fn(),
events: {
...events,
addSilentRenewError: jest.fn((callback) => callbacks.push(callback)),
removeSilentRenewError: jest.fn((callback) =>
callbacks.splice(callbacks.indexOf(callback), 1),
),
},
} as any;

// when: the AuthProvider is mounted
let result: RenderResult;
await act(async () => {
result = render(<AuthProvider userManager={u} />);
});

// then: the silentRenewError callback should be registered
expect(u.events.addSilentRenewError).toHaveBeenCalledTimes(1);
expect(callbacks).toHaveLength(1);

// when: the registered silentRenewError callback is called
await act(async () => {
callbacks[0](new Error('test'));
});

// then: the callback should trigger a signout redirect
expect(u.signoutRedirect).toHaveBeenCalledTimes(1);

// when: the AuthProvider is unmounted
act(() => {
result.unmount();
});

// then: the callback should be unregistered
expect(u.events.removeSilentRenewError).toHaveBeenCalledTimes(1);
expect(callbacks).toHaveLength(0);
});
});

0 comments on commit 751732b

Please sign in to comment.