Skip to content

Commit 3fe465a

Browse files
committed
Add support for DPoP
1 parent 64c0fb2 commit 3fe465a

File tree

5 files changed

+288
-4
lines changed

5 files changed

+288
-4
lines changed

EXAMPLES.md

Lines changed: 169 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- [Protecting a route in a Next.js app (in SPA mode)](#protecting-a-route-in-a-nextjs-app-in-spa-mode)
99
- [Use with Auth0 organizations](#use-with-auth0-organizations)
1010
- [Protecting a route with a claims check](#protecting-a-route-with-a-claims-check)
11+
- [Device-bound tokens with DPoP](#device-bound-tokens-with-dpop)
1112

1213
## Use with a Class Component
1314

@@ -325,17 +326,181 @@ In order to protect a route with a claims check alongside an authentication requ
325326
326327
```jsx
327328
const withClaimCheck = (Component, myClaimCheckFunction, returnTo) => {
328-
const { user } = useAuth0();
329+
const { user } = useAuth0();
329330
if (myClaimCheckFunction(user)) {
330-
return <Component />
331+
return <Component />;
331332
}
332333
Router.push(returnTo);
333-
}
334+
};
334335

335336
const checkClaims = (claim?: User) => claim?.['https://my.app.io/jwt/claims']?.ROLE?.includes('ADMIN');
336337

337338
// Usage
338339
const Page = withAuthenticationRequired(
339-
withClaimCheck(Component, checkClaims, '/missing-roles' )
340+
withClaimCheck(Component, checkClaims, '/missing-roles')
340341
);
341342
```
343+
344+
## Device-bound tokens with DPoP
345+
346+
**Demonstrating Proof-of-Possession** –or just **DPoP**– is an OAuth 2.0 extension defined in [RFC9449](https://datatracker.ietf.org/doc/html/rfc9449).
347+
348+
It defines a mechanism for securely binding tokens to a specific device by means of cryptographic signatures. Without it, **a token leak caused by XSS or other vulnerability could result in an attacker impersonating the real user.**
349+
350+
In order to support DPoP in `auth0-spa-js`, we require some APIs found in modern browsers:
351+
352+
- [Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Crypto): it allows to create and use cryptographic keys that will be used for creating the proofs (i.e. signatures) used in DPoP.
353+
354+
- [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API): it allows to use cryptographic keys [without giving access to the private material](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto#storing_keys).
355+
356+
The following OAuth 2.0 flows are currently supported by `auth0-spa-js`:
357+
358+
- [Authorization Code Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow) (`authorization_code`).
359+
360+
- [Refresh Token Flow](https://auth0.com/docs/secure/tokens/refresh-tokens) (`refresh_token`).
361+
362+
- [Custom Token Exchange Flow](https://auth0.com/docs/authenticate/custom-token-exchange) (`urn:ietf:params:oauth:grant-type:token-exchange`).
363+
364+
Currently, only the `ES256` algorithm is supported.
365+
366+
### Enabling DPoP
367+
368+
Currently, DPoP is disabled by default. To enable it, set the `useDpop` option to `true` when invoking the provider. For example:
369+
370+
```jsx
371+
<Auth0Provider
372+
domain="YOUR_AUTH0_DOMAIN"
373+
clientId="YOUR_AUTH0_CLIENT_ID"
374+
useDpop={true} // 👈
375+
authorizationParams={{ redirect_uri: window.location.origin }}
376+
>
377+
```
378+
379+
After enabling DPoP, supported OAuth 2.0 flows in Auth0 will start transparently issuing tokens that will be cryptographically bound to the current browser.
380+
381+
Note that a DPoP token will have to be sent to a resource server with an `Authorization: DPoP <token>` header instead of `Authorization: Bearer <token>` as usual.
382+
383+
If you're using both types at the same time, you can use the `detailedResponse` option in `getAccessTokenSilently()` to get access to the `token_type` property and know what kind of token you got:
384+
385+
```js
386+
const headers = {
387+
Authorization: `${token.token_type} ${token.access_token}`,
388+
};
389+
```
390+
391+
If all your clients are already using DPoP, you may want to increase security and make Auth0 reject non-DPoP interactions by enabling the "Require Token Sender-Constraining" option in your Auth0's application settings. Check [the docs](https://auth0.com/docs/get-started/applications/configure-sender-constraining) for details.
392+
393+
### Clearing DPoP data
394+
395+
When using DPoP some temporary data is stored in the user's browser. When you log the user out with `logout()`, it will be deleted.
396+
397+
### Using DPoP in your own requests
398+
399+
Enabling `useDpop` **protects every internal request that the SDK sends to Auth0** (i.e. the authorization server).
400+
401+
However, if you want to use a DPoP access token to authenticate against a custom API (i.e. a resource server), some extra work is required. `Auth0Provider` has some methods that will provide the needed pieces:
402+
403+
- `getDpopNonce()`
404+
- `setDpopNonce()`
405+
- `generateDpopProof()`
406+
407+
This example shows how these coould be used:
408+
409+
```jsx
410+
import { useEffect, useState } from 'react';
411+
import { useAuth0 } from '@auth0/auth0-react';
412+
413+
const Posts = () => {
414+
const {
415+
getAccessTokenSilently,
416+
getDpopNonce,
417+
setDpopNonce,
418+
generateDpopProof,
419+
} = useAuth0();
420+
421+
const [posts, setPosts] = useState(null);
422+
423+
useEffect(() => {
424+
(async () => {
425+
// Define an identifier that the SDK will use to reference the nonces.
426+
const nonceId = 'my_api_request';
427+
428+
// Get an access token as usual.
429+
const accessToken = await getAccessTokenSilently();
430+
431+
// Get the current DPoP nonce (if any) and do the request with it.
432+
const nonce = await getDpopNonce(nonceId);
433+
434+
const response = await fetchWithDpop({
435+
url: 'https://api.example.com/posts',
436+
method: 'GET',
437+
accessToken,
438+
nonce,
439+
});
440+
441+
setPosts(await response.json());
442+
443+
async function fetchWithDpop({
444+
url,
445+
method,
446+
body,
447+
accessToken,
448+
nonce,
449+
isDpopNonceRetry,
450+
}) {
451+
const headers = {
452+
// A DPoP access token has the type `DPoP` and not `Bearer`.
453+
Authorization: `DPoP ${accessToken}`,
454+
455+
// Include the DPoP proof, which is cryptographic evidence that we
456+
// are in possession of the same key that was used to get the token.
457+
DPoP: await generateDpopProof({ url, method, nonce, accessToken }),
458+
};
459+
460+
// Make the request.
461+
const response = await fetch(url, { method, headers, body });
462+
463+
// If there was a nonce in the response, save it.
464+
const newNonce = response.headers.get('dpop-nonce');
465+
466+
if (newNonce) {
467+
setDpopNonce(newNonce, nonceId);
468+
}
469+
470+
// If the server rejects the DPoP nonce but it provides a new one, try
471+
// the request one last time with the correct nonce.
472+
if (
473+
response.status === 401 &&
474+
response.headers.get('www-authenticate')?.includes('use_dpop_nonce')
475+
) {
476+
if (isDpopNonceRetry) {
477+
throw new Error('DPoP nonce was rejected twice, giving up');
478+
}
479+
480+
return fetchWithDpop({
481+
...params,
482+
nonce: newNonce ?? nonce,
483+
isDpopNonceRetry: true,
484+
});
485+
}
486+
487+
return response;
488+
}
489+
})();
490+
}, []);
491+
492+
if (!posts) {
493+
return <div>Loading...</div>;
494+
}
495+
496+
return (
497+
<ul>
498+
{posts.map((post, index) => {
499+
return <li key={index}>{post}</li>;
500+
})}
501+
</ul>
502+
);
503+
};
504+
505+
export default Posts;
506+
```

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const isAuthenticated = jest.fn(() => false);
1010
const loginWithPopup = jest.fn();
1111
const loginWithRedirect = jest.fn();
1212
const logout = jest.fn();
13+
const getDpopNonce = jest.fn();
14+
const setDpopNonce = jest.fn();
15+
const generateDpopProof = jest.fn();
1316

1417
export const Auth0Client = jest.fn(() => {
1518
return {
@@ -25,5 +28,8 @@ export const Auth0Client = jest.fn(() => {
2528
loginWithPopup,
2629
loginWithRedirect,
2730
logout,
31+
getDpopNonce,
32+
setDpopNonce,
33+
generateDpopProof,
2834
};
2935
});

__tests__/auth-provider.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,7 @@ describe('Auth0Provider', () => {
522522
access_token: '123',
523523
id_token: '456',
524524
expires_in: 2,
525+
token_type: 'Bearer',
525526
};
526527
(clientMock.getTokenSilently as jest.Mock).mockResolvedValue(tokenResponse);
527528
const wrapper = createWrapper();
@@ -940,6 +941,52 @@ describe('Auth0Provider', () => {
940941
});
941942
});
942943

944+
it('should provide a getDpopNonce method', async () => {
945+
const wrapper = createWrapper();
946+
const { result } = renderHook(
947+
() => useContext(Auth0Context),
948+
{ wrapper }
949+
);
950+
951+
expect(result.current.getDpopNonce).toBeInstanceOf(Function);
952+
await act(() => result.current.getDpopNonce())
953+
expect(clientMock.getDpopNonce).toHaveBeenCalled();
954+
});
955+
956+
it('should provide a setDpopNonce method', async () => {
957+
const wrapper = createWrapper();
958+
const { result } = renderHook(
959+
() => useContext(Auth0Context),
960+
{ wrapper }
961+
);
962+
963+
const nonce = 'n-123456';
964+
const id = 'my-nonce';
965+
966+
expect(result.current.setDpopNonce).toBeInstanceOf(Function);
967+
await act(() => result.current.setDpopNonce(nonce, id))
968+
expect(clientMock.setDpopNonce).toHaveBeenCalledWith(nonce, id);
969+
});
970+
971+
it('should provide a generateDpopProof method', async () => {
972+
const wrapper = createWrapper();
973+
const { result } = renderHook(
974+
() => useContext(Auth0Context),
975+
{ wrapper }
976+
);
977+
978+
const params = {
979+
url: 'https://api.example.com/foo',
980+
method: 'GET',
981+
nonce: 'n-123456',
982+
accessToken: 'at-123456',
983+
};
984+
985+
expect(result.current.generateDpopProof).toBeInstanceOf(Function);
986+
await act(() => result.current.generateDpopProof(params))
987+
expect(clientMock.generateDpopProof).toHaveBeenCalledWith(params);
988+
});
989+
943990
it('should not update context value after rerender with no state change', async () => {
944991
clientMock.getTokenSilently.mockReturnThis();
945992
clientMock.getUser.mockResolvedValue({ name: 'foo' });

src/auth0-context.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,46 @@ 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+
144+
/**
145+
* Returns the current DPoP nonce used for making requests to Auth0.
146+
*
147+
* It can return `undefined` because when starting fresh it will not
148+
* be populated until after the first response from the server.
149+
*
150+
* It requires enabling the {@link Auth0ClientOptions.useDpop} option.
151+
*
152+
* @param nonce The nonce value.
153+
* @param id The identifier of a nonce: if absent, it will set the nonce
154+
* used for requests to Auth0. Otherwise, it will be used to
155+
* select a specific non-Auth0 nonce.
156+
*/
157+
getDpopNonce(id?: string): Promise<string | undefined>;
158+
159+
/**
160+
* Gets the current DPoP nonce used for making requests to Auth0.
161+
*
162+
* It requires enabling the {@link Auth0ClientOptions.useDpop} option.
163+
*
164+
* @param nonce The nonce value.
165+
* @param id The identifier of a nonce: if absent, it will set the nonce
166+
* used for requests to Auth0. Otherwise, it will be used to
167+
* select a specific non-Auth0 nonce.
168+
*/
169+
setDpopNonce(nonce: string, id?: string): Promise<void>;
170+
171+
/**
172+
* Returns a string to be used to demonstrate possession of the private
173+
* key used to cryptographically bind access tokens with DPoP.
174+
*
175+
* It requires enabling the {@link Auth0ClientOptions.useDpop} option.
176+
*/
177+
generateDpopProof(params: {
178+
url: string;
179+
method: string;
180+
nonce?: string;
181+
accessToken: string;
182+
}): Promise<string>;
143183
}
144184

145185
/**
@@ -163,6 +203,9 @@ export const initialContext = {
163203
loginWithPopup: stub,
164204
logout: stub,
165205
handleRedirectCallback: stub,
206+
getDpopNonce: stub,
207+
setDpopNonce: stub,
208+
generateDpopProof: stub,
166209
};
167210

168211
/**

src/auth0-provider.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,23 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
272272
[client]
273273
);
274274

275+
const getDpopNonce = useCallback(() => client.getDpopNonce(), [client]);
276+
277+
const setDpopNonce = useCallback(
278+
(nonce: string, nonceId?: string) => client.setDpopNonce(nonce, nonceId),
279+
[client]
280+
);
281+
282+
const generateDpopProof = useCallback(
283+
(params: {
284+
url: string;
285+
method: string;
286+
nonce?: string;
287+
accessToken: string;
288+
}) => client.generateDpopProof(params),
289+
[client]
290+
);
291+
275292
const contextValue = useMemo<Auth0ContextInterface<TUser>>(() => {
276293
return {
277294
...state,
@@ -282,6 +299,9 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
282299
loginWithPopup,
283300
logout,
284301
handleRedirectCallback,
302+
getDpopNonce,
303+
setDpopNonce,
304+
generateDpopProof,
285305
};
286306
}, [
287307
state,
@@ -292,6 +312,9 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
292312
loginWithPopup,
293313
logout,
294314
handleRedirectCallback,
315+
getDpopNonce,
316+
setDpopNonce,
317+
generateDpopProof,
295318
]);
296319

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

0 commit comments

Comments
 (0)