Skip to content

Commit a97412a

Browse files
committed
Add fetcher support and docs and simplify types
1 parent 1a6f0db commit a97412a

6 files changed

Lines changed: 227 additions & 146 deletions

File tree

EXAMPLES.md

Lines changed: 182 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -343,29 +343,30 @@ const Page = withAuthenticationRequired(
343343
344344
## Device-bound tokens with DPoP
345345
346-
**Demonstrating Proof-of-Possession** or just **DPoP** is an OAuth 2.0 extension defined in [RFC9449](https://datatracker.ietf.org/doc/html/rfc9449).
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).
347347
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.**
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.**
349349
350-
In order to support DPoP in `auth0-spa-js`, we require some APIs found in modern browsers:
350+
To support DPoP in `auth0-react`, some APIs available in modern browsers are required:
351351
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.
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.
353353
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).
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).
355355
356-
The following OAuth 2.0 flows are currently supported by `auth0-spa-js`:
356+
The following OAuth 2.0 flows are currently supported by `auth0-react`:
357357
358358
- [Authorization Code Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow) (`authorization_code`).
359359
360360
- [Refresh Token Flow](https://auth0.com/docs/secure/tokens/refresh-tokens) (`refresh_token`).
361361
362362
- [Custom Token Exchange Flow](https://auth0.com/docs/authenticate/custom-token-exchange) (`urn:ietf:params:oauth:grant-type:token-exchange`).
363363
364-
Currently, only the `ES256` algorithm is supported.
364+
> [!IMPORTANT]
365+
> Currently, only the `ES256` algorithm is supported.
365366
366367
### Enabling DPoP
367368
368-
Currently, DPoP is disabled by default. To enable it, set the `useDpop` option to `true` when invoking the provider. For example:
369+
DPoP is disabled by default. To enable it, set the `useDpop` option to `true` when invoking the provider. For example:
369370
370371
```jsx
371372
<Auth0Provider
@@ -376,127 +377,197 @@ Currently, DPoP is disabled by default. To enable it, set the `useDpop` option t
376377
>
377378
```
378379
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+
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**.
380381
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:
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.
384386
385-
```js
386-
const headers = {
387-
Authorization: `${token.token_type} ${token.access_token}`,
388-
};
389-
```
387+
> [!IMPORTANT]
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.
390389
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.
390+
> [!IMPORTANT]
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.
392392
393-
### Clearing DPoP data
393+
### Using DPoP in your own requests
394394
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.
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>`.
396396
397-
### Using DPoP in your own requests
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`.
398398
399-
Enabling `useDpop` **protects every internal request that the SDK sends to Auth0** (i.e. the authorization server).
399+
For internal requests sent by `auth0-react` to Auth0, simply enable the `useDpop` option and **every interaction with Auth0 will be protected**.
400400
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:
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:
402402
403403
- `getDpopNonce()`
404404
- `setDpopNonce()`
405405
- `generateDpopProof()`
406406
407-
This example shows how these coould be used:
407+
However, due to the nature of how DPoP works, **this is not a trivial task**:
408408
409-
```jsx
410-
import { useEffect, useState } from 'react';
411-
import { useAuth0 } from '@auth0/auth0-react';
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.
412412
413-
const Posts = () => {
414-
const {
415-
getAccessTokenSilently,
416-
getDpopNonce,
417-
setDpopNonce,
418-
generateDpopProof,
419-
} = useAuth0();
413+
Because of this, we recommend using the provided `fetchWithAuth()` method, which **handles all of this for you**.
420414
421-
const [posts, setPosts] = useState(null);
415+
#### Simple usage
422416
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(params) {
444-
const { url, method, body, accessToken, nonce, isDpopNonceRetry } =
445-
params;
446-
447-
const headers = {
448-
// A DPoP access token has the type `DPoP` and not `Bearer`.
449-
Authorization: `DPoP ${accessToken}`,
450-
451-
// Include the DPoP proof, which is cryptographic evidence that we
452-
// are in possession of the same key that was used to get the token.
453-
DPoP: await generateDpopProof({ url, method, nonce, accessToken }),
454-
};
455-
456-
// Make the request.
457-
const response = await fetch(url, { method, headers, body });
458-
459-
// If there was a nonce in the response, save it.
460-
const newNonce = response.headers.get('dpop-nonce');
461-
462-
if (newNonce) {
463-
setDpopNonce(newNonce, nonceId);
464-
}
465-
466-
// If the server rejects the DPoP nonce but it provides a new one, try
467-
// the request one last time with the correct nonce.
468-
if (
469-
response.status === 401 &&
470-
response.headers.get('www-authenticate')?.includes('use_dpop_nonce')
471-
) {
472-
if (isDpopNonceRetry) {
473-
throw new Error('DPoP nonce was rejected twice, giving up');
474-
}
475-
476-
return fetchWithDpop({
477-
...params,
478-
nonce: newNonce ?? nonce,
479-
isDpopNonceRetry: true,
480-
});
481-
}
482-
483-
return response;
484-
}
485-
})();
486-
}, []);
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.
487418
488-
if (!posts) {
489-
return <div>Loading...</div>;
490-
}
419+
For example, if you had this code:
491420
492-
return (
493-
<ul>
494-
{posts.map((post, index) => {
495-
return <li key={index}>{post}</li>;
496-
})}
497-
</ul>
498-
);
499-
};
421+
```js
422+
await fetch('https://api.example.com/foo', {
423+
method: 'GET',
424+
headers: { 'user-agent': 'My Client 1.0' }
425+
});
500426

501-
export default Posts;
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) => 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+
});
502573
```

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const logout = jest.fn();
1313
const getDpopNonce = jest.fn();
1414
const setDpopNonce = jest.fn();
1515
const generateDpopProof = jest.fn();
16+
const createFetcher = jest.fn();
1617

1718
export const Auth0Client = jest.fn(() => {
1819
return {
@@ -31,5 +32,6 @@ export const Auth0Client = jest.fn(() => {
3132
getDpopNonce,
3233
setDpopNonce,
3334
generateDpopProof,
35+
createFetcher,
3436
};
3537
});

0 commit comments

Comments
 (0)