Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(hooks): update useAuth to handle more auth hub events #2795

Merged
merged 9 commits into from
Nov 1, 2022
154 changes: 92 additions & 62 deletions packages/react/src/hooks/__tests__/useAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,31 @@ import { Hub } from '@aws-amplify/core';
import { act, renderHook } from '@testing-library/react-hooks';
import { useAuth } from '../useAuth';

jest.mock('@aws-amplify/auth');
const currentAuthenticatedUser = jest.fn();

jest
.spyOn(Auth, 'currentAuthenticatedUser')
.mockImplementation(currentAuthenticatedUser);
wlee221 marked this conversation as resolved.
Show resolved Hide resolved

// hub events that return valid user object
const SUCCESS_EVENTS_WITH_USER = ['signIn', 'signUp', 'autoSignIn'];

// hub events that return error object
const FAILURE_EVENTS_WITH_ERROR = ['tokenRefresh_failure', 'signIn_failure'];

const mockCognitoUser = {
username: 'johndoe',
attributes: {
phone_number: '+1-234-567-890',
email: 'john@doe.com',
},
};

describe('useAuth', () => {
afterEach(() => jest.clearAllMocks());

it('should return default values when initialized', async () => {
(Auth.currentAuthenticatedUser as jest.Mock).mockResolvedValue(undefined);
currentAuthenticatedUser.mockResolvedValue(undefined);

const { result, waitForNextUpdate } = renderHook(() => useAuth());

Expand All @@ -21,23 +39,17 @@ describe('useAuth', () => {
});

it('should invoke Auth.currentAuthenticatedUser function', async () => {
const mockCurrentAuthenticatedUser = jest.fn(() => Promise.resolve());

(Auth.currentAuthenticatedUser as jest.Mock).mockImplementation(
mockCurrentAuthenticatedUser
);
currentAuthenticatedUser.mockResolvedValue({});
wlee221 marked this conversation as resolved.
Show resolved Hide resolved

const { waitForNextUpdate } = renderHook(() => useAuth());

await waitForNextUpdate();

expect(mockCurrentAuthenticatedUser).toHaveBeenCalled();
expect(currentAuthenticatedUser).toHaveBeenCalled();
});

it('should set an error when something unexpected happen', async () => {
(Auth.currentAuthenticatedUser as jest.Mock).mockRejectedValue(
new Error('Unknown error')
);
currentAuthenticatedUser.mockRejectedValue(new Error('Unknown error'));

const { result, waitForNextUpdate } = renderHook(() => useAuth());

Expand All @@ -47,17 +59,7 @@ describe('useAuth', () => {
});

it('should retrieve a Cognito user', async () => {
const mockCognitoUser = {
username: 'johndoe',
attributes: {
phone_number: '+1-234-567-890',
email: 'john@doe.com',
},
};

(Auth.currentAuthenticatedUser as jest.Mock).mockResolvedValue(
mockCognitoUser
);
currentAuthenticatedUser.mockResolvedValue(mockCognitoUser);

const { result, waitForNextUpdate } = renderHook(() => useAuth());

Expand All @@ -67,65 +69,93 @@ describe('useAuth', () => {
expect(result.current.user).toBe(mockCognitoUser);
});

it('should receive a Cognito user on Auth.signIn Hub event', async () => {
const mockCognitoUser = {
username: 'johndoe',
attributes: {
phone_number: '+1-234-567-890',
email: 'john@doe.com',
},
};
it.each(SUCCESS_EVENTS_WITH_USER)(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL it.each

'should receive a Cognito user on %s Hub event',
async () => {
currentAuthenticatedUser.mockResolvedValue(undefined);

const { result, waitForNextUpdate } = renderHook(() => useAuth());

await waitForNextUpdate();

expect(result.current.user).toBe(undefined);

// Simulate Auth signIn Hub action
act(() => {
Hub.dispatch('auth', { event: 'signIn', data: mockCognitoUser });
});

(Auth.currentAuthenticatedUser as jest.Mock).mockResolvedValue(undefined);
expect(result.current.user).toBe(mockCognitoUser);
}
);

it('should should unset user on Auth.signOut Hub event', async () => {
currentAuthenticatedUser.mockResolvedValue(mockCognitoUser);

const { result, waitForNextUpdate } = renderHook(() => useAuth());

await waitForNextUpdate();

expect(result.current.user).toBe(undefined);
expect(result.current.user).toBe(mockCognitoUser);

// Simulate Auth signIn Hub action
// Simulate Auth signOut Hub action
act(() => {
Hub.dispatch(
'auth',
{ event: 'signIn', data: mockCognitoUser },
'Auth',
Symbol.for('amplify_default')
);
Hub.dispatch('auth', { event: 'signOut' });
});

expect(result.current.user).toBe(mockCognitoUser);
expect(result.current.user).toBeUndefined();
});

it('should should unset user on Auth.signOut Hub event', async () => {
const mockCognitoUser = {
username: 'johndoe',
attributes: {
phone_number: '+1-234-567-890',
email: 'john@doe.com',
},
};

(Auth.currentAuthenticatedUser as jest.Mock).mockResolvedValue(
mockCognitoUser
);

const { result, waitForNextUpdate } = renderHook(() => useAuth());
it('invokes Auth.currentAuthenticatedUser on tokenRefresh event', async () => {
currentAuthenticatedUser.mockResolvedValue(mockCognitoUser);

const { waitForNextUpdate } = renderHook(() => useAuth());
await waitForNextUpdate();

expect(result.current.user).toBe(mockCognitoUser);
// Simulate Auth tokenRefresh Hub action
act(() => {
Hub.dispatch('auth', { event: 'tokenRefresh' });
});

expect(currentAuthenticatedUser).toHaveBeenCalled();
});

it.each(FAILURE_EVENTS_WITH_ERROR)(
'returns error on %s event',
async (event) => {
currentAuthenticatedUser.mockResolvedValue(mockCognitoUser);

const { result, waitForNextUpdate } = renderHook(() => useAuth());
await waitForNextUpdate();

act(() => {
Hub.dispatch('auth', {
event,
data: new Error('mock auth error'),
});
});

expect(result.current.user).toBeUndefined();
expect(result.current.error?.message).toBe('mock auth error');
}
);

it('returns error on autoSignIn_failure event', async () => {
currentAuthenticatedUser.mockResolvedValue(mockCognitoUser);

const { result, waitForNextUpdate } = renderHook(() => useAuth());
await waitForNextUpdate();

// Simulate Auth signOut Hub action
act(() => {
Hub.dispatch(
'auth',
{ event: 'signOut' },
'Auth',
Symbol.for('amplify_default')
);
// adapted from https://github.com/aws-amplify/amplify-js/blob/272c2c607cc4adb5ddc9421444887bdb382227a0/packages/auth/src/Auth.ts#L274-L278
Hub.dispatch('auth', {
event: 'autoSignIn_failure',
// autoSignIn_failure event only contains `message` but not `payload`.
message: 'autoSignInError',
});
});

expect(result.current.user).toBeUndefined();
expect(result.current.error?.message).toBe('autoSignInError');
});
});
88 changes: 58 additions & 30 deletions packages/react/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import * as React from 'react';

import Auth, { CognitoUser } from '@aws-amplify/auth';
import { Hub, HubCallback } from '@aws-amplify/core';

// Exposes relevant CognitoUser properties
interface AuthUser extends CognitoUser {
username: string;
attributes: Record<string, string>;
}
import { AmplifyUser } from '@aws-amplify/ui';
import { Auth } from 'aws-amplify';

export interface UseAuthResult {
user?: AuthUser;
user?: AmplifyUser;
isLoading: boolean;
error?: Error;
fetch?: () => void;
/** @deprecated Fetch is handled automatically, do not use this directly */
fetch?: () => Promise<void>;
}

/**
Expand All @@ -27,32 +23,64 @@ export const useAuth = (): UseAuthResult => {
user: undefined,
});

const handleAuth: HubCallback = ({ payload }) => {
switch (payload.event) {
case 'signIn':
return setResult({ user: payload.data as AuthUser, isLoading: false });
case 'signOut':
return setResult({ isLoading: false });
default:
break;
/**
* Hub events like `tokenRefresh` will not give back the user object.
* This util will be used to get current user after those events.
*/
const fetchCurrentUser = React.useCallback(async () => {
setResult({ user: undefined, isLoading: true, error: undefined });

try {
const user = (await Auth.currentAuthenticatedUser()) as AmplifyUser;
wlee221 marked this conversation as resolved.
Show resolved Hide resolved
setResult({ user, isLoading: false });
} catch (e) {
const error = e as Error;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this line for?

Copy link
Contributor Author

@wlee221 wlee221 Oct 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, so I needed to cast e to an Error, because e was any.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hoping we could do something like

} catch (error: Error) {
  setResult({ error, isLoading: false })
}

but TS does not support strongly typing catch statement, so left it like that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Thanks!

setResult({ error, isLoading: false });
}
};
}, [setResult]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since setResult is memoized, why do we need it here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very fair, we don't. 8082476


const fetch = () => {
setResult({ isLoading: true });
const handleAuth: HubCallback = React.useCallback(
({ payload }) => {
switch (payload.event) {
calebpollman marked this conversation as resolved.
Show resolved Hide resolved
// success events
case 'signIn':
case 'signUp':
case 'autoSignIn':
setResult({ user: payload.data as AmplifyUser, isLoading: false });
break;
case 'signOut':
setResult({ user: undefined, isLoading: false });
break;

Auth.currentAuthenticatedUser()
.then((user: AuthUser) => setResult({ user, isLoading: false }))
.catch((error: Error) => setResult({ error, isLoading: false }));
// failure events
case 'tokenRefresh_failure':
case 'signIn_failure':
setResult({ error: payload.data as Error, isLoading: false });
break;
case 'autoSignIn_failure':
// autoSignIn just returns error message. Wrap it to an Error object
setResult({ error: new Error(payload.message), isLoading: false });
break;

// Handle Hub Auth events
Hub.listen('auth', handleAuth);
// events that need another fetch
case 'tokenRefresh':
fetchCurrentUser();
break;
}
},
[setResult, fetchCurrentUser]
);

// Stop listening events on unmount
return () => Hub.remove('auth', handleAuth);
};
React.useEffect(() => {
const unsubscribe = Hub.listen('auth', handleAuth, 'useAuth');
fetchCurrentUser(); // on init, see if user is already logged in

React.useEffect(fetch, []);
return unsubscribe;
}, [handleAuth, fetchCurrentUser]);

return { ...result, fetch };
return {
...result,
/** @deprecated Fetch is handled automatically, do not use this directly */
fetch: fetchCurrentUser,
};
};