Skip to content
Merged
232 changes: 232 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- [Protecting a route in a Next.js app (in SPA mode)](#protecting-a-route-in-a-nextjs-app-in-spa-mode)
- [Use with Auth0 organizations](#use-with-auth0-organizations)
- [Protecting a route with a claims check](#protecting-a-route-with-a-claims-check)
- [Device-bound tokens with DPoP](#device-bound-tokens-with-dpop)

## Use with a Class Component

Expand Down Expand Up @@ -339,3 +340,234 @@ const Page = withAuthenticationRequired(
withClaimCheck(Component, checkClaims, '/missing-roles' )
);
```

## Device-bound tokens with DPoP

**Demonstrating Proof-of-Possession** —or simply **DPoP**— is a recent OAuth 2.0 extension defined in [RFC9449](https://datatracker.ietf.org/doc/html/rfc9449).

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.**

To support DPoP in `auth0-react`, some APIs available in modern browsers are required:

- [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.

- [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).

The following OAuth 2.0 flows are currently supported by `auth0-react`:

- [Authorization Code Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow) (`authorization_code`).

- [Refresh Token Flow](https://auth0.com/docs/secure/tokens/refresh-tokens) (`refresh_token`).

- [Custom Token Exchange Flow](https://auth0.com/docs/authenticate/custom-token-exchange) (`urn:ietf:params:oauth:grant-type:token-exchange`).

> [!IMPORTANT]
> Currently, only the `ES256` algorithm is supported.

### Enabling DPoP

DPoP is disabled by default. To enable it, set the `useDpop` option to `true` when invoking the provider. For example:

```jsx
<Auth0Provider
domain="YOUR_AUTH0_DOMAIN"
clientId="YOUR_AUTH0_CLIENT_ID"
useDpop={true} // 👈
authorizationParams={{ redirect_uri: window.location.origin }}
>
```

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**.

> [!IMPORTANT]
> 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.
>
> You decide how to handle this transition. For example, you might require users to log in again the next time they use your application.

> [!IMPORTANT]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one should probably be a NOTE.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

> Using DPoP requires storing some temporary data in the user's browser. When you log the user out with `logout()`, this data is deleted.

> [!IMPORTANT]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one should probably be a TIP.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

> 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.

### Using DPoP in your own requests

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>`.

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`.

For internal requests sent by `auth0-react` to Auth0, simply enable the `useDpop` option and **every interaction with Auth0 will be protected**.

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:

- `getDpopNonce()`
- `setDpopNonce()`
- `generateDpopProof()`

However, due to the nature of how DPoP works, **this is not a trivial task**:

- When a nonce is missing or expired, the request may need to be retried.
- Received nonces must be stored and managed.
- DPoP headers must be generated and included in every request, and regenerated for retries.

Because of this, we recommend using the provided `fetchWithAuth()` method, which **handles all of this for you**.

#### Simple usage

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.

For example, if you had this code:

```js
await fetch('https://api.example.com/foo', {
method: 'GET',
headers: { 'user-agent': 'My Client 1.0' }
});

console.log(response.status);
console.log(response.headers);
console.log(await response.json());
```

You would change it as follows:

```js
const { createFetcher } = useAuth0();

const fetcher = createFetcher({
dpopNonceId: 'my_api_request'
});

await fetcher.fetchWithAuth('https://api.example.com/foo', {
method: 'GET',
headers: { 'user-agent': 'My Client 1.0' }
});

console.log(response.status);
console.log(response.headers);
console.log(await response.json());
```

When using `fetchWithAuth()`, the following will be handled for you automatically:

- Use `getAccessTokenSilently()` to get the access token to inject in the headers.
- Generate and inject DPoP headers when needed.
- Store and update any DPoP nonces.
- Handle retries caused by a rejected nonce.

> [!IMPORTANT]
> 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.

#### Advanced usage

If you need something more complex than the example above, you can provide a custom implementation in the `fetch` property.

However, since `auth0-react` needs to make decisions based on HTTP responses, your implementation **must return an object with _at least_ two properties**:

1. `status`: the response status code as a number.
2. `headers`: the response headers as a plain object or as a Fetch API’s Headers-like interface.

Whatever it returns, it will be passed as the output of the `fetchWithAuth()` method.

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.

##### Example with `axios`

```js
const { createFetcher } = useAuth0();

const fetcher = createFetcher({
dpopNonceId: 'my_api_request',
fetch: (request) =>
// The `Request` object has everything you need to do a request in a
// different library. Make sure that your output meets the requirements
// about the `status` and `headers` properties.
axios.request({
url: request.url,
method: request.method,
data: request.body,
headers: Object.fromEntries(request.headers),
timeout: 2000,
// etc.
}),
},
});

const response = await fetcher.fetchWithAuth('https://api.example.com/foo', {
method: 'POST',
body: JSON.stringify({ name: 'John Doe' }),
headers: { 'user-agent': 'My Client 1.0' },
});

console.log(response.status);
console.log(response.headers);
console.log(response.data);
```

##### Timeouts with native `fetch()`

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:

```js
const { createFetcher } = useAuth0();

const fetcher = createFetcher();

await fetcher.fetchWithAuth('https://api.example.com/foo', {
signal: AbortSignal.timeout(2000)
});
```

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**.

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:

```js
const { createFetcher } = useAuth0();

const fetcher = createFetcher({
fetch: (request) => signal: AbortSignal.timeout(2000),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The syntax here seems off.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

});

await fetcher.fetchWithAuth('https://api.example.com/foo');
```

##### Having a base URL

If you need to make requests to different endpoints of the same API, passing a `baseUrl` to `createFetcher()` can be useful:

```js
const { createFetcher } = useAuth0();

const fetcher = createFetcher({
baseUrl: 'https://api.example.com'
});

await fetcher.fetchWithAuth('/foo'); // => https://api.example.com/foo
await fetcher.fetchWithAuth('/bar'); // => https://api.example.com/bar
await fetcher.fetchWithAuth('/xyz'); // => https://api.example.com/xyz

// If the passed URL is absolute, `baseUrl` will be ignored for convenience:
await fetcher.fetchWithAuth('https://other-api.example.com/foo');
```

##### Passing an access token

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.

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:

```js
const { createFetcher, getAccessTokenSilently } = useAuth0();

createFetcher({
getAccessToken: () =>
getAccessTokenSilently({
authorizationParams: {
audience: '<SOME_AUDIENCE>',
scope: '<SOME_SCOPE>'
// etc.
}
})
});
```
8 changes: 8 additions & 0 deletions __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const isAuthenticated = jest.fn(() => false);
const loginWithPopup = jest.fn();
const loginWithRedirect = jest.fn();
const logout = jest.fn();
const getDpopNonce = jest.fn();
const setDpopNonce = jest.fn();
const generateDpopProof = jest.fn();
const createFetcher = jest.fn();

export const Auth0Client = jest.fn(() => {
return {
Expand All @@ -25,5 +29,9 @@ export const Auth0Client = jest.fn(() => {
loginWithPopup,
loginWithRedirect,
logout,
getDpopNonce,
setDpopNonce,
generateDpopProof,
createFetcher,
};
});
49 changes: 49 additions & 0 deletions __tests__/auth-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ describe('Auth0Provider', () => {
access_token: '123',
id_token: '456',
expires_in: 2,
token_type: 'Bearer',
};
(clientMock.getTokenSilently as jest.Mock).mockResolvedValue(tokenResponse);
const wrapper = createWrapper();
Expand Down Expand Up @@ -940,6 +941,54 @@ describe('Auth0Provider', () => {
});
});

it('should provide a getDpopNonce method', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useContext(Auth0Context), { wrapper });

expect(result.current.getDpopNonce).toBeInstanceOf(Function);
await act(() => result.current.getDpopNonce());
expect(clientMock.getDpopNonce).toHaveBeenCalled();
});

it('should provide a setDpopNonce method', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useContext(Auth0Context), { wrapper });

const nonce = 'n-123456';
const id = 'my-nonce';

expect(result.current.setDpopNonce).toBeInstanceOf(Function);
await act(() => result.current.setDpopNonce(nonce, id));
expect(clientMock.setDpopNonce).toHaveBeenCalledWith(nonce, id);
});

it('should provide a generateDpopProof method', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useContext(Auth0Context), { wrapper });

const params = {
url: 'https://api.example.com/foo',
method: 'GET',
nonce: 'n-123456',
accessToken: 'at-123456',
};

expect(result.current.generateDpopProof).toBeInstanceOf(Function);
await act(() => result.current.generateDpopProof(params));
expect(clientMock.generateDpopProof).toHaveBeenCalledWith(params);
});

it('should provide a createFetcher method', async () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useContext(Auth0Context), { wrapper });

const config = { dpopNonceId: 'my_dpop_nonce_test_id' };

expect(result.current.createFetcher).toBeInstanceOf(Function);
await act(() => result.current.createFetcher(config));
expect(clientMock.createFetcher).toHaveBeenCalledWith(config);
});

it('should not update context value after rerender with no state change', async () => {
clientMock.getTokenSilently.mockReturnThis();
clientMock.getUser.mockResolvedValue({ name: 'foo' });
Expand Down
42 changes: 42 additions & 0 deletions src/auth0-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
User,
GetTokenSilentlyVerboseResponse,
RedirectLoginOptions as SPARedirectLoginOptions,
type Auth0Client,
} from '@auth0/auth0-spa-js';
import { createContext } from 'react';
import { AuthState, initialAuthState } from './auth-state';
Expand Down Expand Up @@ -140,6 +141,43 @@ export interface Auth0ContextInterface<TUser extends User = User>
* @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<RedirectLoginResult>;

/**
* Returns the current DPoP nonce used for making requests to Auth0.
*
* It can return `undefined` because when starting fresh it will not
* be populated until after the first response from the server.
*
* It requires enabling the {@link Auth0ClientOptions.useDpop} option.
*
* @param nonce The nonce value.
* @param id The identifier of a nonce: if absent, it will get the nonce
* used for requests to Auth0. Otherwise, it will be used to
* select a specific non-Auth0 nonce.
*/
getDpopNonce: Auth0Client['getDpopNonce'];

/**
* Gets the current DPoP nonce used for making requests to Auth0.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Gets the current DPoP nonce used for making requests to Auth0.
* Sets the current DPoP nonce used for making requests to Auth0.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

*
* It requires enabling the {@link Auth0ClientOptions.useDpop} option.
*
* @param nonce The nonce value.
* @param id The identifier of a nonce: if absent, it will set the nonce
* used for requests to Auth0. Otherwise, it will be used to
* select a specific non-Auth0 nonce.
*/
setDpopNonce: Auth0Client['setDpopNonce'];

/**
* Returns a string to be used to demonstrate possession of the private
* key used to cryptographically bind access tokens with DPoP.
*
* It requires enabling the {@link Auth0ClientOptions.useDpop} option.
*/
generateDpopProof: Auth0Client['generateDpopProof'];

createFetcher: Auth0Client['createFetcher'];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is missing doc comments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

}

/**
Expand All @@ -163,6 +201,10 @@ export const initialContext = {
loginWithPopup: stub,
logout: stub,
handleRedirectCallback: stub,
getDpopNonce: stub,
setDpopNonce: stub,
generateDpopProof: stub,
createFetcher: stub,
};

/**
Expand Down
Loading
Loading