Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

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

> [!TIP]
> 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) => fetch(request, { signal: AbortSignal.timeout(2000) })
});

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