From f889afe94ab3894dd3d32f99866fae593468d6be Mon Sep 17 00:00:00 2001 From: Milagros Lucia Ayala Date: Wed, 6 Aug 2025 23:52:01 -0300 Subject: [PATCH] feat: modify withAuthenticationRequired to validate authorization and keep context of aud and scope --- __mocks__/@auth0/auth0-spa-js.tsx | 2 + __tests__/auth-provider.test.tsx | 121 +++++++++++++++++++++++ __tests__/use-auth.test.tsx | 141 +++++++++++++++++++++++++-- src/auth0-context.tsx | 15 ++- src/auth0-provider.tsx | 15 +++ src/default-error.tsx | 45 +++++++++ src/use-auth0.tsx | 53 +++++++++- src/with-authentication-required.tsx | 105 ++++++++++++++++++-- 8 files changed, 476 insertions(+), 21 deletions(-) create mode 100644 src/default-error.tsx diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index b19548ad..ca73e11a 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -7,6 +7,7 @@ const getTokenWithPopup = jest.fn(); const getUser = jest.fn(); const getIdTokenClaims = jest.fn(); const isAuthenticated = jest.fn(() => false); +const isAuthorized = jest.fn(); const loginWithPopup = jest.fn(); const loginWithRedirect = jest.fn(); const logout = jest.fn(); @@ -22,6 +23,7 @@ export const Auth0Client = jest.fn(() => { getUser, getIdTokenClaims, isAuthenticated, + isAuthorized, loginWithPopup, loginWithRedirect, logout, diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx index 0ec66d3c..2ca7c794 100644 --- a/__tests__/auth-provider.test.tsx +++ b/__tests__/auth-provider.test.tsx @@ -810,6 +810,127 @@ describe('Auth0Provider', () => { }); }); + it('should provide a isAuthorized method', async () => { + clientMock.isAuthorized.mockResolvedValue(true); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + + expect(result.current.isAuthorized).toBeInstanceOf(Function); + let isAuthorized; + await act(async () => { + if (result.current.isAuthorized) { + isAuthorized = await result.current.isAuthorized({ + audience: 'test-audience', + scope: 'read:data', + }); + } + }); + expect(clientMock.isAuthorized).toHaveBeenCalledWith({ + audience: 'test-audience', + scope: 'read:data', + }); + expect(isAuthorized).toBe(true); + }); + + it('should return false from isAuthorized when not authorized', async () => { + clientMock.isAuthorized.mockResolvedValue(false); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + + let isAuthorized; + await act(async () => { + if (result.current.isAuthorized) { + isAuthorized = await result.current.isAuthorized({ + audience: 'test-audience', + scope: 'admin:write', + }); + } + }); + expect(clientMock.isAuthorized).toHaveBeenCalledWith({ + audience: 'test-audience', + scope: 'admin:write', + }); + expect(isAuthorized).toBe(false); + }); + + it('should handle errors from isAuthorized method', async () => { + clientMock.isAuthorized.mockRejectedValue(new Error('__test_error__')); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + + await act(async () => { + if (result.current.isAuthorized) { + await expect(result.current.isAuthorized({ + audience: 'test-audience', + scope: 'read:data', + })).rejects.toThrowError('__test_error__'); + } + }); + expect(clientMock.isAuthorized).toHaveBeenCalledWith({ + audience: 'test-audience', + scope: 'read:data', + }); + }); + + it('should normalize errors from isAuthorized method', async () => { + clientMock.isAuthorized.mockRejectedValue(new ProgressEvent('error')); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + + await act(async () => { + if (result.current.isAuthorized) { + await expect(result.current.isAuthorized({ + audience: 'test-audience', + scope: 'read:data', + })).rejects.toThrowError('Get access token failed'); + } + }); + }); + + it('should call isAuthorized in the scope of the Auth0 client', async () => { + clientMock.isAuthorized.mockReturnThis(); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + + await act(async () => { + if (result.current.isAuthorized) { + const returnedThis = await result.current.isAuthorized({ + audience: 'test-audience', + scope: 'read:data', + }); + expect(returnedThis).toStrictEqual(clientMock); + } + }); + }); + + it('should memoize the isAuthorized method', async () => { + const wrapper = createWrapper(); + const { result, rerender } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitFor(() => { + const memoized = result.current.isAuthorized; + rerender(); + expect(result.current.isAuthorized).toBe(memoized); + }); + }); + it('should provide a handleRedirectCallback method', async () => { clientMock.handleRedirectCallback.mockResolvedValue({ appState: { redirectUri: '/' }, diff --git a/__tests__/use-auth.test.tsx b/__tests__/use-auth.test.tsx index b188757a..dc918824 100644 --- a/__tests__/use-auth.test.tsx +++ b/__tests__/use-auth.test.tsx @@ -1,14 +1,46 @@ +import { Auth0Client } from '@auth0/auth0-spa-js'; import { act, renderHook, waitFor } from '@testing-library/react'; import React from 'react'; import { Auth0ContextInterface, initialContext } from '../src/auth0-context'; import useAuth0 from '../src/use-auth0'; import { createWrapper } from './helpers'; +const mockClient = jest.mocked(new Auth0Client({ clientId: '', domain: '' })); + describe('useAuth0', () => { + let wrapper: ReturnType; + + const TEST_AUDIENCE = 'test-audience'; + const TEST_SCOPE = 'read:data'; + const TEST_USER = { name: '__test_user__' }; + const AUDIENCE_1 = 'audience1'; + const SCOPE_1 = 'scope1'; + const AUDIENCE_2 = 'audience2'; + const SCOPE_2 = 'scope2'; + + beforeEach(() => { + jest.clearAllMocks(); + + mockClient.getUser.mockResolvedValue(TEST_USER); + mockClient.isAuthenticated.mockResolvedValue(true); + mockClient.isAuthorized.mockResolvedValue(true); + + wrapper = createWrapper(); + }); + + const expectAuthenticatedState = async ( + result: { current: Auth0ContextInterface }, + isAuthenticated = true + ) => { + await waitFor(() => { + expect(result.current.isAuthenticated).toBe(isAuthenticated); + expect(result.current.isLoading).toBe(false); + }); + }; + it('should provide the auth context', async () => { - const wrapper = createWrapper(); const { - result: { current } + result: { current }, } = renderHook(() => useAuth0(), { wrapper }); await waitFor(() => { expect(current).toBeDefined(); @@ -26,10 +58,10 @@ describe('useAuth0', () => { it('should throw when context is not associated with provider', async () => { const context = React.createContext(initialContext); - const wrapper = createWrapper({ context }); + const customWrapper = createWrapper({ context }); const { result: { current }, - } = renderHook(() => useAuth0(), { wrapper }); + } = renderHook(() => useAuth0(), { wrapper: customWrapper }); await act(async () => { expect(current.loginWithRedirect).toThrowError( 'You forgot to wrap your component in .' @@ -39,13 +71,110 @@ describe('useAuth0', () => { it('should accept custom auth context', async () => { const context = React.createContext(initialContext); - const wrapper = createWrapper({ context }); + const customWrapper = createWrapper({ context }); const { result: { current }, - } = renderHook(() => useAuth0(context), { wrapper }); + } = renderHook(() => useAuth0(context), { wrapper: customWrapper }); await waitFor(() => { expect(current).toBeDefined(); expect(current.loginWithRedirect).not.toThrowError(); }); }); + + it('should handle audience and scope options', async () => { + const { result } = renderHook( + () => useAuth0(undefined, { audience: TEST_AUDIENCE, scope: TEST_SCOPE }), + { wrapper } + ); + + await expectAuthenticatedState(result); + + expect(mockClient.isAuthorized).toHaveBeenCalledWith({ + audience: TEST_AUDIENCE, + scope: TEST_SCOPE, + }); + }); + + it('should set isAuthenticated to false when isAuthorized returns false', async () => { + mockClient.isAuthorized.mockResolvedValue(false); + + const { result } = renderHook( + () => useAuth0(undefined, { audience: TEST_AUDIENCE, scope: TEST_SCOPE }), + { wrapper } + ); + + await expectAuthenticatedState(result, false); + + expect(mockClient.isAuthorized).toHaveBeenCalledWith({ + audience: TEST_AUDIENCE, + scope: TEST_SCOPE, + }); + }); + + it('should not call isAuthorized when user is not authenticated', async () => { + mockClient.getUser.mockResolvedValue(undefined); + mockClient.isAuthenticated.mockResolvedValue(false); + + const { result } = renderHook( + () => useAuth0(undefined, { audience: TEST_AUDIENCE, scope: TEST_SCOPE }), + { wrapper } + ); + + await expectAuthenticatedState(result, false); + + expect(mockClient.isAuthorized).not.toHaveBeenCalled(); + }); + + it('should not call isAuthorized when no audience or scope provided', async () => { + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await expectAuthenticatedState(result); + + expect(mockClient.isAuthorized).not.toHaveBeenCalled(); + }); + + it('should show loading state during auth check', async () => { + mockClient.isAuthorized.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(true), 100)) + ); + + const { result } = renderHook( + () => useAuth0(undefined, { audience: TEST_AUDIENCE }), + { wrapper } + ); + + expect(result.current.isLoading).toBe(true); + + await expectAuthenticatedState(result); + }); + + it('should re-check authorization when dependencies change', async () => { + const { result, rerender } = renderHook( + ({ audience, scope }) => useAuth0(undefined, { audience, scope }), + { + wrapper, + initialProps: { audience: AUDIENCE_1, scope: SCOPE_1 }, + } + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockClient.isAuthorized).toHaveBeenCalledWith({ + audience: AUDIENCE_1, + scope: SCOPE_1, + }); + + rerender({ audience: AUDIENCE_2, scope: SCOPE_2 }); + + await waitFor(() => { + expect(mockClient.isAuthorized).toHaveBeenCalledWith({ + audience: AUDIENCE_2, + scope: SCOPE_2, + }); + }); + + expect(mockClient.isAuthorized).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index d2960ab9..bd9807bd 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -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`. * @@ -140,8 +140,18 @@ export interface Auth0ContextInterface * @param url The URL to that should be used to retrieve the `state` and `code` values. Defaults to `window.location.href` if not given. */ handleRedirectCallback: (url?: string) => Promise; -} + /** + * Validates if the current token contains the required scopes and audience. + * + * @param options Options for scope and audience validation. + * @returns `true` if the token contains the required scopes and audience, otherwise `false`. + */ + isAuthorized?: (options: { + audience?: string; + scope?: string; + }) => Promise; +} /** * @ignore */ @@ -163,6 +173,7 @@ export const initialContext = { loginWithPopup: stub, logout: stub, handleRedirectCallback: stub, + isAuthorized: stub, }; /** diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index 9e4e09a2..8ee73fbb 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -14,6 +14,7 @@ import { GetTokenWithPopupOptions, RedirectLoginResult, GetTokenSilentlyOptions, + AuthorizationParams, User, } from '@auth0/auth0-spa-js'; import Auth0Context, { @@ -256,6 +257,18 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { [client] ); + const isAuthorized = useCallback( + async (options: AuthorizationParams): Promise => { + try { + return await client.isAuthorized(options); + } catch (error) { + handleError(tokenError(error)); + return false; + } + }, + [client, handleError] + ); + const handleRedirectCallback = useCallback( async (url?: string): Promise => { try { @@ -282,6 +295,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { loginWithPopup, logout, handleRedirectCallback, + isAuthorized, }; }, [ state, @@ -292,6 +306,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => { loginWithPopup, logout, handleRedirectCallback, + isAuthorized, ]); return {children}; diff --git a/src/default-error.tsx b/src/default-error.tsx new file mode 100644 index 00000000..ccc3608c --- /dev/null +++ b/src/default-error.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +const styles = { + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100vh', + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + backgroundColor: '#f8f8f8', + color: '#333', + textAlign: 'center' as const, + padding: '20px', + }, + content: { + padding: '40px', + borderRadius: '8px', + backgroundColor: '#fff', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', + }, + title: { + fontSize: '24px', + fontWeight: 600, + margin: '0 0 10px 0', + }, + message: { + fontSize: '16px', + margin: '0', + }, +}; + +/** + * @ignore + */ +export const DefaultErrorComponent = (): React.JSX.Element => ( +
+
+

Access Denied

+

+ You do not have the required permissions to access this page. +

+
+
+); diff --git a/src/use-auth0.tsx b/src/use-auth0.tsx index 8bc03eae..4d11b7b4 100644 --- a/src/use-auth0.tsx +++ b/src/use-auth0.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { User } from '@auth0/auth0-spa-js'; import Auth0Context, { Auth0ContextInterface } from './auth0-context'; @@ -17,6 +17,7 @@ import Auth0Context, { Auth0ContextInterface } from './auth0-context'; * loginWithRedirect, * loginWithPopup, * logout, + * isAuthorized, * } = useAuth0(); * ``` * @@ -25,8 +26,52 @@ import Auth0Context, { Auth0ContextInterface } from './auth0-context'; * TUser is an optional type param to provide a type to the `user` field. */ const useAuth0 = ( - context = Auth0Context -): Auth0ContextInterface => - useContext(context) as Auth0ContextInterface; + context = Auth0Context, + options: { audience?: string; scope?: string } = {} +): Auth0ContextInterface => { + const auth0Context = useContext(context) as Auth0ContextInterface; + const [isAuthenticated, setIsAuthenticated] = useState( + auth0Context.isAuthenticated + ); + const [isCheckingAuth, setIsCheckingAuth] = useState(true); + const { audience, scope } = options; + + useEffect(() => { + const checkAuth = async () => { + setIsCheckingAuth(true); + + if (!auth0Context.isAuthenticated) { + setIsAuthenticated(false); + setIsCheckingAuth(false); + return; + } + + if (auth0Context.isAuthorized && (scope || audience)) { + const hasRequiredScopes = await auth0Context.isAuthorized({ + audience, + scope, + }); + setIsAuthenticated(hasRequiredScopes); + } else { + setIsAuthenticated(auth0Context.isAuthenticated); + } + + setIsCheckingAuth(false); + }; + + checkAuth(); + }, [ + auth0Context.isAuthenticated, + auth0Context.isAuthorized, + audience, + scope, + ]); + + return { + ...auth0Context, + isAuthenticated, + isLoading: auth0Context.isLoading || isCheckingAuth, + }; +}; export default useAuth0; diff --git a/src/with-authentication-required.tsx b/src/with-authentication-required.tsx index a6db68b7..4d6bab43 100644 --- a/src/with-authentication-required.tsx +++ b/src/with-authentication-required.tsx @@ -1,9 +1,47 @@ -import React, { ComponentType, useEffect, FC } from 'react'; +import React, { + ComponentType, + useContext, + useEffect, + FC, + useMemo, +} from 'react'; import useAuth0 from './use-auth0'; import Auth0Context, { Auth0ContextInterface, RedirectLoginOptions, } from './auth0-context'; +import { DefaultErrorComponent } from './default-error'; + +/** + * Creates a wrapped Auth0 context that automatically includes audience and scope + * in getAccessTokenSilently calls when they're not explicitly provided + */ +const createWrappedAuth0Context = ( + originalContext: Auth0ContextInterface, + audience?: string, + scope?: string +): Auth0ContextInterface => ({ + ...originalContext, + getAccessTokenSilently: ((options: any = {}) => { + const mergedOptions = { + ...options, + }; + + if (!mergedOptions.authorizationParams) { + mergedOptions.authorizationParams = {}; + } + + if (audience && !mergedOptions.authorizationParams.audience) { + mergedOptions.authorizationParams.audience = audience; + } + + if (scope && !mergedOptions.authorizationParams.scope) { + mergedOptions.authorizationParams.scope = scope; + } + + return originalContext.getAccessTokenSilently(mergedOptions); + }) as typeof originalContext.getAccessTokenSilently, +}); /** * @ignore @@ -11,9 +49,11 @@ import Auth0Context, { const defaultOnRedirecting = (): React.JSX.Element => <>; /** -* @ignore -*/ -const defaultOnBeforeAuthentication = async (): Promise => {/* noop */ }; + * @ignore + */ +const defaultOnBeforeAuthentication = async (): Promise => { + /* noop */ +}; /** * @ignore @@ -63,6 +103,19 @@ export interface WithAuthenticationRequiredOptions { * Allows executing logic before the user is redirected to the login page. */ onBeforeAuthentication?: () => Promise; + /** + * A function that returns a component to display in the event of an authorization error. + * An authorization error occurs when the user is authenticated but does not have the required permissions to view the component. + * If not provided, a default "Access Denied" page will be shown. + * + * ```js + * withAuthenticationRequired(Admin, { + * scope: 'read:admin-messages', + * onError: () =>

You don't have permission to view this page.

+ * }) + * ``` + */ + onError?: () => React.JSX.Element; /** * ```js * withAuthenticationRequired(Profile, { @@ -105,13 +158,25 @@ const withAuthenticationRequired =

( onBeforeAuthentication = defaultOnBeforeAuthentication, loginOptions, context = Auth0Context, + onError, } = options; - const { isAuthenticated, isLoading, loginWithRedirect } = - useAuth0(context); + const audience = loginOptions?.authorizationParams?.audience; + const scope = loginOptions?.authorizationParams?.scope; + + const { isAuthenticated, isLoading, loginWithRedirect } = useAuth0( + context, + { audience, scope } + ); + const globalContext = useContext(context); + + const wrappedContextValue = useMemo( + () => createWrappedAuth0Context(globalContext, audience, scope), + [globalContext, audience, scope] + ); useEffect(() => { - if (isLoading || isAuthenticated) { + if (isLoading || globalContext.isAuthenticated) { return; } const opts = { @@ -127,14 +192,36 @@ const withAuthenticationRequired =

( })(); }, [ isLoading, - isAuthenticated, + globalContext.isAuthenticated, loginWithRedirect, onBeforeAuthentication, loginOptions, returnTo, ]); - return isAuthenticated ? : onRedirecting(); + if (isLoading) { + return onRedirecting(); + } + + // If the user is authenticated and has the required permissions, render the component. + if (isAuthenticated) { + return ( + + + + ); + } + + // If the user is authenticated but NOT authorized, show the error UI. + if (globalContext.isAuthenticated) { + if (onError) { + return onError(); + } + return ; + } + + // Otherwise, the user is not authenticated and is being redirected. + return onRedirecting(); }; };