diff --git a/EXAMPLES.md b/EXAMPLES.md index 40745e91..7b82e86f 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,9 +1,9 @@ # Examples -1. [Protecting a route in a `react-router-dom` app](#1-protecting-a-route-in-a--react-router-dom--app) +1. [Protecting a route in a `react-router-dom` app](#1-protecting-a-route-in-a-react-router-dom-app) 2. [Protecting a route in a Gatsby app](#2-protecting-a-route-in-a-gatsby-app) -3. [Protecting a route in a Next.js app (in SPA mode)](#3-protecting-a-route-in-a-nextjs-app--in-spa-mode-) -4. [Create a `useApi` hook for accessing protected APIs with an access token.](#4-create-a--useapi--hook-for-accessing-protected-apis-with-an-access-token) +3. [Protecting a route in a Next.js app (in SPA mode)](#3-protecting-a-route-in-a-nextjs-app-in-spa-mode) +4. [Create a `useApi` hook for accessing protected APIs with an access token.](#4-create-a-useapi-hook-for-accessing-protected-apis-with-an-access-token) ## 1. Protecting a route in a `react-router-dom` app @@ -33,8 +33,8 @@ export default function App() { return ( {/* Don't forget to add the history to your router */} @@ -70,8 +70,8 @@ export const wrapRootElement = ({ element }) => { return ( {element} @@ -125,8 +125,8 @@ class MyApp extends App { return ( @@ -164,20 +164,17 @@ export default withAuthenticationRequired(Profile); ```js // use-api.js import { useEffect, useState } from 'react'; -import { useAuth0 } from '@auth0/auth0-react'; export const useApi = (url, options = {}) => { - const { isAuthenticated } = useAuth0(); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - const [response, setResponse] = useState(true); + const { getAccessTokenSilently } = useAuth0(); + const [state, setState] = useState({ + error: null, + loading: true, + data: null, + }); + const [refreshIndex, setRefreshIndex] = useState(0); useEffect(() => { - if (!isAuthenticated) { - setLoading(false); - setError(new Error('The user is not signed in')); - return; - } (async () => { try { const { audience, scope, ...fetchOptions } = options; @@ -190,19 +187,25 @@ export const useApi = (url, options = {}) => { Authorization: `Bearer ${accessToken}`, }, }); - setResponse(await res.json()); + setState({ + ...state, + data: await res.json(), + error: null, + loading: false, + }); } catch (error) { - setError(error); - } finally { - setLoading(false); + setState({ + ...state, + error, + loading: false, + }); } })(); - }, []); + }, [refreshIndex]); return { - loading, - error, - response, + ...state, + refresh: () => setRefreshIndex(refreshIndex + 1), }; }; ``` @@ -214,17 +217,31 @@ Then use it for accessing protected APIs from your components: import { useApi } from './use-api'; export const Profile = () => { - const { loading, error, response: users } = useApi( + const opts = { + audience: 'https://api.example.com/', + scope: 'read:users', + }; + const { login, getTokenWithPopup } = useAuth0(); + const { loading, error, refresh, data: users } = useApi( 'https://api.example.com/users', - { - audience: 'https://api.example.com/', - scope: 'read:users', - } + opts ); + const getTokenAndTryAgain = async () => { + await getTokenWithPopup(opts); + refresh(); + }; if (loading) { return
Loading...
; } if (error) { + if (error.error === 'login_required') { + return ; + } + if (error.error === 'consent_required') { + return ( + + ); + } return
Oops {error.message}
; } return ( diff --git a/README.md b/README.md index ae6b773e..8d4a54e0 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ import App from './App'; ReactDOM.render( , diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx index 1ce51399..f99c20c0 100644 --- a/__tests__/auth-provider.test.tsx +++ b/__tests__/auth-provider.test.tsx @@ -20,14 +20,23 @@ describe('Auth0Provider', () => { it('should configure an instance of the Auth0Client', async () => { const opts = { - client_id: 'foo', + clientId: 'foo', domain: 'bar', + redirectUri: 'baz', + maxAge: 'qux', + extra_param: '__test_extra_param__', }; const wrapper = createWrapper(opts); const { waitForNextUpdate } = renderHook(() => useContext(Auth0Context), { wrapper, }); - expect(Auth0Client).toHaveBeenCalledWith(opts); + expect(Auth0Client).toHaveBeenCalledWith({ + client_id: 'foo', + domain: 'bar', + redirect_uri: 'baz', + max_age: 'qux', + extra_param: '__test_extra_param__', + }); await waitForNextUpdate(); }); @@ -75,7 +84,8 @@ describe('Auth0Provider', () => { it('should handle other errors when getting token', async () => { clientMock.getTokenSilently.mockRejectedValue({ - error_description: '__test_error__', + error: '__test_error__', + error_description: '__test_error_description__', }); const wrapper = createWrapper(); const { waitForNextUpdate, result } = renderHook( @@ -86,7 +96,7 @@ describe('Auth0Provider', () => { expect(clientMock.getTokenSilently).toHaveBeenCalled(); expect(() => { throw result.current.error; - }).toThrowError('__test_error__'); + }).toThrowError('__test_error_description__'); expect(result.current.isAuthenticated).toBe(false); }); @@ -198,7 +208,7 @@ describe('Auth0Provider', () => { await waitForNextUpdate(); expect(result.current.loginWithRedirect).toBeInstanceOf(Function); await result.current.loginWithRedirect({ - redirect_uri: '__redirect_uri__', + redirectUri: '__redirect_uri__', }); expect(clientMock.loginWithRedirect).toHaveBeenCalledWith({ redirect_uri: '__redirect_uri__', @@ -233,6 +243,19 @@ describe('Auth0Provider', () => { expect(token).toBe('token'); }); + it('should normalize errors from getAccessTokenSilently method', async () => { + clientMock.getTokenSilently.mockRejectedValue(new ProgressEvent('error')); + const wrapper = createWrapper(); + const { waitForNextUpdate, result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitForNextUpdate(); + expect(result.current.getAccessTokenSilently).rejects.toThrowError( + 'Get access token failed' + ); + }); + it('should provide a getAccessTokenWithPopup method', async () => { clientMock.getTokenWithPopup.mockResolvedValue('token'); const wrapper = createWrapper(); @@ -247,6 +270,19 @@ describe('Auth0Provider', () => { expect(token).toBe('token'); }); + it('should normalize errors from getAccessTokenWithPopup method', async () => { + clientMock.getTokenWithPopup.mockRejectedValue(new ProgressEvent('error')); + const wrapper = createWrapper(); + const { waitForNextUpdate, result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitForNextUpdate(); + expect(result.current.getAccessTokenWithPopup).rejects.toThrowError( + 'Get access token failed' + ); + }); + it('should provide a getIdTokenClaims method', async () => { clientMock.getIdTokenClaims.mockResolvedValue({ claim: '__test_claim__', diff --git a/__tests__/helpers.tsx b/__tests__/helpers.tsx index 0f728415..11662387 100644 --- a/__tests__/helpers.tsx +++ b/__tests__/helpers.tsx @@ -3,13 +3,13 @@ import React, { PropsWithChildren } from 'react'; import Auth0Provider from '../src/auth0-provider'; export const createWrapper = ({ - client_id = '__test_client_id__', + clientId = '__test_client_id__', domain = '__test_domain__', ...opts }: Partial = {}) => ({ children, }: PropsWithChildren<{}>): JSX.Element => ( - + {children} ); diff --git a/__tests__/utils.test.tsx b/__tests__/utils.test.tsx index 7d1364ab..f3fc50b0 100644 --- a/__tests__/utils.test.tsx +++ b/__tests__/utils.test.tsx @@ -2,6 +2,8 @@ import { defaultOnRedirectCallback, hasAuthParams, loginError, + OAuthError, + tokenError, } from '../src/utils'; describe('utils hasAuthParams', () => { @@ -52,17 +54,20 @@ describe('utils defaultOnRedirectCallback', () => { }); }); -describe('utils loginError', () => { +describe('utils error', () => { it('should return the original error', async () => { const error = new Error('__test_error__'); expect(loginError(error)).toBe(error); }); - it('should convert an OAuth error to a JS error', async () => { - const error = { error_description: '__test_error__' }; + it('should convert OAuth error data to an OAuth JS error', async () => { + const error = { + error: '__test_error__', + error_description: '__test_error_description__', + }; expect(() => { - throw loginError(error); - }).toThrowError('__test_error__'); + throw tokenError(error); + }).toThrow(OAuthError); }); it('should convert a ProgressEvent error to a JS error', async () => { @@ -71,4 +76,20 @@ describe('utils loginError', () => { throw loginError(error); }).toThrowError('Login failed'); }); + + it('should produce an OAuth JS error with error_description properties', async () => { + const error = new OAuthError( + '__test_error__', + '__test_error_description__' + ); + expect(error.error).toBe('__test_error__'); + expect(error.error_description).toBe('__test_error_description__'); + expect(error.message).toBe('__test_error_description__'); + }); + + it('should produce an OAuth JS error with error properties', async () => { + const error = new OAuthError('__test_error__'); + expect(error.error).toBe('__test_error__'); + expect(error.message).toBe('__test_error__'); + }); }); diff --git a/__tests__/withLoginRequired.test.tsx b/__tests__/withLoginRequired.test.tsx index cdf321fa..623d3ca8 100644 --- a/__tests__/withLoginRequired.test.tsx +++ b/__tests__/withLoginRequired.test.tsx @@ -4,7 +4,7 @@ import withAuthenticationRequired from '../src/with-login-required'; import { render, screen, waitFor } from '@testing-library/react'; import { Auth0Client } from '@auth0/auth0-spa-js'; import Auth0Provider from '../src/auth0-provider'; -import { mocked } from 'ts-jest'; +import { mocked } from 'ts-jest/utils'; const mockClient = mocked(new Auth0Client({ client_id: '', domain: '' })); @@ -13,7 +13,7 @@ describe('withAuthenticationRequired', () => { const MyComponent = (): JSX.Element => <>Private; const WrappedComponent = withAuthenticationRequired(MyComponent); render( - + ); @@ -28,7 +28,7 @@ describe('withAuthenticationRequired', () => { const MyComponent = (): JSX.Element => <>Private; const WrappedComponent = withAuthenticationRequired(MyComponent); render( - + ); @@ -47,7 +47,7 @@ describe('withAuthenticationRequired', () => { OnRedirecting ); render( - + ); diff --git a/jest.config.js b/jest.config.js index c53e8523..78f1217b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,13 @@ module.exports = { ], testURL: 'https://www.example.com/', testRegex: '/__tests__/.+test.tsx?$', + globals: { + 'ts-jest': { + tsConfig: { + target: 'es6', + }, + }, + }, coverageThreshold: { global: { branches: 100, diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 8dc4c62c..09c0292e 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -1,15 +1,33 @@ import { + BaseLoginOptions, GetIdTokenClaimsOptions, GetTokenSilentlyOptions, GetTokenWithPopupOptions, IdToken, LogoutOptions, PopupLoginOptions, - RedirectLoginOptions, } from '@auth0/auth0-spa-js'; import { createContext } from 'react'; import { AuthState, initialAuthState } from './auth-state'; +export interface RedirectLoginOptions extends BaseLoginOptions { + /** + * The URL where Auth0 will redirect your browser to with + * the authentication result. It must be whitelisted in + * the "Allowed Callback URLs" field in your Auth0 Application's + * settings. + */ + redirectUri?: string; + /** + * Used to store state before doing the redirect + */ + appState?: any; // eslint-disable-line @typescript-eslint/no-explicit-any + /** + * Used to add to the URL fragment before redirecting + */ + fragment?: string; +} + export interface Auth0ContextInterface extends AuthState { /** * Get an access token. diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index 18747bcb..4e75d70d 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -9,28 +9,138 @@ import { Auth0ClientOptions, IdToken, PopupLoginOptions, + RedirectLoginOptions as Auth0RedirectLoginOptions, + CacheLocation, } from '@auth0/auth0-spa-js'; -import Auth0Context from './auth0-context'; +import Auth0Context, { RedirectLoginOptions } from './auth0-context'; import { AppState, defaultOnRedirectCallback, loginError, hasAuthParams, + wrappedGetToken, } from './utils'; import { reducer } from './reducer'; import { initialAuthState } from './auth-state'; -export interface Auth0ProviderOptions - extends PropsWithChildren { +export interface Auth0ProviderOptions extends PropsWithChildren<{}> { + /** + * By default this removes the code and state parameters from the url when you are redirected from the authorize page. + * It uses `window.history` but you might want to overwrite this if you are using a custom router, like `react-router-dom` + * See the EXAMPLES.md for more info. + */ onRedirectCallback?: (appState: AppState) => void; + /** + * Your Auth0 account domain such as `'example.auth0.com'`, + * `'example.eu.auth0.com'` or , `'example.mycompany.com'` + * (when using [custom domains](https://auth0.com/docs/custom-domains)) + */ + domain: string; + /** + * The issuer to be used for validation of JWTs, optionally defaults to the domain above + */ + issuer?: string; + /** + * The Client ID found on your Application settings page + */ + clientId: string; + /** + * The default URL where Auth0 will redirect your browser to with + * the authentication result. It must be whitelisted in + * the "Allowed Callback URLs" field in your Auth0 Application's + * settings. If not provided here, it should be provided in the other + * methods that provide authentication. + */ + redirectUri?: string; + /** + * The value in seconds used to account for clock skew in JWT expirations. + * Typically, this value is no more than a minute or two at maximum. + * Defaults to 60s. + */ + leeway?: number; + /** + * The location to use when storing cache data. Valid values are `memory` or `localstorage`. + * The default setting is `memory`. + */ + cacheLocation?: CacheLocation; + /** + * If true, refresh tokens are used to fetch new access tokens from the Auth0 server. If false, the legacy technique of using a hidden iframe and the `authorization_code` grant with `prompt=none` is used. + * The default setting is `false`. + * + * **Note**: Use of refresh tokens must be enabled by an administrator on your Auth0 client application. + */ + useRefreshTokens?: boolean; + /** + * A maximum number of seconds to wait before declaring background calls to /authorize as failed for timeout + * Defaults to 60s. + */ + authorizeTimeoutInSeconds?: number; + /** + * Changes to recommended defaults, like defaultScope + */ + advancedOptions?: { + /** + * The default scope to be included with all requests. + * If not provided, 'openid profile email' is used. This can be set to `null` in order to effectively remove the default scopes. + * + * Note: The `openid` scope is **always applied** regardless of this setting. + */ + defaultScope?: string; + }; + /** + * Maximum allowable elasped time (in seconds) since authentication. + * If the last time the user authenticated is greater than this value, + * the user must be reauthenticated. + */ + maxAge?: string | number; + /** + * The default scope to be used on authentication requests. + * The defaultScope defined in the Auth0Client is included + * along with this scope + */ + scope?: string; + /** + * The default audience to be used for requesting API access. + */ + audience?: string; + /** + * If you need to send custom parameters to the Authorization Server, + * make sure to use the original parameter name. + */ + [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any } +const toAuth0ClientOptions = ( + opts: Auth0ProviderOptions +): Auth0ClientOptions => { + const { clientId, redirectUri, maxAge, ...validOpts } = opts; + return { + ...validOpts, + client_id: clientId, + redirect_uri: redirectUri, + max_age: maxAge, + }; +}; + +const toAuth0LoginRedirectOptions = ( + opts?: Auth0RedirectLoginOptions +): RedirectLoginOptions | undefined => { + if (!opts) { + return; + } + const { redirectUri, ...validOpts } = opts; + return { + ...validOpts, + redirect_uri: redirectUri, + }; +}; + const Auth0Provider = ({ children, onRedirectCallback = defaultOnRedirectCallback, ...opts }: Auth0ProviderOptions): JSX.Element => { - const [client] = useState(() => new Auth0Client(opts)); + const [client] = useState(() => new Auth0Client(toAuth0ClientOptions(opts))); const [state, dispatch] = useReducer(reducer, initialAuthState); useEffect(() => { @@ -61,6 +171,7 @@ const Auth0Provider = ({ await client.loginWithPopup(options); } catch (error) { dispatch({ type: 'ERROR', error: loginError(error) }); + return; } const isAuthenticated = await client.isAuthenticated(); const user = isAuthenticated && (await client.getUser()); @@ -71,14 +182,12 @@ const Auth0Provider = ({ => - client.getTokenSilently(opts), - getAccessTokenWithPopup: (opts): Promise => - client.getTokenWithPopup(opts), + getAccessTokenSilently: wrappedGetToken(client.getTokenSilently), + getAccessTokenWithPopup: wrappedGetToken(client.getTokenWithPopup), getIdTokenClaims: (opts): Promise => client.getIdTokenClaims(opts), loginWithRedirect: (opts): Promise => - client.loginWithRedirect(opts), + client.loginWithRedirect(toAuth0LoginRedirectOptions(opts)), loginWithPopup: (opts): Promise => loginWithPopup(opts), logout: (opts): void => client.logout(opts), }} diff --git a/src/index.tsx b/src/index.tsx index c9d5936b..93f393fd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,4 +5,14 @@ export { export { default as useAuth0 } from './use-auth0'; export { default as withAuth0, WithAuth0Props } from './with-auth0'; export { default as withAuthenticationRequired } from './with-login-required'; -export { Auth0ContextInterface } from './auth0-context'; +export { OAuthError } from './utils'; +export { Auth0ContextInterface, RedirectLoginOptions } from './auth0-context'; +export { + PopupLoginOptions, + PopupConfigOptions, + GetIdTokenClaimsOptions, + GetTokenWithPopupOptions, + LogoutOptions, + CacheLocation, + IdToken, +} from '@auth0/auth0-spa-js'; diff --git a/src/reducer.tsx b/src/reducer.tsx index db520d4d..5377e8ad 100644 --- a/src/reducer.tsx +++ b/src/reducer.tsx @@ -23,6 +23,7 @@ export const reducer = (state: AuthState, action: Action): AuthState => { isAuthenticated: action.isAuthenticated, user: action.user, isLoading: false, + error: undefined, }; case 'ERROR': return { diff --git a/src/utils.tsx b/src/utils.tsx index 7e209808..51cf3a0f 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -1,3 +1,8 @@ +import { + GetTokenSilentlyOptions, + GetTokenWithPopupOptions, +} from '@auth0/auth0-spa-js'; + const CODE_RE = /[?&]code=[^&]+/; const ERROR_RE = /[?&]error=[^&]+/; @@ -17,11 +22,38 @@ export const defaultOnRedirectCallback = (appState?: AppState): void => { ); }; -export const loginError = ( - error: Error | { error_description: string } | ProgressEvent -): Error => - error instanceof Error - ? error - : new Error( - 'error_description' in error ? error.error_description : 'Login failed' - ); +export class OAuthError extends Error { + constructor(public error: string, public error_description?: string) { + super(error_description || error); + } +} + +const normalizeErrorFn = (fallbackMessage: string) => ( + error: Error | { error: string; error_description?: string } | ProgressEvent +): Error => { + if ('error' in error) { + return new OAuthError(error.error, error.error_description); + } + if (error instanceof Error) { + return error; + } + return new Error(fallbackMessage); +}; + +export const loginError = normalizeErrorFn('Login failed'); + +export const tokenError = normalizeErrorFn('Get access token failed'); + +export const wrappedGetToken = ( + getTokenFn: ( + opts?: GetTokenSilentlyOptions | GetTokenWithPopupOptions + ) => Promise +) => async ( + opts?: GetTokenSilentlyOptions | GetTokenWithPopupOptions +): Promise => { + try { + return await getTokenFn(opts); + } catch (error) { + throw tokenError(error); + } +}; diff --git a/static/index.html b/static/index.html index f767ad8d..c4c692b9 100644 --- a/static/index.html +++ b/static/index.html @@ -78,8 +78,8 @@ ReactDOM.render( ,