diff --git a/FAQ.md b/FAQ.md index 6db02a8e..76d91808 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:** + +```tsx +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. diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx index 0ec66d3c..ffd3f4f4 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,124 @@ 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 valid 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 waitFor(() => { + 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(); + + await waitFor(() => { + expect(clientMock.handleRedirectCallback).not.toHaveBeenCalled(); + }); + }); }); 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-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..1ce6afdd 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(); + }, [ + 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 => { @@ -198,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] ); @@ -278,6 +307,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { getAccessTokenSilently, getAccessTokenWithPopup, getIdTokenClaims, + setAuthCallbackUrl, loginWithRedirect, loginWithPopup, logout, @@ -288,6 +318,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { getAccessTokenSilently, getAccessTokenWithPopup, getIdTokenClaims, + setAuthCallbackUrl, loginWithRedirect, loginWithPopup, logout, 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,