From 5fafb6e17727d0d94d85f2a491a3c936dc6ebea5 Mon Sep 17 00:00:00 2001 From: Toubat Date: Sat, 26 Apr 2025 16:34:38 -0400 Subject: [PATCH 1/7] temp checkin --- src/auth0-context.tsx | 26 ++++++++++++--- src/auth0-provider.tsx | 76 +++++++++++++++++++++++++++++------------- 2 files changed, 74 insertions(+), 28 deletions(-) diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index d2960ab9..d3aa31b6 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -1,14 +1,14 @@ import { GetTokenSilentlyOptions, + GetTokenSilentlyVerboseResponse, GetTokenWithPopupOptions, IdToken, - LogoutOptions as SPALogoutOptions, - PopupLoginOptions, PopupConfigOptions, + PopupLoginOptions, RedirectLoginResult, - User, - GetTokenSilentlyVerboseResponse, + LogoutOptions as SPALogoutOptions, RedirectLoginOptions as SPARedirectLoginOptions, + User, } from '@auth0/auth0-spa-js'; import { createContext } from 'react'; import { AuthState, initialAuthState } from './auth-state'; @@ -38,7 +38,7 @@ export interface Auth0ContextInterface * * 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`. * @@ -87,6 +87,21 @@ export interface Auth0ContextInterface */ getIdTokenClaims: () => Promise; + /** + * ```js + * setAuthCallbackUrl(url); + * ``` + * + * Manually set the auth callback URL and trigger the authentication state handling. + * + * This is useful if you do not want to reload the page with authentication search query parameters + * in order to handle the authentication state. It can be particularly useful for native applications + * (like Electron), where reloading the page requires extra steps to be taken in the main process. + * + * @param url The URL containing the authentication result parameters to process + */ + setAuthCallbackUrl: (url: string) => void; + /** * ```js * await loginWithRedirect(options); @@ -159,6 +174,7 @@ export const initialContext = { getAccessTokenSilently: stub, getAccessTokenWithPopup: stub, getIdTokenClaims: stub, + setAuthCallbackUrl: stub, loginWithRedirect: stub, loginWithPopup: stub, logout: stub, diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index 9e4e09a2..967fd873 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -1,3 +1,13 @@ +import { + Auth0Client, + Auth0ClientOptions, + GetTokenSilentlyOptions, + GetTokenWithPopupOptions, + PopupConfigOptions, + PopupLoginOptions, + RedirectLoginResult, + User, +} from '@auth0/auth0-spa-js'; import React, { useCallback, useEffect, @@ -6,29 +16,19 @@ import React, { useRef, useState, } from 'react'; -import { - Auth0Client, - Auth0ClientOptions, - PopupLoginOptions, - PopupConfigOptions, - GetTokenWithPopupOptions, - RedirectLoginResult, - GetTokenSilentlyOptions, - User, -} from '@auth0/auth0-spa-js'; +import { initialAuthState } from './auth-state'; import Auth0Context, { Auth0ContextInterface, LogoutOptions, RedirectLoginOptions, } from './auth0-context'; +import { reducer } from './reducer'; import { + deprecateRedirectUri, hasAuthParams, loginError, tokenError, - deprecateRedirectUri, } from './utils'; -import { reducer } from './reducer'; -import { initialAuthState } from './auth-state'; /** * The state of the application before the user was redirected to the login page. @@ -144,6 +144,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { () => new Auth0Client(toAuth0ClientOptions(clientOpts)) ); const [state, dispatch] = useReducer(reducer, initialAuthState); + const [authCallbackUrl, setAuthCallbackUrl] = useState(); const didInitialise = useRef(false); const handleError = useCallback((error: Error) => { @@ -151,16 +152,15 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { return error; }, []); - useEffect(() => { - if (didInitialise.current) { - return; - } - didInitialise.current = true; - (async (): Promise => { + const handleAuthCallback = useCallback( + async (authParams: string, authCallbackUrl: string) => { try { let user: User | undefined; - if (hasAuthParams() && !skipRedirectCallback) { - const { appState } = await client.handleRedirectCallback(); + + if (hasAuthParams(authParams) && !skipRedirectCallback) { + const { appState } = await client.handleRedirectCallback( + authCallbackUrl + ); user = await client.getUser(); onRedirectCallback(appState, user); } else { @@ -171,8 +171,36 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { } catch (error) { handleError(loginError(error)); } - })(); - }, [client, onRedirectCallback, skipRedirectCallback, handleError]); + }, + [client, onRedirectCallback, skipRedirectCallback, handleError] + ); + + useEffect(() => { + if (didInitialise.current) { + return; + } + didInitialise.current = true; + handleAuthCallback(window.location.search, window.location.href); + }, [ + client, + onRedirectCallback, + skipRedirectCallback, + handleError, + handleAuthCallback, + ]); + + useEffect(() => { + if (authCallbackUrl === undefined) { + return; + } + + setAuthCallbackUrl(undefined); + const idx = authCallbackUrl.indexOf('?'); + + if (idx !== -1) { + handleAuthCallback(authCallbackUrl.slice(idx), authCallbackUrl); + } + }, [authCallbackUrl, handleAuthCallback]); const loginWithRedirect = useCallback( (opts?: RedirectLoginOptions): Promise => { @@ -278,6 +306,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { getAccessTokenSilently, getAccessTokenWithPopup, getIdTokenClaims, + setAuthCallbackUrl, loginWithRedirect, loginWithPopup, logout, @@ -288,6 +317,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { getAccessTokenSilently, getAccessTokenWithPopup, getIdTokenClaims, + setAuthCallbackUrl, loginWithRedirect, loginWithPopup, logout, From 035904e448c9b515406ef35be74a8a454581adaa Mon Sep 17 00:00:00 2001 From: Toubat Date: Sat, 26 Apr 2025 16:52:47 -0400 Subject: [PATCH 2/7] add unit tests --- __tests__/auth-provider.test.tsx | 350 +++++++++++++++++-------------- src/auth0-provider.tsx | 1 + 2 files changed, 189 insertions(+), 162 deletions(-) diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx index 0ec66d3c..138f6182 100644 --- a/__tests__/auth-provider.test.tsx +++ b/__tests__/auth-provider.test.tsx @@ -3,7 +3,13 @@ import { GetTokenSilentlyVerboseResponse, } from '@auth0/auth0-spa-js'; import '@testing-library/jest-dom'; -import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { + act, + render, + renderHook, + screen, + waitFor, +} from '@testing-library/react'; import React, { StrictMode, useContext } from 'react'; import pkg from '../package.json'; import { Auth0Provider, useAuth0 } from '../src'; @@ -22,10 +28,7 @@ describe('Auth0Provider', () => { it('should provide the Auth0Provider result', async () => { const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current).toBeDefined(); }); @@ -61,7 +64,9 @@ describe('Auth0Provider', () => { }); it('should support redirectUri', async () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); const opts = { clientId: 'foo', domain: 'bar', @@ -86,7 +91,9 @@ describe('Auth0Provider', () => { }); it('should support authorizationParams.redirectUri', async () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); const opts = { clientId: 'foo', domain: 'bar', @@ -135,10 +142,7 @@ describe('Auth0Provider', () => { it('should check session when logged out', async () => { const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); expect(result.current.isLoading).toBe(true); await waitFor(() => { expect(result.current.isLoading).toBe(false); @@ -151,10 +155,7 @@ describe('Auth0Provider', () => { const user = { name: '__test_user__' }; clientMock.getUser.mockResolvedValue(user); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(clientMock.checkSession).toHaveBeenCalled(); expect(result.current.isAuthenticated).toBe(true); @@ -168,10 +169,7 @@ describe('Auth0Provider', () => { error_description: '__test_error_description__', }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(clientMock.checkSession).toHaveBeenCalled(); expect(() => { @@ -235,10 +233,7 @@ describe('Auth0Provider', () => { new Error('__test_error__') ); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(clientMock.handleRedirectCallback).toHaveBeenCalled(); expect(() => { @@ -283,10 +278,7 @@ describe('Auth0Provider', () => { const wrapper = createWrapper({ skipRedirectCallback: true, }); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(clientMock.handleRedirectCallback).not.toHaveBeenCalled(); expect(result.current.isAuthenticated).toBe(true); @@ -297,10 +289,7 @@ describe('Auth0Provider', () => { it('should login with a popup', async () => { clientMock.getUser.mockResolvedValue(undefined); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.user).toBeUndefined(); }); @@ -321,10 +310,7 @@ describe('Auth0Provider', () => { it('should handle errors when logging in with a popup', async () => { clientMock.getUser.mockResolvedValue(undefined); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.isAuthenticated).toBe(false); expect(result.current.user).toBeUndefined(); @@ -348,12 +334,8 @@ describe('Auth0Provider', () => { it('should provide a login method', async () => { const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { - expect(result.current.loginWithRedirect).toBeInstanceOf(Function); }); await result.current.loginWithRedirect({ @@ -369,12 +351,11 @@ describe('Auth0Provider', () => { }); it('should provide a login method supporting redirectUri', async () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.loginWithRedirect).toBeInstanceOf(Function); }); @@ -390,12 +371,11 @@ describe('Auth0Provider', () => { }); it('should provide a login method supporting authorizationParams.redirectUri', async () => { - const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.loginWithRedirect).toBeInstanceOf(Function); }); @@ -416,10 +396,7 @@ describe('Auth0Provider', () => { const user = { name: '__test_user__' }; clientMock.getUser.mockResolvedValue(user); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.logout).toBeInstanceOf(Function); }); @@ -438,16 +415,13 @@ describe('Auth0Provider', () => { // get logout to return a Promise to simulate async cache. clientMock.logout.mockResolvedValue(); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.isAuthenticated).toBe(true); }); await act(async () => { // eslint-disable-next-line @typescript-eslint/no-empty-function - await result.current.logout({ openUrl: async () => { } }); + await result.current.logout({ openUrl: async () => {} }); }); expect(result.current.isAuthenticated).toBe(false); }); @@ -462,10 +436,7 @@ describe('Auth0Provider', () => { logoutSpy(); }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.isAuthenticated).toBe(true); }); @@ -479,10 +450,7 @@ describe('Auth0Provider', () => { const user = { name: '__test_user__' }; clientMock.getUser.mockResolvedValue(user); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.isAuthenticated).toBe(true); expect(result.current.user).toBe(user); @@ -502,10 +470,7 @@ describe('Auth0Provider', () => { it('should provide a getAccessTokenSilently method', async () => { clientMock.getTokenSilently.mockResolvedValue('token'); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.getAccessTokenSilently).toBeInstanceOf(Function); }); @@ -525,10 +490,7 @@ describe('Auth0Provider', () => { }; (clientMock.getTokenSilently as jest.Mock).mockResolvedValue(tokenResponse); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await act(async () => { const token = await result.current.getAccessTokenSilently({ detailedResponse: true, @@ -541,10 +503,7 @@ describe('Auth0Provider', () => { it('should normalize errors from getAccessTokenSilently method', async () => { clientMock.getTokenSilently.mockRejectedValue(new ProgressEvent('error')); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await act(async () => { await expect(result.current.getAccessTokenSilently).rejects.toThrowError( 'Get access token failed' @@ -555,10 +514,7 @@ describe('Auth0Provider', () => { it('should call getAccessTokenSilently in the scope of the Auth0 client', async () => { clientMock.getTokenSilently.mockReturnThis(); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await act(async () => { const returnedThis = await result.current.getAccessTokenSilently(); @@ -570,10 +526,7 @@ describe('Auth0Provider', () => { clientMock.getTokenSilently.mockReturnThis(); clientMock.getUser.mockResolvedValue({ name: 'foo' }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(async () => { expect(result.current.user?.name).toEqual('foo'); }); @@ -588,10 +541,7 @@ describe('Auth0Provider', () => { clientMock.getTokenSilently.mockReturnThis(); clientMock.getUser.mockResolvedValue({ name: 'foo' }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.isAuthenticated).toBeTruthy(); }); @@ -611,10 +561,7 @@ describe('Auth0Provider', () => { const userObject = { name: 'foo' }; clientMock.getUser.mockResolvedValue(userObject); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(async () => { const prevUser = result.current.user; clientMock.getUser.mockResolvedValue(userObject); @@ -629,11 +576,8 @@ describe('Auth0Provider', () => { clientMock.getTokenSilently.mockReturnThis(); clientMock.getUser.mockResolvedValue({ name: 'foo' }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); - let memoized + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); + let memoized; await waitFor(async () => { memoized = result.current.getAccessTokenSilently; expect(result.current.user?.name).toEqual('foo'); @@ -662,10 +606,7 @@ describe('Auth0Provider', () => { it('should provide a getAccessTokenWithPopup method', async () => { clientMock.getTokenWithPopup.mockResolvedValue('token'); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); expect(result.current.getAccessTokenWithPopup).toBeInstanceOf(Function); await act(async () => { const token = await result.current.getAccessTokenWithPopup(); @@ -677,10 +618,7 @@ describe('Auth0Provider', () => { it('should call getAccessTokenWithPopup in the scope of the Auth0 client', async () => { clientMock.getTokenWithPopup.mockReturnThis(); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await act(async () => { const returnedThis = await result.current.getAccessTokenWithPopup(); expect(returnedThis).toStrictEqual(clientMock); @@ -691,10 +629,7 @@ describe('Auth0Provider', () => { clientMock.getTokenSilently.mockReturnThis(); clientMock.getUser.mockResolvedValue({ name: 'foo' }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); const prevUser = result.current.user; clientMock.getUser.mockResolvedValue({ name: 'foo' }); @@ -708,10 +643,7 @@ describe('Auth0Provider', () => { clientMock.getTokenSilently.mockReturnThis(); clientMock.getUser.mockResolvedValue({ name: 'foo' }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(() => { expect(result.current.isAuthenticated).toBeTruthy(); }); @@ -733,10 +665,7 @@ describe('Auth0Provider', () => { const userObject = { name: 'foo' }; clientMock.getUser.mockResolvedValue(userObject); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(async () => { const prevState = result.current; clientMock.getUser.mockResolvedValue(userObject); @@ -750,10 +679,7 @@ describe('Auth0Provider', () => { it('should normalize errors from getAccessTokenWithPopup method', async () => { clientMock.getTokenWithPopup.mockRejectedValue(new ProgressEvent('error')); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await act(async () => { await expect(result.current.getAccessTokenWithPopup).rejects.toThrowError( 'Get access token failed' @@ -780,16 +706,13 @@ describe('Auth0Provider', () => { __raw: '__test_raw_token__', }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); expect(result.current.getIdTokenClaims).toBeInstanceOf(Function); let claims; await act(async () => { claims = await result.current.getIdTokenClaims(); - }) + }); expect(clientMock.getIdTokenClaims).toHaveBeenCalled(); expect(claims).toStrictEqual({ claim: '__test_claim__', @@ -799,10 +722,9 @@ describe('Auth0Provider', () => { it('should memoize the getIdTokenClaims method', async () => { const wrapper = createWrapper(); - const { result, rerender } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result, rerender } = renderHook(() => useContext(Auth0Context), { + wrapper, + }); await waitFor(() => { const memoized = result.current.getIdTokenClaims; rerender(); @@ -815,10 +737,7 @@ describe('Auth0Provider', () => { appState: { redirectUri: '/' }, }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); expect(result.current.handleRedirectCallback).toBeInstanceOf(Function); await act(async () => { const response = await result.current.handleRedirectCallback(); @@ -834,10 +753,7 @@ describe('Auth0Provider', () => { it('should call handleRedirectCallback in the scope of the Auth0 client', async () => { clientMock.handleRedirectCallback.mockReturnThis(); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await act(async () => { const returnedThis = await result.current.handleRedirectCallback(); expect(returnedThis).toStrictEqual(clientMock); @@ -848,10 +764,7 @@ describe('Auth0Provider', () => { clientMock.handleRedirectCallback.mockReturnThis(); clientMock.getUser.mockResolvedValue({ name: 'foo' }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); const prevUser = result.current.user; clientMock.getUser.mockResolvedValue({ name: 'foo' }); @@ -865,10 +778,7 @@ describe('Auth0Provider', () => { clientMock.handleRedirectCallback.mockReturnThis(); clientMock.getUser.mockResolvedValue({ name: 'foo' }); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(async () => { expect(result.current.isAuthenticated).toBeTruthy(); }); @@ -889,10 +799,7 @@ describe('Auth0Provider', () => { const userObject = { name: 'foo' }; clientMock.getUser.mockResolvedValue(userObject); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await waitFor(async () => { const prevState = result.current; clientMock.getUser.mockResolvedValue(userObject); @@ -901,7 +808,6 @@ describe('Auth0Provider', () => { }); expect(result.current).toBe(prevState); }); - }); it('should normalize errors from handleRedirectCallback method', async () => { @@ -909,10 +815,7 @@ describe('Auth0Provider', () => { new ProgressEvent('error') ); const wrapper = createWrapper(); - const { result } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); await act(async () => { await expect(result.current.handleRedirectCallback).rejects.toThrowError( 'Get access token failed' @@ -944,10 +847,9 @@ describe('Auth0Provider', () => { clientMock.getTokenSilently.mockReturnThis(); clientMock.getUser.mockResolvedValue({ name: 'foo' }); const wrapper = createWrapper(); - const { result, rerender } = renderHook( - () => useContext(Auth0Context), - { wrapper } - ); + const { result, rerender } = renderHook(() => useContext(Auth0Context), { + wrapper, + }); await waitFor(() => { const memoized = result.current; rerender(); @@ -1037,4 +939,128 @@ describe('Auth0Provider', () => { expect(screen.queryByText('__custom_user__')).toBeInTheDocument(); expect(screen.queryByText('__main_user__')).not.toBeInTheDocument(); }); + + it('should provide a setAuthCallbackUrl method', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(Auth0Context), { wrapper }); + + await waitFor(() => { + expect(result.current.setAuthCallbackUrl).toBeInstanceOf(Function); + }); + }); + + it.each([ + 'https://example.com/callback?code=test_code&state=test_state', + 'http://localhost:3000/callback?code=test_code&state=test_state', + 'file://Application/dist/index.html?code=test_code&state=test_state', + 'chrome-extension://__test_extension_id__/callback?code=test_code&state=test_state', + 'electron-fidde://__test_fidder_id__/callback?code=test_code&state=test_state', + ])( + 'should handle authentication from setAuthCallbackUrl with query parameters', + async (testUrl) => { + const user = { name: '__test_user__' }; + clientMock.getUser.mockResolvedValue(user); + clientMock.handleRedirectCallback.mockResolvedValue({ + appState: { foo: 'bar' }, + }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(Auth0Context), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.setAuthCallbackUrl(testUrl); + }); + + await waitFor(() => { + // The handleRedirectCallback should be called with the URL + expect(clientMock.handleRedirectCallback).toHaveBeenCalledWith(testUrl); + expect(result.current.user).toBe(user); + }); + } + ); + + it.each([ + 'https://example.com/callback', + 'https://example.com/callback?code=test_code', + 'https://example.com/callback?state=test_state', + ])( + 'should not process URLs without auth query parameters in setAuthCallbackUrl', + async (testUrl) => { + // Mock implementation to track calls + const handleCallbackSpy = jest.fn(); + clientMock.handleRedirectCallback.mockImplementation(handleCallbackSpy); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(Auth0Context), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Reset any previous calls to the mock + handleCallbackSpy.mockClear(); + + act(() => { + result.current.setAuthCallbackUrl(testUrl); + }); + + // Wait a bit to ensure the effect has time to run + await new Promise((resolve) => setTimeout(resolve, 0)); + + // The handleRedirectCallback should not be called for URLs without query params + expect(handleCallbackSpy).not.toHaveBeenCalled(); + } + ); + + it('should call handleRedirectCallback once and reset authCallbackUrl after processing', async () => { + // Create a URL with auth parameters + const testUrl = + 'https://example.com/callback?code=test_code&state=test_state'; + + // Reset mocks + clientMock.handleRedirectCallback.mockClear(); + + // Create a wrapper with the Auth0Provider + const wrapper = createWrapper(); + const { result, rerender } = renderHook(() => useContext(Auth0Context), { + wrapper, + }); + + // Wait for initial loading to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Call setAuthCallbackUrl + act(() => { + result.current.setAuthCallbackUrl(testUrl); + }); + + // Verify handleRedirectCallback was called exactly once + await waitFor(() => { + expect(clientMock.handleRedirectCallback).toHaveBeenCalledTimes(1); + expect(clientMock.handleRedirectCallback).toHaveBeenCalledWith(testUrl); + }); + + // Clear the mock to check if it gets called on rerender + clientMock.handleRedirectCallback.mockClear(); + + // Force a rerender of the component + rerender(); + + // Wait a bit to ensure effects have time to run if they're going to + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Verify handleRedirectCallback is NOT called again on rerender + // This proves authCallbackUrl was reset to undefined and isn't triggering the effect again + expect(clientMock.handleRedirectCallback).not.toHaveBeenCalled(); + }); }); diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index 967fd873..fe0c0d47 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -226,6 +226,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { const user = await client.getUser(); dispatch({ type: 'LOGIN_POPUP_COMPLETE', user }); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [client] ); From 801079da6c07c44ed32d25fee2dd6735b08b6e73 Mon Sep 17 00:00:00 2001 From: Toubat Date: Sat, 26 Apr 2025 17:04:59 -0400 Subject: [PATCH 3/7] Update auth-provider.test.tsx --- __tests__/auth-provider.test.tsx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx index 138f6182..ffd3f4f4 100644 --- a/__tests__/auth-provider.test.tsx +++ b/__tests__/auth-provider.test.tsx @@ -990,7 +990,7 @@ describe('Auth0Provider', () => { 'https://example.com/callback?code=test_code', 'https://example.com/callback?state=test_state', ])( - 'should not process URLs without auth query parameters in setAuthCallbackUrl', + 'should not process URLs without valid auth query parameters in setAuthCallbackUrl', async (testUrl) => { // Mock implementation to track calls const handleCallbackSpy = jest.fn(); @@ -1013,10 +1013,9 @@ describe('Auth0Provider', () => { }); // Wait a bit to ensure the effect has time to run - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The handleRedirectCallback should not be called for URLs without query params - expect(handleCallbackSpy).not.toHaveBeenCalled(); + await waitFor(() => { + expect(handleCallbackSpy).not.toHaveBeenCalled(); + }); } ); @@ -1056,11 +1055,8 @@ describe('Auth0Provider', () => { // Force a rerender of the component rerender(); - // Wait a bit to ensure effects have time to run if they're going to - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Verify handleRedirectCallback is NOT called again on rerender - // This proves authCallbackUrl was reset to undefined and isn't triggering the effect again - expect(clientMock.handleRedirectCallback).not.toHaveBeenCalled(); + await waitFor(() => { + expect(clientMock.handleRedirectCallback).not.toHaveBeenCalled(); + }); }); }); From 0c5f40a63ef5b3bf6358419ea1f2b5bb1add60d7 Mon Sep 17 00:00:00 2001 From: Toubat Date: Sat, 26 Apr 2025 17:07:59 -0400 Subject: [PATCH 4/7] make jest happy again --- __tests__/utils.test.tsx | 2 +- src/auth0-provider.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/__tests__/utils.test.tsx b/__tests__/utils.test.tsx index 033769ca..50912c0b 100644 --- a/__tests__/utils.test.tsx +++ b/__tests__/utils.test.tsx @@ -1,5 +1,5 @@ -import { hasAuthParams, loginError, tokenError } from '../src/utils'; import { OAuthError } from '../src/errors'; +import { hasAuthParams, loginError, tokenError } from '../src/utils'; describe('utils hasAuthParams', () => { it('should not recognise only the code param', async () => { diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index fe0c0d47..1ce6afdd 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -153,7 +153,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { }, []); const handleAuthCallback = useCallback( - async (authParams: string, authCallbackUrl: string) => { + async (authParams?: string, authCallbackUrl?: string) => { try { let user: User | undefined; @@ -180,7 +180,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { return; } didInitialise.current = true; - handleAuthCallback(window.location.search, window.location.href); + handleAuthCallback(); }, [ client, onRedirectCallback, From 7b13c61ccc2172d4878db49e8b2934e286f9486a Mon Sep 17 00:00:00 2001 From: Toubat Date: Sat, 26 Apr 2025 17:21:54 -0400 Subject: [PATCH 5/7] Update use-auth0.tsx --- src/use-auth0.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/use-auth0.tsx b/src/use-auth0.tsx index 8bc03eae..84d9df32 100644 --- a/src/use-auth0.tsx +++ b/src/use-auth0.tsx @@ -1,5 +1,5 @@ -import { useContext } from 'react'; import { User } from '@auth0/auth0-spa-js'; +import { useContext } from 'react'; import Auth0Context, { Auth0ContextInterface } from './auth0-context'; /** @@ -14,6 +14,7 @@ import Auth0Context, { Auth0ContextInterface } from './auth0-context'; * getAccessTokenSilently, * getAccessTokenWithPopup, * getIdTokenClaims, + * setAuthCallbackUrl, * loginWithRedirect, * loginWithPopup, * logout, From 5ce1e712543ebd625b42a719f54e461ac3ffe3b8 Mon Sep 17 00:00:00 2001 From: Toubat Date: Sat, 26 Apr 2025 17:27:40 -0400 Subject: [PATCH 6/7] Update FAQ.md --- FAQ.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/FAQ.md b/FAQ.md index 6db02a8e..fbc3f00c 100644 --- a/FAQ.md +++ b/FAQ.md @@ -4,6 +4,7 @@ 1. [User is not logged in after page refresh](#1-user-is-not-logged-in-after-page-refresh) 2. [User is not logged in after successful sign in with redirect](#2-user-is-not-logged-in-after-successful-sign-in-with-redirect) +3. [How to handle authentication in Electron or other native applications using react](#3-how-to-handle-authentication-in-electron-or-other-native-applications) ## 1. User is not logged in after page refresh @@ -20,3 +21,38 @@ In this case Silent Authentication will not work because it relies on a hidden i ## 2. User is not logged in after successful sign in with redirect If after successfully logging in, your user returns to your SPA and is still not authenticated, do _not_ refresh the page - go to the Network tab on Chrome and confirm that the POST to `oauth/token` resulted in an error `401 Unauthorized`. If this is the case, your tenant is most likely misconfigured. Go to your **Application Properties** in your application's settings in the [Auth0 Dashboard](https://manage.auth0.com) and make sure that `Application Type` is set to `Single Page Application` and `Token Endpoint Authentication Method` is set to `None` (**Note:** there is a known issue with the Auth0 "Default App", if you are unable to set `Token Endpoint Authentication Method` to `None`, create a new Application of type `Single Page Application` or see the advice in [issues/93](https://github.com/auth0/auth0-react/issues/93#issuecomment-673431605)) + +## 3. How to handle authentication in Electron or other native applications + +When using Auth0 React in Electron or other native/desktop applications, handling authentication redirects can be challenging because: + +1. The default redirect-based authentication flow requires page reloads, which may need special handling in native contexts +2. Native apps often use custom URL schemes (like `electron://`, `file://`) for deep linking + +The `setAuthCallbackUrl` method provides a solution for these scenarios by allowing you to manually set the authentication callback URL with auth parameters. + +**Example usage in Electron:** + +```javascript +import { useAuth0 } from '@auth0/auth0-react'; + +function App() { + const { loginWithRedirect, setAuthCallbackUrl } = useAuth0(); + + // Set up a custom protocol handler in your Electron app + // When your app receives a deep link with auth params, call: + const handleAuthCallback = (url) => { + setAuthCallbackUrl(url); + }; + + return ; +} +``` + +You would need to configure your Electron app to: + +1. Register a custom URL protocol (e.g., `your-app://`) +2. Set your Auth0 application's callback URL to include this protocol in the **Allowed Callback URLs** section of the **Settings** tab of your Auth0 application. +3. Intercept the callback URL in your main process and pass it to your renderer where you can call `setAuthCallbackUrl` + +This approach prevents having to reload the entire application when handling authentication callbacks and works well with custom URL schemes that native applications typically use. From deb438046ad2c4b81eeed9c7c020584889cf20f8 Mon Sep 17 00:00:00 2001 From: Toubat Date: Sat, 26 Apr 2025 17:28:50 -0400 Subject: [PATCH 7/7] Update FAQ.md --- FAQ.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FAQ.md b/FAQ.md index fbc3f00c..76d91808 100644 --- a/FAQ.md +++ b/FAQ.md @@ -33,7 +33,7 @@ The `setAuthCallbackUrl` method provides a solution for these scenarios by allow **Example usage in Electron:** -```javascript +```tsx import { useAuth0 } from '@auth0/auth0-react'; function App() {