Skip to content

Commit f889afe

Browse files
committed
feat: modify withAuthenticationRequired to validate authorization and keep context of aud and scope
1 parent 1644bb5 commit f889afe

File tree

8 files changed

+476
-21
lines changed

8 files changed

+476
-21
lines changed

__mocks__/@auth0/auth0-spa-js.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const getTokenWithPopup = jest.fn();
77
const getUser = jest.fn();
88
const getIdTokenClaims = jest.fn();
99
const isAuthenticated = jest.fn(() => false);
10+
const isAuthorized = jest.fn();
1011
const loginWithPopup = jest.fn();
1112
const loginWithRedirect = jest.fn();
1213
const logout = jest.fn();
@@ -22,6 +23,7 @@ export const Auth0Client = jest.fn(() => {
2223
getUser,
2324
getIdTokenClaims,
2425
isAuthenticated,
26+
isAuthorized,
2527
loginWithPopup,
2628
loginWithRedirect,
2729
logout,

__tests__/auth-provider.test.tsx

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,127 @@ describe('Auth0Provider', () => {
810810
});
811811
});
812812

813+
it('should provide a isAuthorized method', async () => {
814+
clientMock.isAuthorized.mockResolvedValue(true);
815+
const wrapper = createWrapper();
816+
const { result } = renderHook(
817+
() => useContext(Auth0Context),
818+
{ wrapper }
819+
);
820+
821+
expect(result.current.isAuthorized).toBeInstanceOf(Function);
822+
let isAuthorized;
823+
await act(async () => {
824+
if (result.current.isAuthorized) {
825+
isAuthorized = await result.current.isAuthorized({
826+
audience: 'test-audience',
827+
scope: 'read:data',
828+
});
829+
}
830+
});
831+
expect(clientMock.isAuthorized).toHaveBeenCalledWith({
832+
audience: 'test-audience',
833+
scope: 'read:data',
834+
});
835+
expect(isAuthorized).toBe(true);
836+
});
837+
838+
it('should return false from isAuthorized when not authorized', async () => {
839+
clientMock.isAuthorized.mockResolvedValue(false);
840+
const wrapper = createWrapper();
841+
const { result } = renderHook(
842+
() => useContext(Auth0Context),
843+
{ wrapper }
844+
);
845+
846+
let isAuthorized;
847+
await act(async () => {
848+
if (result.current.isAuthorized) {
849+
isAuthorized = await result.current.isAuthorized({
850+
audience: 'test-audience',
851+
scope: 'admin:write',
852+
});
853+
}
854+
});
855+
expect(clientMock.isAuthorized).toHaveBeenCalledWith({
856+
audience: 'test-audience',
857+
scope: 'admin:write',
858+
});
859+
expect(isAuthorized).toBe(false);
860+
});
861+
862+
it('should handle errors from isAuthorized method', async () => {
863+
clientMock.isAuthorized.mockRejectedValue(new Error('__test_error__'));
864+
const wrapper = createWrapper();
865+
const { result } = renderHook(
866+
() => useContext(Auth0Context),
867+
{ wrapper }
868+
);
869+
870+
await act(async () => {
871+
if (result.current.isAuthorized) {
872+
await expect(result.current.isAuthorized({
873+
audience: 'test-audience',
874+
scope: 'read:data',
875+
})).rejects.toThrowError('__test_error__');
876+
}
877+
});
878+
expect(clientMock.isAuthorized).toHaveBeenCalledWith({
879+
audience: 'test-audience',
880+
scope: 'read:data',
881+
});
882+
});
883+
884+
it('should normalize errors from isAuthorized method', async () => {
885+
clientMock.isAuthorized.mockRejectedValue(new ProgressEvent('error'));
886+
const wrapper = createWrapper();
887+
const { result } = renderHook(
888+
() => useContext(Auth0Context),
889+
{ wrapper }
890+
);
891+
892+
await act(async () => {
893+
if (result.current.isAuthorized) {
894+
await expect(result.current.isAuthorized({
895+
audience: 'test-audience',
896+
scope: 'read:data',
897+
})).rejects.toThrowError('Get access token failed');
898+
}
899+
});
900+
});
901+
902+
it('should call isAuthorized in the scope of the Auth0 client', async () => {
903+
clientMock.isAuthorized.mockReturnThis();
904+
const wrapper = createWrapper();
905+
const { result } = renderHook(
906+
() => useContext(Auth0Context),
907+
{ wrapper }
908+
);
909+
910+
await act(async () => {
911+
if (result.current.isAuthorized) {
912+
const returnedThis = await result.current.isAuthorized({
913+
audience: 'test-audience',
914+
scope: 'read:data',
915+
});
916+
expect(returnedThis).toStrictEqual(clientMock);
917+
}
918+
});
919+
});
920+
921+
it('should memoize the isAuthorized method', async () => {
922+
const wrapper = createWrapper();
923+
const { result, rerender } = renderHook(
924+
() => useContext(Auth0Context),
925+
{ wrapper }
926+
);
927+
await waitFor(() => {
928+
const memoized = result.current.isAuthorized;
929+
rerender();
930+
expect(result.current.isAuthorized).toBe(memoized);
931+
});
932+
});
933+
813934
it('should provide a handleRedirectCallback method', async () => {
814935
clientMock.handleRedirectCallback.mockResolvedValue({
815936
appState: { redirectUri: '/' },

__tests__/use-auth.test.tsx

Lines changed: 135 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,46 @@
1+
import { Auth0Client } from '@auth0/auth0-spa-js';
12
import { act, renderHook, waitFor } from '@testing-library/react';
23
import React from 'react';
34
import { Auth0ContextInterface, initialContext } from '../src/auth0-context';
45
import useAuth0 from '../src/use-auth0';
56
import { createWrapper } from './helpers';
67

8+
const mockClient = jest.mocked(new Auth0Client({ clientId: '', domain: '' }));
9+
710
describe('useAuth0', () => {
11+
let wrapper: ReturnType<typeof createWrapper>;
12+
13+
const TEST_AUDIENCE = 'test-audience';
14+
const TEST_SCOPE = 'read:data';
15+
const TEST_USER = { name: '__test_user__' };
16+
const AUDIENCE_1 = 'audience1';
17+
const SCOPE_1 = 'scope1';
18+
const AUDIENCE_2 = 'audience2';
19+
const SCOPE_2 = 'scope2';
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
24+
mockClient.getUser.mockResolvedValue(TEST_USER);
25+
mockClient.isAuthenticated.mockResolvedValue(true);
26+
mockClient.isAuthorized.mockResolvedValue(true);
27+
28+
wrapper = createWrapper();
29+
});
30+
31+
const expectAuthenticatedState = async (
32+
result: { current: Auth0ContextInterface },
33+
isAuthenticated = true
34+
) => {
35+
await waitFor(() => {
36+
expect(result.current.isAuthenticated).toBe(isAuthenticated);
37+
expect(result.current.isLoading).toBe(false);
38+
});
39+
};
40+
841
it('should provide the auth context', async () => {
9-
const wrapper = createWrapper();
1042
const {
11-
result: { current }
43+
result: { current },
1244
} = renderHook(() => useAuth0(), { wrapper });
1345
await waitFor(() => {
1446
expect(current).toBeDefined();
@@ -26,10 +58,10 @@ describe('useAuth0', () => {
2658

2759
it('should throw when context is not associated with provider', async () => {
2860
const context = React.createContext<Auth0ContextInterface>(initialContext);
29-
const wrapper = createWrapper({ context });
61+
const customWrapper = createWrapper({ context });
3062
const {
3163
result: { current },
32-
} = renderHook(() => useAuth0(), { wrapper });
64+
} = renderHook(() => useAuth0(), { wrapper: customWrapper });
3365
await act(async () => {
3466
expect(current.loginWithRedirect).toThrowError(
3567
'You forgot to wrap your component in <Auth0Provider>.'
@@ -39,13 +71,110 @@ describe('useAuth0', () => {
3971

4072
it('should accept custom auth context', async () => {
4173
const context = React.createContext<Auth0ContextInterface>(initialContext);
42-
const wrapper = createWrapper({ context });
74+
const customWrapper = createWrapper({ context });
4375
const {
4476
result: { current },
45-
} = renderHook(() => useAuth0(context), { wrapper });
77+
} = renderHook(() => useAuth0(context), { wrapper: customWrapper });
4678
await waitFor(() => {
4779
expect(current).toBeDefined();
4880
expect(current.loginWithRedirect).not.toThrowError();
4981
});
5082
});
83+
84+
it('should handle audience and scope options', async () => {
85+
const { result } = renderHook(
86+
() => useAuth0(undefined, { audience: TEST_AUDIENCE, scope: TEST_SCOPE }),
87+
{ wrapper }
88+
);
89+
90+
await expectAuthenticatedState(result);
91+
92+
expect(mockClient.isAuthorized).toHaveBeenCalledWith({
93+
audience: TEST_AUDIENCE,
94+
scope: TEST_SCOPE,
95+
});
96+
});
97+
98+
it('should set isAuthenticated to false when isAuthorized returns false', async () => {
99+
mockClient.isAuthorized.mockResolvedValue(false);
100+
101+
const { result } = renderHook(
102+
() => useAuth0(undefined, { audience: TEST_AUDIENCE, scope: TEST_SCOPE }),
103+
{ wrapper }
104+
);
105+
106+
await expectAuthenticatedState(result, false);
107+
108+
expect(mockClient.isAuthorized).toHaveBeenCalledWith({
109+
audience: TEST_AUDIENCE,
110+
scope: TEST_SCOPE,
111+
});
112+
});
113+
114+
it('should not call isAuthorized when user is not authenticated', async () => {
115+
mockClient.getUser.mockResolvedValue(undefined);
116+
mockClient.isAuthenticated.mockResolvedValue(false);
117+
118+
const { result } = renderHook(
119+
() => useAuth0(undefined, { audience: TEST_AUDIENCE, scope: TEST_SCOPE }),
120+
{ wrapper }
121+
);
122+
123+
await expectAuthenticatedState(result, false);
124+
125+
expect(mockClient.isAuthorized).not.toHaveBeenCalled();
126+
});
127+
128+
it('should not call isAuthorized when no audience or scope provided', async () => {
129+
const { result } = renderHook(() => useAuth0(), { wrapper });
130+
131+
await expectAuthenticatedState(result);
132+
133+
expect(mockClient.isAuthorized).not.toHaveBeenCalled();
134+
});
135+
136+
it('should show loading state during auth check', async () => {
137+
mockClient.isAuthorized.mockImplementation(
138+
() => new Promise((resolve) => setTimeout(() => resolve(true), 100))
139+
);
140+
141+
const { result } = renderHook(
142+
() => useAuth0(undefined, { audience: TEST_AUDIENCE }),
143+
{ wrapper }
144+
);
145+
146+
expect(result.current.isLoading).toBe(true);
147+
148+
await expectAuthenticatedState(result);
149+
});
150+
151+
it('should re-check authorization when dependencies change', async () => {
152+
const { result, rerender } = renderHook(
153+
({ audience, scope }) => useAuth0(undefined, { audience, scope }),
154+
{
155+
wrapper,
156+
initialProps: { audience: AUDIENCE_1, scope: SCOPE_1 },
157+
}
158+
);
159+
160+
await waitFor(() => {
161+
expect(result.current.isLoading).toBe(false);
162+
});
163+
164+
expect(mockClient.isAuthorized).toHaveBeenCalledWith({
165+
audience: AUDIENCE_1,
166+
scope: SCOPE_1,
167+
});
168+
169+
rerender({ audience: AUDIENCE_2, scope: SCOPE_2 });
170+
171+
await waitFor(() => {
172+
expect(mockClient.isAuthorized).toHaveBeenCalledWith({
173+
audience: AUDIENCE_2,
174+
scope: SCOPE_2,
175+
});
176+
});
177+
178+
expect(mockClient.isAuthorized).toHaveBeenCalledTimes(2);
179+
});
51180
});

src/auth0-context.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export interface Auth0ContextInterface<TUser extends User = User>
3838
*
3939
* If refresh tokens are used, the token endpoint is called directly with the
4040
* 'refresh_token' grant. If no refresh token is available to make this call,
41-
* the SDK will only fall back to using an iframe to the '/authorize' URL if
41+
* the SDK will only fall back to using an iframe to the '/authorize' URL if
4242
* the `useRefreshTokensFallback` setting has been set to `true`. By default this
4343
* setting is `false`.
4444
*
@@ -140,8 +140,18 @@ export interface Auth0ContextInterface<TUser extends User = User>
140140
* @param url The URL to that should be used to retrieve the `state` and `code` values. Defaults to `window.location.href` if not given.
141141
*/
142142
handleRedirectCallback: (url?: string) => Promise<RedirectLoginResult>;
143-
}
144143

144+
/**
145+
* Validates if the current token contains the required scopes and audience.
146+
*
147+
* @param options Options for scope and audience validation.
148+
* @returns `true` if the token contains the required scopes and audience, otherwise `false`.
149+
*/
150+
isAuthorized?: (options: {
151+
audience?: string;
152+
scope?: string;
153+
}) => Promise<boolean>;
154+
}
145155
/**
146156
* @ignore
147157
*/
@@ -163,6 +173,7 @@ export const initialContext = {
163173
loginWithPopup: stub,
164174
logout: stub,
165175
handleRedirectCallback: stub,
176+
isAuthorized: stub,
166177
};
167178

168179
/**

src/auth0-provider.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
GetTokenWithPopupOptions,
1515
RedirectLoginResult,
1616
GetTokenSilentlyOptions,
17+
AuthorizationParams,
1718
User,
1819
} from '@auth0/auth0-spa-js';
1920
import Auth0Context, {
@@ -256,6 +257,18 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => {
256257
[client]
257258
);
258259

260+
const isAuthorized = useCallback(
261+
async (options: AuthorizationParams): Promise<boolean> => {
262+
try {
263+
return await client.isAuthorized(options);
264+
} catch (error) {
265+
handleError(tokenError(error));
266+
return false;
267+
}
268+
},
269+
[client, handleError]
270+
);
271+
259272
const handleRedirectCallback = useCallback(
260273
async (url?: string): Promise<RedirectLoginResult> => {
261274
try {
@@ -282,6 +295,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => {
282295
loginWithPopup,
283296
logout,
284297
handleRedirectCallback,
298+
isAuthorized,
285299
};
286300
}, [
287301
state,
@@ -292,6 +306,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions) => {
292306
loginWithPopup,
293307
logout,
294308
handleRedirectCallback,
309+
isAuthorized,
295310
]);
296311

297312
return <context.Provider value={contextValue}>{children}</context.Provider>;

0 commit comments

Comments
 (0)