Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const getTokenWithPopup = jest.fn();
const getUser = jest.fn();
const getIdTokenClaims = jest.fn();
const isAuthenticated = jest.fn(() => false);
const isAuthorized = jest.fn();
const loginWithPopup = jest.fn();
const loginWithRedirect = jest.fn();
const logout = jest.fn();
Expand All @@ -22,6 +23,7 @@ export const Auth0Client = jest.fn(() => {
getUser,
getIdTokenClaims,
isAuthenticated,
isAuthorized,
loginWithPopup,
loginWithRedirect,
logout,
Expand Down
121 changes: 121 additions & 0 deletions __tests__/auth-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,127 @@ describe('Auth0Provider', () => {
});
});

it('should provide a isAuthorized method', async () => {
clientMock.isAuthorized.mockResolvedValue(true);
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);

expect(result.current.isAuthorized).toBeInstanceOf(Function);
let isAuthorized;
await act(async () => {
if (result.current.isAuthorized) {
isAuthorized = await result.current.isAuthorized({
audience: 'test-audience',
scope: 'read:data',
});
}
});
expect(clientMock.isAuthorized).toHaveBeenCalledWith({
audience: 'test-audience',
scope: 'read:data',
});
expect(isAuthorized).toBe(true);
});

it('should return false from isAuthorized when not authorized', async () => {
clientMock.isAuthorized.mockResolvedValue(false);
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);

let isAuthorized;
await act(async () => {
if (result.current.isAuthorized) {
isAuthorized = await result.current.isAuthorized({
audience: 'test-audience',
scope: 'admin:write',
});
}
});
expect(clientMock.isAuthorized).toHaveBeenCalledWith({
audience: 'test-audience',
scope: 'admin:write',
});
expect(isAuthorized).toBe(false);
});

it('should handle errors from isAuthorized method', async () => {
clientMock.isAuthorized.mockRejectedValue(new Error('__test_error__'));
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);

await act(async () => {
if (result.current.isAuthorized) {
await expect(result.current.isAuthorized({
audience: 'test-audience',
scope: 'read:data',
})).rejects.toThrowError('__test_error__');
}
});
expect(clientMock.isAuthorized).toHaveBeenCalledWith({
audience: 'test-audience',
scope: 'read:data',
});
});

it('should normalize errors from isAuthorized method', async () => {
clientMock.isAuthorized.mockRejectedValue(new ProgressEvent('error'));
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);

await act(async () => {
if (result.current.isAuthorized) {
await expect(result.current.isAuthorized({
audience: 'test-audience',
scope: 'read:data',
})).rejects.toThrowError('Get access token failed');
}
});
});

it('should call isAuthorized in the scope of the Auth0 client', async () => {
clientMock.isAuthorized.mockReturnThis();
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);

await act(async () => {
if (result.current.isAuthorized) {
const returnedThis = await result.current.isAuthorized({
audience: 'test-audience',
scope: 'read:data',
});
expect(returnedThis).toStrictEqual(clientMock);
}
});
});

it('should memoize the isAuthorized method', async () => {
const wrapper = createWrapper();
const { result, rerender } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitFor(() => {
const memoized = result.current.isAuthorized;
rerender();
expect(result.current.isAuthorized).toBe(memoized);
});
});

it('should provide a handleRedirectCallback method', async () => {
clientMock.handleRedirectCallback.mockResolvedValue({
appState: { redirectUri: '/' },
Expand Down
141 changes: 135 additions & 6 deletions __tests__/use-auth.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,46 @@
import { Auth0Client } from '@auth0/auth0-spa-js';
import { act, renderHook, waitFor } from '@testing-library/react';
import React from 'react';
import { Auth0ContextInterface, initialContext } from '../src/auth0-context';
import useAuth0 from '../src/use-auth0';
import { createWrapper } from './helpers';

const mockClient = jest.mocked(new Auth0Client({ clientId: '', domain: '' }));

describe('useAuth0', () => {
let wrapper: ReturnType<typeof createWrapper>;

const TEST_AUDIENCE = 'test-audience';
const TEST_SCOPE = 'read:data';
const TEST_USER = { name: '__test_user__' };
const AUDIENCE_1 = 'audience1';
const SCOPE_1 = 'scope1';
const AUDIENCE_2 = 'audience2';
const SCOPE_2 = 'scope2';

beforeEach(() => {
jest.clearAllMocks();

mockClient.getUser.mockResolvedValue(TEST_USER);
mockClient.isAuthenticated.mockResolvedValue(true);
mockClient.isAuthorized.mockResolvedValue(true);

wrapper = createWrapper();
});

const expectAuthenticatedState = async (
result: { current: Auth0ContextInterface },
isAuthenticated = true
) => {
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(isAuthenticated);
expect(result.current.isLoading).toBe(false);
});
};

it('should provide the auth context', async () => {
const wrapper = createWrapper();
const {
result: { current }
result: { current },
} = renderHook(() => useAuth0(), { wrapper });
await waitFor(() => {
expect(current).toBeDefined();
Expand All @@ -26,10 +58,10 @@ describe('useAuth0', () => {

it('should throw when context is not associated with provider', async () => {
const context = React.createContext<Auth0ContextInterface>(initialContext);
const wrapper = createWrapper({ context });
const customWrapper = createWrapper({ context });
const {
result: { current },
} = renderHook(() => useAuth0(), { wrapper });
} = renderHook(() => useAuth0(), { wrapper: customWrapper });
await act(async () => {
expect(current.loginWithRedirect).toThrowError(
'You forgot to wrap your component in <Auth0Provider>.'
Expand All @@ -39,13 +71,110 @@ describe('useAuth0', () => {

it('should accept custom auth context', async () => {
const context = React.createContext<Auth0ContextInterface>(initialContext);
const wrapper = createWrapper({ context });
const customWrapper = createWrapper({ context });
const {
result: { current },
} = renderHook(() => useAuth0(context), { wrapper });
} = renderHook(() => useAuth0(context), { wrapper: customWrapper });
await waitFor(() => {
expect(current).toBeDefined();
expect(current.loginWithRedirect).not.toThrowError();
});
});

it('should handle audience and scope options', async () => {
const { result } = renderHook(
() => useAuth0(undefined, { audience: TEST_AUDIENCE, scope: TEST_SCOPE }),
{ wrapper }
);

await expectAuthenticatedState(result);

expect(mockClient.isAuthorized).toHaveBeenCalledWith({
audience: TEST_AUDIENCE,
scope: TEST_SCOPE,
});
});

it('should set isAuthenticated to false when isAuthorized returns false', async () => {
mockClient.isAuthorized.mockResolvedValue(false);

const { result } = renderHook(
() => useAuth0(undefined, { audience: TEST_AUDIENCE, scope: TEST_SCOPE }),
{ wrapper }
);

await expectAuthenticatedState(result, false);

expect(mockClient.isAuthorized).toHaveBeenCalledWith({
audience: TEST_AUDIENCE,
scope: TEST_SCOPE,
});
});

it('should not call isAuthorized when user is not authenticated', async () => {
mockClient.getUser.mockResolvedValue(undefined);
mockClient.isAuthenticated.mockResolvedValue(false);

const { result } = renderHook(
() => useAuth0(undefined, { audience: TEST_AUDIENCE, scope: TEST_SCOPE }),
{ wrapper }
);

await expectAuthenticatedState(result, false);

expect(mockClient.isAuthorized).not.toHaveBeenCalled();
});

it('should not call isAuthorized when no audience or scope provided', async () => {
const { result } = renderHook(() => useAuth0(), { wrapper });

await expectAuthenticatedState(result);

expect(mockClient.isAuthorized).not.toHaveBeenCalled();
});

it('should show loading state during auth check', async () => {
mockClient.isAuthorized.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(true), 100))
);

const { result } = renderHook(
() => useAuth0(undefined, { audience: TEST_AUDIENCE }),
{ wrapper }
);

expect(result.current.isLoading).toBe(true);

await expectAuthenticatedState(result);
});

it('should re-check authorization when dependencies change', async () => {
const { result, rerender } = renderHook(
({ audience, scope }) => useAuth0(undefined, { audience, scope }),
{
wrapper,
initialProps: { audience: AUDIENCE_1, scope: SCOPE_1 },
}
);

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});

expect(mockClient.isAuthorized).toHaveBeenCalledWith({
audience: AUDIENCE_1,
scope: SCOPE_1,
});

rerender({ audience: AUDIENCE_2, scope: SCOPE_2 });

await waitFor(() => {
expect(mockClient.isAuthorized).toHaveBeenCalledWith({
audience: AUDIENCE_2,
scope: SCOPE_2,
});
});

expect(mockClient.isAuthorized).toHaveBeenCalledTimes(2);
});
});
15 changes: 13 additions & 2 deletions src/auth0-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface Auth0ContextInterface<TUser extends User = User>
*
* If refresh tokens are used, the token endpoint is called directly with the
* 'refresh_token' grant. If no refresh token is available to make this call,
* the SDK will only fall back to using an iframe to the '/authorize' URL if
* the SDK will only fall back to using an iframe to the '/authorize' URL if
* the `useRefreshTokensFallback` setting has been set to `true`. By default this
* setting is `false`.
*
Expand Down Expand Up @@ -140,8 +140,18 @@ export interface Auth0ContextInterface<TUser extends User = User>
* @param url The URL to that should be used to retrieve the `state` and `code` values. Defaults to `window.location.href` if not given.
*/
handleRedirectCallback: (url?: string) => Promise<RedirectLoginResult>;
}

/**
* Validates if the current token contains the required scopes and audience.
*
* @param options Options for scope and audience validation.
* @returns `true` if the token contains the required scopes and audience, otherwise `false`.
*/
isAuthorized?: (options: {
audience?: string;
scope?: string;
}) => Promise<boolean>;
}
/**
* @ignore
*/
Expand All @@ -163,6 +173,7 @@ export const initialContext = {
loginWithPopup: stub,
logout: stub,
handleRedirectCallback: stub,
isAuthorized: stub,
};

/**
Expand Down
15 changes: 15 additions & 0 deletions src/auth0-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
GetTokenWithPopupOptions,
RedirectLoginResult,
GetTokenSilentlyOptions,
AuthorizationParams,
User,
} from '@auth0/auth0-spa-js';
import Auth0Context, {
Expand Down Expand Up @@ -198,7 +199,7 @@
const user = await client.getUser();
dispatch({ type: 'LOGIN_POPUP_COMPLETE', user });
},
[client]

Check warning on line 202 in src/auth0-provider.tsx

View workflow job for this annotation

GitHub Actions / Build Package

React Hook useCallback has a missing dependency: 'handleError'. Either include it or remove the dependency array

Check warning on line 202 in src/auth0-provider.tsx

View workflow job for this annotation

GitHub Actions / BrowserStack Tests

React Hook useCallback has a missing dependency: 'handleError'. Either include it or remove the dependency array
);

const logout = useCallback(
Expand Down Expand Up @@ -256,6 +257,18 @@
[client]
);

const isAuthorized = useCallback(
async (options: AuthorizationParams): Promise<boolean> => {
try {
return await client.isAuthorized(options);
} catch (error) {
handleError(tokenError(error));
return false;
}
},
[client, handleError]
);

const handleRedirectCallback = useCallback(
async (url?: string): Promise<RedirectLoginResult> => {
try {
Expand All @@ -282,6 +295,7 @@
loginWithPopup,
logout,
handleRedirectCallback,
isAuthorized,
};
}, [
state,
Expand All @@ -292,6 +306,7 @@
loginWithPopup,
logout,
handleRedirectCallback,
isAuthorized,
]);

return <context.Provider value={contextValue}>{children}</context.Provider>;
Expand Down
Loading
Loading