diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index 8a8fb48c..83dfa820 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -1,7 +1,10 @@ export const handleRedirectCallback = jest.fn(() => ({ appState: {} })); export const getTokenSilently = jest.fn(); +export const getTokenWithPopup = jest.fn(); export const getUser = jest.fn(); +export const getIdTokenClaims = jest.fn(); export const isAuthenticated = jest.fn(() => false); +export const loginWithPopup = jest.fn(); export const loginWithRedirect = jest.fn(); export const logout = jest.fn(); @@ -9,8 +12,11 @@ export const Auth0Client = jest.fn(() => { return { handleRedirectCallback, getTokenSilently, + getTokenWithPopup, getUser, + getIdTokenClaims, isAuthenticated, + loginWithPopup, loginWithRedirect, logout, }; diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx index 42412a21..5f6369e1 100644 --- a/__tests__/auth-provider.test.tsx +++ b/__tests__/auth-provider.test.tsx @@ -1,17 +1,23 @@ import { useContext } from 'react'; import Auth0Context from '../src/auth0-context'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, act } from '@testing-library/react-hooks'; import { Auth0Client, // @ts-ignore getTokenSilently, // @ts-ignore + getTokenWithPopup, + // @ts-ignore isAuthenticated, // @ts-ignore getUser, // @ts-ignore + getIdTokenClaims, + // @ts-ignore handleRedirectCallback, // @ts-ignore + loginWithPopup, + // @ts-ignore loginWithRedirect, // @ts-ignore logout, @@ -148,6 +154,50 @@ describe('Auth0Provider', () => { expect(onRedirectCallback).toHaveBeenCalledWith({ foo: 'bar' }); }); + it('should login with a popup', async () => { + isAuthenticated.mockResolvedValue(false); + const wrapper = createWrapper(); + const { waitForNextUpdate, result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitForNextUpdate(); + expect(result.current.isAuthenticated).toBe(false); + isAuthenticated.mockResolvedValue(true); + act(() => { + result.current.loginWithPopup(); + }); + expect(result.current.isLoading).toBe(true); + await waitForNextUpdate(); + expect(result.current.isLoading).toBe(false); + expect(loginWithPopup).toHaveBeenCalled(); + expect(result.current.isAuthenticated).toBe(true); + }); + + it('should handle errors when logging in with a popup', async () => { + isAuthenticated.mockResolvedValue(false); + const wrapper = createWrapper(); + const { waitForNextUpdate, result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitForNextUpdate(); + expect(result.current.isAuthenticated).toBe(false); + isAuthenticated.mockResolvedValue(false); + loginWithPopup.mockRejectedValue(new Error('__test_error__')); + act(() => { + result.current.loginWithPopup(); + }); + expect(result.current.isLoading).toBe(true); + await waitForNextUpdate(); + expect(result.current.isLoading).toBe(false); + expect(loginWithPopup).toHaveBeenCalled(); + expect(result.current.isAuthenticated).toBe(false); + expect(() => { + throw result.current.error; + }).toThrowError('__test_error__'); + }); + it('should provide a login method', async () => { const wrapper = createWrapper(); const { waitForNextUpdate, result } = renderHook( @@ -189,4 +239,32 @@ describe('Auth0Provider', () => { expect(getTokenSilently).toHaveBeenCalled(); expect(token).toBe('token'); }); + + it('should provide a getTokenWithPopup method', async () => { + getTokenWithPopup.mockResolvedValue('token'); + const wrapper = createWrapper(); + const { waitForNextUpdate, result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitForNextUpdate(); + expect(result.current.getTokenWithPopup).toBeInstanceOf(Function); + const token = await result.current.getTokenWithPopup(); + expect(getTokenWithPopup).toHaveBeenCalled(); + expect(token).toBe('token'); + }); + + it('should provide a getIdTokenClaims method', async () => { + getIdTokenClaims.mockResolvedValue({ claim: '__test_claim__' }); + const wrapper = createWrapper(); + const { waitForNextUpdate, result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitForNextUpdate(); + expect(result.current.getIdTokenClaims).toBeInstanceOf(Function); + const claims = await result.current.getIdTokenClaims(); + expect(getIdTokenClaims).toHaveBeenCalled(); + expect(claims).toStrictEqual({ claim: '__test_claim__' }); + }); }); diff --git a/__tests__/utils.test.tsx b/__tests__/utils.test.tsx index d5c31ed0..7d1364ab 100644 --- a/__tests__/utils.test.tsx +++ b/__tests__/utils.test.tsx @@ -47,7 +47,7 @@ describe('utils defaultOnRedirectCallback', () => { expect(window.location.href).toBe( 'https://www.example.com/?code=__test_code__&state=__test_state__' ); - defaultOnRedirectCallback({ redirectTo: '/foo' }); + defaultOnRedirectCallback({ returnTo: '/foo' }); expect(window.location.href).toBe('https://www.example.com/foo'); }); }); diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 377b0820..2ed49981 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -1,6 +1,10 @@ import { + GetIdTokenClaimsOptions, GetTokenSilentlyOptions, + GetTokenWithPopupOptions, + IdToken, LogoutOptions, + PopupLoginOptions, RedirectLoginOptions, } from '@auth0/auth0-spa-js'; import { createContext } from 'react'; @@ -12,11 +16,26 @@ export interface Auth0ContextInterface extends AuthState { */ getToken: (options?: GetTokenSilentlyOptions) => Promise; + /** + * Get an access token interactively. + */ + getTokenWithPopup: (options?: GetTokenWithPopupOptions) => Promise; + + /** + * Returns all claims from the id_token if available. + */ + getIdTokenClaims: (options?: GetIdTokenClaimsOptions) => Promise; + /** * Login in with a redirect. */ login: (options?: RedirectLoginOptions) => Promise; + /** + * Login in with a popup. + */ + loginWithPopup: (options?: PopupLoginOptions) => Promise; + /** * Logout. */ @@ -30,6 +49,9 @@ const stub = (): never => { export default createContext({ ...initialAuthState, getToken: stub, + getTokenWithPopup: stub, + getIdTokenClaims: stub, login: stub, + loginWithPopup: stub, logout: stub, }); diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index f62b6c11..75a168b8 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -4,7 +4,12 @@ import React, { useReducer, useState, } from 'react'; -import { Auth0Client, Auth0ClientOptions } from '@auth0/auth0-spa-js'; +import { + Auth0Client, + Auth0ClientOptions, + IdToken, + PopupLoginOptions, +} from '@auth0/auth0-spa-js'; import Auth0Context from './auth0-context'; import { AppState, @@ -50,12 +55,29 @@ const Auth0Provider = ({ })(); }, [client, onRedirectCallback]); + const loginWithPopup = async (options?: PopupLoginOptions): Promise => { + dispatch({ type: 'LOGIN_POPUP_STARTED' }); + try { + await client.loginWithPopup(options); + } catch (error) { + dispatch({ type: 'ERROR', error: loginError(error) }); + } + const isAuthenticated = await client.isAuthenticated(); + const user = isAuthenticated && (await client.getUser()); + dispatch({ type: 'LOGIN_POPUP_COMPLETE', isAuthenticated, user }); + }; + return ( => client.getTokenSilently(opts), + getTokenWithPopup: (opts): Promise => + client.getTokenWithPopup(opts), + getIdTokenClaims: (opts): Promise => + client.getIdTokenClaims(opts), login: (opts): Promise => client.loginWithRedirect(opts), + loginWithPopup: (opts): Promise => loginWithPopup(opts), logout: (opts): void => client.logout(opts), }} > diff --git a/src/reducer.tsx b/src/reducer.tsx index 3de269a7..a6404951 100644 --- a/src/reducer.tsx +++ b/src/reducer.tsx @@ -1,11 +1,22 @@ import { AuthState } from './auth-state'; type Action = - | { type: 'INITIALISED'; isAuthenticated: boolean; user?: unknown } + | { type: 'LOGIN_POPUP_STARTED' } + | { + type: 'INITIALISED' | 'LOGIN_POPUP_COMPLETE'; + isAuthenticated: boolean; + user?: unknown; + } | { type: 'ERROR'; error: Error }; export const reducer = (state: AuthState, action: Action): AuthState => { switch (action.type) { + case 'LOGIN_POPUP_STARTED': + return { + ...state, + isLoading: true, + }; + case 'LOGIN_POPUP_COMPLETE': case 'INITIALISED': return { ...state, diff --git a/src/utils.tsx b/src/utils.tsx index c29c1195..6b929d61 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -2,7 +2,7 @@ const CODE_RE = /[?&]code=[^&]+/; const ERROR_RE = /[?&]error=[^&]+/; export type AppState = { - redirectTo?: string; + returnTo?: string; [key: string]: unknown; }; @@ -13,7 +13,7 @@ export const defaultOnRedirectCallback = (appState?: AppState): void => { window.history.replaceState( {}, document.title, - appState?.redirectTo || window.location.pathname + appState?.returnTo || window.location.pathname ); }; diff --git a/src/with-login-required.tsx b/src/with-login-required.tsx index 40f37066..7df6d17e 100644 --- a/src/with-login-required.tsx +++ b/src/with-login-required.tsx @@ -15,7 +15,7 @@ const withLoginRequired =

( } (async (): Promise => { await login({ - appState: { redirectTo: window.location.pathname }, + appState: { returnTo: window.location.pathname }, }); })(); }, [isLoading, isAuthenticated, login]); diff --git a/static/index.html b/static/index.html index f23a4219..16860305 100644 --- a/static/index.html +++ b/static/index.html @@ -24,18 +24,48 @@