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