Skip to content

Commit 1d5e69f

Browse files
martinmlWidcketgyaneshgouraw-okta
authored
Add support for DPoP (#869)
### Description This PR adds support for DPoP via an update of `auth0-spa-js` from 2.3.0 to 2.4.1. New methods are exposed, docs are updated. No logic changes. ### Testing - [x] This change adds test coverage for new/changed/fixed functionality ### Checklist - [x] I have added documentation for new/changed functionality in this PR or in auth0.com/docs - [x] All active GitHub checks for tests, formatting, and security are passing - [x] The correct base branch is being used, if not the default branch --------- Co-authored-by: Rita Zerrizuela <[email protected]> Co-authored-by: Gyanesh Gouraw <[email protected]>
1 parent 1068215 commit 1d5e69f

File tree

8 files changed

+461
-308
lines changed

8 files changed

+461
-308
lines changed

EXAMPLES.md

Lines changed: 232 additions & 0 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

@@ -339,3 +340,234 @@ const Page = withAuthenticationRequired(
339340
withClaimCheck(Component, checkClaims, '/missing-roles' )
340341
);
341342
```
343+
344+
## Device-bound tokens with DPoP
345+
346+
**Demonstrating Proof-of-Possession** —or simply **DPoP**— is a recent 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 using cryptographic signatures. Without it, **a token leak caused by XSS or other vulnerabilities could allow an attacker to impersonate the real user.**
349+
350+
To support DPoP in `auth0-react`, some APIs available in modern browsers are required:
351+
352+
- [Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Crypto): allows to create and use cryptographic keys, which are used to generate the proofs (i.e. signatures) required for DPoP.
353+
354+
- [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API): enables the use of cryptographic keys [without exposing 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-react`:
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+
> [!IMPORTANT]
365+
> Currently, only the `ES256` algorithm is supported.
366+
367+
### Enabling DPoP
368+
369+
DPoP is disabled by default. To enable it, set the `useDpop` option to `true` when invoking the provider. For example:
370+
371+
```jsx
372+
<Auth0Provider
373+
domain="YOUR_AUTH0_DOMAIN"
374+
clientId="YOUR_AUTH0_CLIENT_ID"
375+
useDpop={true} // 👈
376+
authorizationParams={{ redirect_uri: window.location.origin }}
377+
>
378+
```
379+
380+
After enabling DPoP, **every new session using a supported OAuth 2.0 flow in Auth0 will begin transparently to use tokens that are cryptographically bound to the current browser**.
381+
382+
> [!IMPORTANT]
383+
> DPoP will only be used for new user sessions created after enabling it. Any previously existing sessions will continue using non-DPoP tokens until the user logs in again.
384+
>
385+
> You decide how to handle this transition. For example, you might require users to log in again the next time they use your application.
386+
387+
> [!NOTE]
388+
> Using DPoP requires storing some temporary data in the user's browser. When you log the user out with `logout()`, this data is deleted.
389+
390+
> [!TIP]
391+
> If all your clients are already using DPoP, you may want to increase security by making Auth0 reject any non-DPoP interactions. See [the docs on Sender Constraining](https://auth0.com/docs/secure/sender-constraining/configure-sender-constraining) for details.
392+
393+
### Using DPoP in your own requests
394+
395+
You use a DPoP token the same way as a "traditional" access token, except it must be sent to the server with an `Authorization: DPoP <token>` header instead of the usual `Authorization: Bearer <token>`.
396+
397+
To determine the type of a token, use the `detailedResponse` option in `getAccessTokenSilently()` to access the `token_type` property, which will be either `DPoP` or `Bearer`.
398+
399+
For internal requests sent by `auth0-react` to Auth0, simply enable the `useDpop` option and **every interaction with Auth0 will be protected**.
400+
401+
However, **to use DPoP with a custom, external API, some additional work is required**. The `useAuth()` hook provides some low-level methods to help with this:
402+
403+
- `getDpopNonce()`
404+
- `setDpopNonce()`
405+
- `generateDpopProof()`
406+
407+
However, due to the nature of how DPoP works, **this is not a trivial task**:
408+
409+
- When a nonce is missing or expired, the request may need to be retried.
410+
- Received nonces must be stored and managed.
411+
- DPoP headers must be generated and included in every request, and regenerated for retries.
412+
413+
Because of this, we recommend using the provided `fetchWithAuth()` method, which **handles all of this for you**.
414+
415+
#### Simple usage
416+
417+
The `fetchWithAuth()` method is a drop-in replacement for the native `fetch()` function from the Fetch API, so if you're already using it, the change will be minimal.
418+
419+
For example, if you had this code:
420+
421+
```js
422+
await fetch('https://api.example.com/foo', {
423+
method: 'GET',
424+
headers: { 'user-agent': 'My Client 1.0' }
425+
});
426+
427+
console.log(response.status);
428+
console.log(response.headers);
429+
console.log(await response.json());
430+
```
431+
432+
You would change it as follows:
433+
434+
```js
435+
const { createFetcher } = useAuth0();
436+
437+
const fetcher = createFetcher({
438+
dpopNonceId: 'my_api_request'
439+
});
440+
441+
await fetcher.fetchWithAuth('https://api.example.com/foo', {
442+
method: 'GET',
443+
headers: { 'user-agent': 'My Client 1.0' }
444+
});
445+
446+
console.log(response.status);
447+
console.log(response.headers);
448+
console.log(await response.json());
449+
```
450+
451+
When using `fetchWithAuth()`, the following will be handled for you automatically:
452+
453+
- Use `getAccessTokenSilently()` to get the access token to inject in the headers.
454+
- Generate and inject DPoP headers when needed.
455+
- Store and update any DPoP nonces.
456+
- Handle retries caused by a rejected nonce.
457+
458+
> [!IMPORTANT]
459+
> If DPoP is enabled in the provider, a `dpopNonceId` **must** be present in the `createFetcher()` parameters, since it’s used to keep track of the DPoP nonces for each request.
460+
461+
#### Advanced usage
462+
463+
If you need something more complex than the example above, you can provide a custom implementation in the `fetch` property.
464+
465+
However, since `auth0-react` needs to make decisions based on HTTP responses, your implementation **must return an object with _at least_ two properties**:
466+
467+
1. `status`: the response status code as a number.
468+
2. `headers`: the response headers as a plain object or as a Fetch API’s Headers-like interface.
469+
470+
Whatever it returns, it will be passed as the output of the `fetchWithAuth()` method.
471+
472+
Your implementation will be called with a standard, ready-to-use [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) object, which will contain any headers needed for authorization and DPoP usage (if enabled). Depending on your needs, you can use this object directly or treat it as a container with everything required to make the request your own way.
473+
474+
##### Example with `axios`
475+
476+
```js
477+
const { createFetcher } = useAuth0();
478+
479+
const fetcher = createFetcher({
480+
dpopNonceId: 'my_api_request',
481+
fetch: (request) =>
482+
// The `Request` object has everything you need to do a request in a
483+
// different library. Make sure that your output meets the requirements
484+
// about the `status` and `headers` properties.
485+
axios.request({
486+
url: request.url,
487+
method: request.method,
488+
data: request.body,
489+
headers: Object.fromEntries(request.headers),
490+
timeout: 2000,
491+
// etc.
492+
}),
493+
},
494+
});
495+
496+
const response = await fetcher.fetchWithAuth('https://api.example.com/foo', {
497+
method: 'POST',
498+
body: JSON.stringify({ name: 'John Doe' }),
499+
headers: { 'user-agent': 'My Client 1.0' },
500+
});
501+
502+
console.log(response.status);
503+
console.log(response.headers);
504+
console.log(response.data);
505+
```
506+
507+
##### Timeouts with native `fetch()`
508+
509+
The Fetch API doesn’t support passing a timeout value directly; instead, you’re expected to use an [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). For example:
510+
511+
```js
512+
const { createFetcher } = useAuth0();
513+
514+
const fetcher = createFetcher();
515+
516+
await fetcher.fetchWithAuth('https://api.example.com/foo', {
517+
signal: AbortSignal.timeout(2000)
518+
});
519+
```
520+
521+
This works, but if you define your request parameters statically when your app starts and then call `fetchWithAuth()` after an indeterminate amount of time, you'll find that **the request will timeout immediately**. This happens because the `AbortSignal` **starts counting time as soon as it is created**.
522+
523+
To work around this, you can pass a thin wrapper over the native `fetch()` so that a new `AbortSignal` is created each time a request is made:
524+
525+
```js
526+
const { createFetcher } = useAuth0();
527+
528+
const fetcher = createFetcher({
529+
fetch: (request) => fetch(request, { signal: AbortSignal.timeout(2000) })
530+
});
531+
532+
await fetcher.fetchWithAuth('https://api.example.com/foo');
533+
```
534+
535+
##### Having a base URL
536+
537+
If you need to make requests to different endpoints of the same API, passing a `baseUrl` to `createFetcher()` can be useful:
538+
539+
```js
540+
const { createFetcher } = useAuth0();
541+
542+
const fetcher = createFetcher({
543+
baseUrl: 'https://api.example.com'
544+
});
545+
546+
await fetcher.fetchWithAuth('/foo'); // => https://api.example.com/foo
547+
await fetcher.fetchWithAuth('/bar'); // => https://api.example.com/bar
548+
await fetcher.fetchWithAuth('/xyz'); // => https://api.example.com/xyz
549+
550+
// If the passed URL is absolute, `baseUrl` will be ignored for convenience:
551+
await fetcher.fetchWithAuth('https://other-api.example.com/foo');
552+
```
553+
554+
##### Passing an access token
555+
556+
The `fetchWithAuth()` method assumes you’re using the SDK to get the access token for the request. This means that by default, it will always call `getAccessTokenSilently()` internally before making the request.
557+
558+
However, if you already have an access token or need to pass specific parameters to `getAccessTokenSilently()`, you can override this behavior with a custom access token factory, like so:
559+
560+
```js
561+
const { createFetcher, getAccessTokenSilently } = useAuth0();
562+
563+
createFetcher({
564+
getAccessToken: () =>
565+
getAccessTokenSilently({
566+
authorizationParams: {
567+
audience: '<SOME_AUDIENCE>',
568+
scope: '<SOME_SCOPE>'
569+
// etc.
570+
}
571+
})
572+
});
573+
```

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ 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();
16+
const createFetcher = jest.fn();
1317

1418
export const Auth0Client = jest.fn(() => {
1519
return {
@@ -25,5 +29,9 @@ export const Auth0Client = jest.fn(() => {
2529
loginWithPopup,
2630
loginWithRedirect,
2731
logout,
32+
getDpopNonce,
33+
setDpopNonce,
34+
generateDpopProof,
35+
createFetcher,
2836
};
2937
});

__tests__/auth-provider.test.tsx

Lines changed: 49 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,54 @@ describe('Auth0Provider', () => {
940941
});
941942
});
942943

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

0 commit comments

Comments
 (0)