diff --git a/.eslintrc.js b/.eslintrc.js
index b809c7c8..01b672a2 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -15,8 +15,17 @@ module.exports = {
extends: [
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
],
rules: {
'@typescript-eslint/camelcase': 'off',
},
+ overrides: [
+ {
+ files: ['*.test.tsx'],
+ rules: {
+ '@typescript-eslint/ban-ts-ignore': 'off',
+ },
+ },
+ ],
};
diff --git a/README.md b/README.md
index 01baf716..e4458d74 100644
--- a/README.md
+++ b/README.md
@@ -38,16 +38,16 @@ npm install react react-dom @auth0/auth0-spa-js auth0/auth0-react
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
-import { AuthProvider } from '@auth0/auth0-react';
+import { Auth0Provider } from '@auth0/auth0-react';
ReactDOM.render(
-
- ,
+ ,
document.getElementById('app')
);
```
diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx
new file mode 100644
index 00000000..e3617301
--- /dev/null
+++ b/__mocks__/@auth0/auth0-spa-js.tsx
@@ -0,0 +1,19 @@
+export const handleRedirectCallback = jest
+ .fn()
+ .mockResolvedValue({ appState: {} });
+export const getTokenSilently = jest.fn();
+export const getUser = jest.fn();
+export const isAuthenticated = jest.fn().mockResolvedValue(false);
+export const loginWithRedirect = jest.fn();
+export const logout = jest.fn();
+
+export const Auth0Client = jest.fn().mockImplementation(() => {
+ return {
+ handleRedirectCallback,
+ getTokenSilently,
+ getUser,
+ isAuthenticated,
+ loginWithRedirect,
+ logout,
+ };
+});
diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx
new file mode 100644
index 00000000..f2dca1b4
--- /dev/null
+++ b/__tests__/auth-provider.test.tsx
@@ -0,0 +1,174 @@
+import { useContext } from 'react';
+import Auth0Context from '../src/auth0-context';
+import { renderHook } from '@testing-library/react-hooks';
+import {
+ Auth0Client,
+ // @ts-ignore
+ getTokenSilently,
+ // @ts-ignore
+ isAuthenticated,
+ // @ts-ignore
+ getUser,
+ // @ts-ignore
+ handleRedirectCallback,
+ // @ts-ignore
+ loginWithRedirect,
+ // @ts-ignore
+ logout,
+} from '@auth0/auth0-spa-js';
+import { createWrapper } from './helpers';
+
+describe('Auth0Provider', () => {
+ it('should provide the Auth0Provider result', async () => {
+ const wrapper = createWrapper();
+ const { result, waitForNextUpdate } = renderHook(
+ () => useContext(Auth0Context),
+ { wrapper }
+ );
+ expect(result.current).toBeDefined();
+ await waitForNextUpdate();
+ });
+
+ it('should configure an instance of the Auth0Client', async () => {
+ const opts = {
+ client_id: 'foo',
+ domain: 'bar',
+ };
+ const wrapper = createWrapper(opts);
+ const { waitForNextUpdate } = renderHook(() => useContext(Auth0Context), {
+ wrapper,
+ });
+ expect(Auth0Client).toHaveBeenCalledWith(opts);
+ await waitForNextUpdate();
+ });
+
+ it('should get token silently when logged out', async () => {
+ const wrapper = createWrapper();
+ const { waitForNextUpdate, result } = renderHook(
+ () => useContext(Auth0Context),
+ { wrapper }
+ );
+ expect(result.current.isLoading).toBe(true);
+ await waitForNextUpdate();
+ expect(result.current.isLoading).toBe(false);
+ expect(getTokenSilently).toHaveBeenCalled();
+ expect(result.current.isAuthenticated).toBe(false);
+ });
+
+ it('should get token silently when logged in', async () => {
+ isAuthenticated.mockResolvedValue(true);
+ getUser.mockResolvedValue('__test_user__');
+ const wrapper = createWrapper();
+ const { waitForNextUpdate, result } = renderHook(
+ () => useContext(Auth0Context),
+ { wrapper }
+ );
+ await waitForNextUpdate();
+ expect(getTokenSilently).toHaveBeenCalled();
+ expect(result.current.isAuthenticated).toBe(true);
+ expect(result.current.user).toBe('__test_user__');
+ });
+
+ it('should handle login_required errors when getting token', async () => {
+ getTokenSilently.mockRejectedValue({ error: 'login_required' });
+ const wrapper = createWrapper();
+ const { waitForNextUpdate, result } = renderHook(
+ () => useContext(Auth0Context),
+ { wrapper }
+ );
+ await waitForNextUpdate();
+ expect(getTokenSilently).toHaveBeenCalled();
+ expect(result.current.error).toBeUndefined();
+ expect(result.current.isAuthenticated).toBe(false);
+ });
+
+ it('should handle other errors when getting token', async () => {
+ getTokenSilently.mockRejectedValue({ error: '__test_error__' });
+ const wrapper = createWrapper();
+ const { waitForNextUpdate, result } = renderHook(
+ () => useContext(Auth0Context),
+ { wrapper }
+ );
+ await waitForNextUpdate();
+ expect(getTokenSilently).toHaveBeenCalled();
+ expect(result.current.error).toStrictEqual({ error: '__test_error__' });
+ expect(result.current.isAuthenticated).toBe(false);
+ });
+
+ it('should handle redirect callback success and clear the url', async () => {
+ window.history.pushState(
+ {},
+ document.title,
+ '/?code=__test_code__&state=__test_state__'
+ );
+ expect(window.location.href).toBe(
+ 'https://www.example.com/?code=__test_code__&state=__test_state__'
+ );
+ const wrapper = createWrapper();
+ const { waitForNextUpdate } = renderHook(() => useContext(Auth0Context), {
+ wrapper,
+ });
+ await waitForNextUpdate();
+ expect(handleRedirectCallback).toHaveBeenCalled();
+ expect(window.location.href).toBe('https://www.example.com/');
+ });
+
+ it('should handle redirect callback errors', async () => {
+ window.history.pushState({}, document.title, '/?error=__test_error__');
+ handleRedirectCallback.mockRejectedValue('__test_error__');
+ const wrapper = createWrapper();
+ const { waitForNextUpdate, result } = renderHook(
+ () => useContext(Auth0Context),
+ { wrapper }
+ );
+ await waitForNextUpdate();
+ expect(handleRedirectCallback).toHaveBeenCalled();
+ expect(result.current.error).toStrictEqual('__test_error__');
+ });
+
+ it('should handle redirect and call a custom handler', async () => {
+ window.history.pushState(
+ {},
+ document.title,
+ '/?code=__test_code__&state=__test_state__'
+ );
+ handleRedirectCallback.mockResolvedValue({ appState: { foo: 'bar' } });
+ const onRedirectCallback = jest.fn();
+ const wrapper = createWrapper({
+ onRedirectCallback,
+ });
+ const { waitForNextUpdate } = renderHook(() => useContext(Auth0Context), {
+ wrapper,
+ });
+ await waitForNextUpdate();
+ expect(onRedirectCallback).toHaveBeenCalledWith({ foo: 'bar' });
+ });
+
+ it('should provide a login method', async () => {
+ const wrapper = createWrapper();
+ const { waitForNextUpdate, result } = renderHook(
+ () => useContext(Auth0Context),
+ { wrapper }
+ );
+ await waitForNextUpdate();
+ expect(result.current.login).toBeInstanceOf(Function);
+ await result.current.login({ redirect_uri: '__redirect_uri__' });
+ expect(loginWithRedirect).toHaveBeenCalledWith({
+ redirect_uri: '__redirect_uri__',
+ });
+ });
+
+ it('should provide a logout method', async () => {
+ const wrapper = createWrapper();
+ const { waitForNextUpdate, result } = renderHook(
+ () => useContext(Auth0Context),
+ { wrapper }
+ );
+ await waitForNextUpdate();
+ expect(result.current.logout).toBeInstanceOf(Function);
+ await result.current.logout({ returnTo: '__return_to__' });
+ expect(logout).toHaveBeenCalledWith({
+ returnTo: '__return_to__',
+ });
+ });
+});
diff --git a/__tests__/auth-reducer.test.tsx b/__tests__/auth-reducer.test.tsx
new file mode 100644
index 00000000..f907e17f
--- /dev/null
+++ b/__tests__/auth-reducer.test.tsx
@@ -0,0 +1,42 @@
+import { reducer } from '../src/reducer';
+import { initialAuthState } from '../src/auth-state';
+
+describe('reducer', () => {
+ it('should initialise when authenticated', async () => {
+ const payload = {
+ isAuthenticated: true,
+ user: 'Bob',
+ };
+ expect(
+ reducer(initialAuthState, { type: 'INITIALISED', ...payload })
+ ).toEqual({
+ ...initialAuthState,
+ isLoading: false,
+ ...payload,
+ });
+ });
+
+ it('should initialise when not authenticated', async () => {
+ const payload = {
+ isAuthenticated: false,
+ };
+ expect(
+ reducer(initialAuthState, { type: 'INITIALISED', ...payload })
+ ).toEqual({
+ ...initialAuthState,
+ isLoading: false,
+ ...payload,
+ });
+ });
+
+ it('should handle error state', async () => {
+ const payload = {
+ error: new Error('__test_error__'),
+ };
+ expect(reducer(initialAuthState, { type: 'ERROR', ...payload })).toEqual({
+ ...initialAuthState,
+ isLoading: false,
+ ...payload,
+ });
+ });
+});
diff --git a/__tests__/auth0-provider.test.tsx b/__tests__/auth0-provider.test.tsx
deleted file mode 100644
index 5db7bac6..00000000
--- a/__tests__/auth0-provider.test.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useContext } from 'react';
-import { Auth0Context } from '../src/auth0-provider';
-import { renderHook } from '@testing-library/react-hooks';
-import { Auth0Client } from '@auth0/auth0-spa-js';
-import { createWrapper } from './helpers';
-
-jest.mock('@auth0/auth0-spa-js');
-
-describe('Auth0Provider', () => {
- it('should provide an instance of the Auth0Client', () => {
- const wrapper = createWrapper();
- const {
- result: { current },
- } = renderHook(() => useContext(Auth0Context), { wrapper });
- expect(current).toBeInstanceOf(Auth0Client);
- });
-
- it('should configure an instance of the Auth0Client', () => {
- const opts = {
- client_id: 'foo',
- domain: 'bar',
- };
- const wrapper = createWrapper(opts);
- renderHook(() => useContext(Auth0Context), {
- wrapper,
- });
- expect(Auth0Client).toHaveBeenCalledWith(opts);
- });
-});
diff --git a/__tests__/helpers.tsx b/__tests__/helpers.tsx
index 980ad2e6..0f728415 100644
--- a/__tests__/helpers.tsx
+++ b/__tests__/helpers.tsx
@@ -5,10 +5,11 @@ import Auth0Provider from '../src/auth0-provider';
export const createWrapper = ({
client_id = '__test_client_id__',
domain = '__test_domain__',
+ ...opts
}: Partial = {}) => ({
children,
}: PropsWithChildren<{}>): JSX.Element => (
-
+
{children}
);
diff --git a/__tests__/use-auth.test.tsx b/__tests__/use-auth.test.tsx
new file mode 100644
index 00000000..04635a0e
--- /dev/null
+++ b/__tests__/use-auth.test.tsx
@@ -0,0 +1,24 @@
+import useAuth0 from '../src/use-auth0';
+import { renderHook } from '@testing-library/react-hooks';
+import { createWrapper } from './helpers';
+
+describe('useAuth0', () => {
+ it('should provide the auth context', async () => {
+ const wrapper = createWrapper();
+ const {
+ result: { current },
+ waitForNextUpdate,
+ } = renderHook(useAuth0, { wrapper });
+ await waitForNextUpdate();
+ expect(current).toBeDefined();
+ });
+
+ it('should throw with no provider', () => {
+ const {
+ result: { current },
+ } = renderHook(useAuth0);
+ expect(current.login).toThrowError(
+ 'You forgot to wrap your component in .'
+ );
+ });
+});
diff --git a/__tests__/use-auth0.test.tsx b/__tests__/use-auth0.test.tsx
deleted file mode 100644
index ce06f5e7..00000000
--- a/__tests__/use-auth0.test.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import useAuth0 from '../src/use-auth0';
-import { renderHook } from '@testing-library/react-hooks';
-import { Auth0Client } from '@auth0/auth0-spa-js';
-import { createWrapper } from './helpers';
-
-jest.mock('@auth0/auth0-spa-js');
-
-describe('useAuth0', () => {
- it('should provide an instance of the Auth0Client', () => {
- const wrapper = createWrapper();
- const {
- result: { current },
- } = renderHook(useAuth0, { wrapper });
- expect(current).toBeInstanceOf(Auth0Client);
- });
-});
diff --git a/__tests__/utils.test.tsx b/__tests__/utils.test.tsx
new file mode 100644
index 00000000..6060ebc7
--- /dev/null
+++ b/__tests__/utils.test.tsx
@@ -0,0 +1,49 @@
+import { defaultOnRedirectCallback, hasAuthParams } from '../src/utils';
+
+describe('utils hasAuthParams', () => {
+ it('should recognise the code param', async () => {
+ ['?code=1', '?foo=1&code=2', '?code=1&foo=2'].forEach((search) =>
+ expect(hasAuthParams(search)).toBeTruthy()
+ );
+ });
+
+ it('should recognise the error param', async () => {
+ ['?error=1', '?foo=1&error=2', '?error=1&foo=2'].forEach((search) =>
+ expect(hasAuthParams(search)).toBeTruthy()
+ );
+ });
+
+ it('should ignore invalid params', async () => {
+ ['', '?', '?foo=1', '?code=&foo=2', '?error='].forEach((search) =>
+ expect(hasAuthParams(search)).toBeFalsy()
+ );
+ });
+});
+
+describe('utils defaultOnRedirectCallback', () => {
+ it('should remove auth params', async () => {
+ window.history.pushState(
+ {},
+ document.title,
+ '/?code=__test_code__&state=__test_state__'
+ );
+ expect(window.location.href).toBe(
+ 'https://www.example.com/?code=__test_code__&state=__test_state__'
+ );
+ defaultOnRedirectCallback();
+ expect(window.location.href).toBe('https://www.example.com/');
+ });
+
+ it('should redirect to app state param', async () => {
+ window.history.pushState(
+ {},
+ document.title,
+ '/?code=__test_code__&state=__test_state__'
+ );
+ expect(window.location.href).toBe(
+ 'https://www.example.com/?code=__test_code__&state=__test_state__'
+ );
+ defaultOnRedirectCallback({ redirectTo: '/foo' });
+ expect(window.location.href).toBe('https://www.example.com/foo');
+ });
+});
diff --git a/jest.config.js b/jest.config.js
index 0f7241aa..c53e8523 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -7,5 +7,14 @@ module.exports = {
'default',
['jest-junit', { outputDirectory: 'test-results/jest' }],
],
+ testURL: 'https://www.example.com/',
testRegex: '/__tests__/.+test.tsx?$',
+ coverageThreshold: {
+ global: {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: 100,
+ },
+ },
};
diff --git a/package-lock.json b/package-lock.json
index 1b7f9f59..92ede55f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2363,6 +2363,12 @@
"xregexp": "^4.3.0"
}
},
+ "eslint-plugin-react-hooks": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.0.0.tgz",
+ "integrity": "sha512-YKBY+kilK5wrwIdQnCF395Ya6nDro3EAMoe+2xFkmyklyhF16fH83TrQOo9zbZIDxBsXFgBbywta/0JKRNFDkw==",
+ "dev": true
+ },
"eslint-scope": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz",
diff --git a/package.json b/package.json
index 2d5e9ae4..5d9463ab 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
"@typescript-eslint/parser": "^2.30.0",
"eslint": "^6.8.0",
"eslint-plugin-react": "^7.19.0",
+ "eslint-plugin-react-hooks": "^4.0.0",
"husky": "^4.2.5",
"jest": "^25.5.2",
"jest-junit": "^10.0.0",
diff --git a/src/auth-state.tsx b/src/auth-state.tsx
new file mode 100644
index 00000000..01127489
--- /dev/null
+++ b/src/auth-state.tsx
@@ -0,0 +1,11 @@
+export interface AuthState {
+ error?: Error;
+ isAuthenticated: boolean;
+ isLoading: boolean;
+ user?: unknown;
+}
+
+export const initialAuthState: AuthState = {
+ isAuthenticated: false,
+ isLoading: true, // TODO: SSR support
+};
diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx
new file mode 100644
index 00000000..61f62c92
--- /dev/null
+++ b/src/auth0-context.tsx
@@ -0,0 +1,25 @@
+import { LogoutOptions, RedirectLoginOptions } from '@auth0/auth0-spa-js';
+import { createContext } from 'react';
+import { AuthState, initialAuthState } from './auth-state';
+
+export interface Auth0ContextInterface extends AuthState {
+ /**
+ * Login in with a redirect.
+ */
+ login: (options?: RedirectLoginOptions) => Promise;
+
+ /**
+ * Logout.
+ */
+ logout: (options?: LogoutOptions) => void;
+}
+
+const stub = (): never => {
+ throw new Error('You forgot to wrap your component in .');
+};
+
+export default createContext({
+ ...initialAuthState,
+ login: stub,
+ logout: stub,
+});
diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx
index 8764c0ce..b097f4bc 100644
--- a/src/auth0-provider.tsx
+++ b/src/auth0-provider.tsx
@@ -1,16 +1,59 @@
-import React, { createContext, PropsWithChildren } from 'react';
+import React, {
+ PropsWithChildren,
+ useEffect,
+ useReducer,
+ useState,
+} from 'react';
import { Auth0Client, Auth0ClientOptions } from '@auth0/auth0-spa-js';
+import Auth0Context from './auth0-context';
+import { AppState, defaultOnRedirectCallback, hasAuthParams } from './utils';
+import { reducer } from './reducer';
+import { initialAuthState } from './auth-state';
-export const Auth0Context = createContext(null);
+interface AuthProviderOptions extends PropsWithChildren {
+ onRedirectCallback?: (appState: AppState) => void;
+}
const Auth0Provider = ({
children,
+ onRedirectCallback = defaultOnRedirectCallback,
...opts
-}: PropsWithChildren): JSX.Element => {
- const client = new Auth0Client(opts);
+}: AuthProviderOptions): JSX.Element => {
+ const [client] = useState(() => new Auth0Client(opts));
+ const [state, dispatch] = useReducer(reducer, initialAuthState);
+
+ useEffect(() => {
+ (async (): Promise => {
+ try {
+ if (hasAuthParams()) {
+ const { appState } = await client.handleRedirectCallback();
+ onRedirectCallback(appState);
+ } else {
+ await client.getTokenSilently();
+ }
+ const isAuthenticated = await client.isAuthenticated();
+ const user = isAuthenticated && (await client.getUser());
+ dispatch({ type: 'INITIALISED', isAuthenticated, user });
+ } catch (error) {
+ if (error.error !== 'login_required') {
+ dispatch({ type: 'ERROR', error });
+ } else {
+ dispatch({ type: 'INITIALISED', isAuthenticated: false });
+ }
+ }
+ })();
+ }, [client, onRedirectCallback]);
return (
- {children}
+ => client.loginWithRedirect(opts),
+ logout: (opts): void => client.logout(opts),
+ }}
+ >
+ {children}
+
);
};
diff --git a/src/reducer.tsx b/src/reducer.tsx
new file mode 100644
index 00000000..3de269a7
--- /dev/null
+++ b/src/reducer.tsx
@@ -0,0 +1,23 @@
+import { AuthState } from './auth-state';
+
+type Action =
+ | { type: 'INITIALISED'; isAuthenticated: boolean; user?: unknown }
+ | { type: 'ERROR'; error: Error };
+
+export const reducer = (state: AuthState, action: Action): AuthState => {
+ switch (action.type) {
+ case 'INITIALISED':
+ return {
+ ...state,
+ isAuthenticated: action.isAuthenticated,
+ user: action.user,
+ isLoading: false,
+ };
+ case 'ERROR':
+ return {
+ ...state,
+ isLoading: false,
+ error: action.error,
+ };
+ }
+};
diff --git a/src/use-auth0.tsx b/src/use-auth0.tsx
index efc13076..49472ca1 100644
--- a/src/use-auth0.tsx
+++ b/src/use-auth0.tsx
@@ -1,5 +1,4 @@
import { useContext } from 'react';
-import { Auth0Context } from './auth0-provider';
-import { Auth0Client } from '@auth0/auth0-spa-js';
+import Auth0Context, { Auth0ContextInterface } from './auth0-context';
-export default (): Auth0Client | null => useContext(Auth0Context);
+export default (): Auth0ContextInterface => useContext(Auth0Context);
diff --git a/src/utils.tsx b/src/utils.tsx
new file mode 100644
index 00000000..38fec8f3
--- /dev/null
+++ b/src/utils.tsx
@@ -0,0 +1,18 @@
+const CODE_RE = /[?&]code=[^&]+/;
+const ERROR_RE = /[?&]error=[^&]+/;
+
+export type AppState = {
+ redirectTo?: string;
+ [key: string]: unknown;
+};
+
+export const hasAuthParams = (searchParams = window.location.search): boolean =>
+ CODE_RE.test(searchParams) || ERROR_RE.test(searchParams);
+
+export const defaultOnRedirectCallback = (appState?: AppState): void => {
+ window.history.replaceState(
+ {},
+ document.title,
+ appState?.redirectTo || window.location.pathname
+ );
+};
diff --git a/static/index.html b/static/index.html
index 704333af..f23a4219 100644
--- a/static/index.html
+++ b/static/index.html
@@ -24,35 +24,18 @@