Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
97 changes: 97 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- [Device-bound tokens with DPoP](#device-bound-tokens-with-dpop)
- [Using Multi Resource Refresh Tokens](#using-multi-resource-refresh-tokens)
- [Revoke Refresh Token](#revoke-refresh-token)
- [Online Access (Online Refresh Tokens)](#online-access-online-refresh-tokens)
- [Connect Accounts for using Token Vault](#connect-accounts-for-using-token-vault)
- [Access SDK Configuration](#access-sdk-configuration)
- [Multi-Factor Authentication (MFA)](#multi-factor-authentication-mfa)
Expand Down Expand Up @@ -830,6 +831,102 @@ await revokeRefreshToken({ audience: 'https://api.example.com' });
> await logout({ openUrl: false });
> ```

## Online Access (Online Refresh Tokens)

> [!NOTE]
> Online Access (Online Refresh Tokens) support via SDKs is currently in Early Access. To request access to this feature, contact your Auth0 representative.

**Online Refresh Tokens (ORTs)** are a refresh token type bound to the lifetime of the user's Auth0 session, unlike the rotating offline refresh tokens described above. An ORT is:

- **Session-bound** — valid only while the underlying Auth0 session is active. When the session ends (logout, idle/absolute session expiry, or an admin revoking the session), the ORT stops working.
- **Non-rotating** — refreshing an access token with an ORT does **not** issue a new refresh token; the same ORT is reused for the life of the session.

Read more about [Online Refresh Tokens](https://auth0.com/docs/secure/tokens/refresh-tokens/online-refresh-tokens/online-refresh-tokens) to decide whether this fits your application.

> [!IMPORTANT]
> Online access requires DPoP. Sender-constraining the token via [DPoP](#device-bound-tokens-with-dpop) is mandatory because the ORT is non-rotating — binding it to the browser's key pair is what mitigates token replay if it is exfiltrated. You must set `useDpop={true}` explicitly; the SDK does not enable it for you.
>
> This also requires the `online_refresh_tokens` flag to be enabled for your Auth0 tenant, and `allow_online_access` to be enabled on the resource server you log in with (on by default).

### Enabling Online Access

Set `refreshTokenMode` to `RefreshTokenMode.Online` together with `useRefreshTokens={true}` and `useDpop={true}`:

```jsx
import { Auth0Provider, RefreshTokenMode } from '@auth0/auth0-react';

<Auth0Provider
domain="YOUR_AUTH0_DOMAIN"
clientId="YOUR_AUTH0_CLIENT_ID"
useRefreshTokens={true} // required — online access is a refresh-token grant
refreshTokenMode={RefreshTokenMode.Online} // 👈
useDpop={true} // required — DPoP is mandatory for online access
authorizationParams={{ redirect_uri: window.location.origin }}
>
```

`refreshTokenMode` defaults to `RefreshTokenMode.Offline` (the rotating refresh tokens described above). Enabling online mode causes the underlying SDK to:

- Send the `online_access` scope to the authorization server (instead of `offline_access`) — you do **not** need to add it to `authorizationParams.scope` yourself.
- Route token renewal through the `refresh_token` grant against `/oauth/token` rather than a hidden iframe.
- Store the non-rotating ORT in the existing cache and reuse it on every refresh, never replacing it.

### Configuration validation

If `refreshTokenMode={RefreshTokenMode.Online}` is set without `useRefreshTokens={true}` and `useDpop={true}`, the underlying `Auth0Client` constructor throws an `InvalidConfigurationError`. Because `Auth0Provider` constructs the client during render, wrap it in an error boundary or validate your configuration up front:

```jsx
import { InvalidConfigurationError } from '@auth0/auth0-react';

try {
// Constructing without useDpop={true} throws InvalidConfigurationError
} catch (e) {
if (e instanceof InvalidConfigurationError) {
console.error(e.error_description); // includes the suggested fix
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

### Revoking the Online Refresh Token

Use `revokeRefreshToken()` from the `useAuth0` hook to explicitly revoke the refresh token via the `/oauth/revoke` endpoint:

```jsx
const { revokeRefreshToken } = useAuth0();

await revokeRefreshToken();
// Revoke for a specific audience:
await revokeRefreshToken({ audience: 'https://api.example.com' });
```

> [!WARNING]
> In online mode, `revokeRefreshToken()` behaves differently from offline mode:
> - The ORT **is** revoked at the authorization server, and because it is session-bound, the Auth0 **session is terminated server-side** as part of revocation.
> - The entire local cache is cleared immediately — `isAuthenticated` becomes `false` and `user` becomes `undefined` right away, without waiting for the access token to expire.
>
> In **offline mode**, only the refresh token is invalidated — the cached access token and user profile remain valid until the access token expires.
>
> After calling `revokeRefreshToken()` in online mode, redirect the user to login. For a redirect-based sign-out in either mode, use `logout()` instead.

### Using Online Access with MRRT

Online access is compatible with [MRRT](#using-multi-resource-refresh-tokens): a single ORT can be exchanged for access tokens across the audiences allowed by your refresh-token policies. The ORT remains non-rotating throughout.

```jsx
<Auth0Provider
domain="YOUR_AUTH0_DOMAIN"
clientId="YOUR_AUTH0_CLIENT_ID"
useRefreshTokens={true}
refreshTokenMode={RefreshTokenMode.Online}
useDpop={true}
useMrrt={true} // 👈
authorizationParams={{
redirect_uri: window.location.origin,
audience: 'https://api.example.com'
}}
>
```

## Connect Accounts for using Token Vault

The Connect Accounts feature uses the Auth0 My Account API to allow users to link multiple third party accounts to a single Auth0 user profile.
Expand Down
3 changes: 3 additions & 0 deletions __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export const Auth0Client = jest.fn(() => {
});

export const ResponseType = actual.ResponseType;
export const RefreshTokenMode = actual.RefreshTokenMode;
export const InvalidConfigurationError = actual.InvalidConfigurationError;
export const MissingScopesError = actual.MissingScopesError;

export const MfaError = actual.MfaError;
export const MfaListAuthenticatorsError = actual.MfaListAuthenticatorsError;
Expand Down
109 changes: 102 additions & 7 deletions __tests__/auth-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
Auth0Client, ConnectAccountRedirectResult,
GetTokenSilentlyVerboseResponse,
ResponseType
ResponseType,
RefreshTokenMode
} from '@auth0/auth0-spa-js';
import '@testing-library/jest-dom';
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
Expand Down Expand Up @@ -61,6 +62,33 @@ describe('Auth0Provider', () => {
});
});

it('should forward online-access options to Auth0Client', async () => {
const opts = {
clientId: 'foo',
domain: 'bar',
useRefreshTokens: true,
useDpop: true,
useMrrt: true,
refreshTokenMode: RefreshTokenMode.Online,
};
const wrapper = createWrapper(opts);
renderHook(() => useContext(Auth0Context), {
wrapper,
});
await waitFor(() => {
expect(Auth0Client).toHaveBeenCalledWith(
expect.objectContaining({
clientId: 'foo',
domain: 'bar',
useRefreshTokens: true,
useDpop: true,
useMrrt: true,
refreshTokenMode: 'online',
})
);
});
});

it('should support redirectUri', async () => {
const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
const opts = {
Expand Down Expand Up @@ -604,13 +632,33 @@ describe('Auth0Provider', () => {
});

it('should provide a revokeRefreshToken method', async () => {
const user = { name: '__test_user__' };
clientMock.getUser.mockResolvedValue(user);
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitFor(() => {
expect(result.current.revokeRefreshToken).toBeInstanceOf(Function);
expect(result.current.isAuthenticated).toBe(true);
});
await act(async () => {
await result.current.revokeRefreshToken();
});
expect(clientMock.revokeRefreshToken).toHaveBeenCalled();
});

it('should forward options to revokeRefreshToken', async () => {
const user = { name: '__test_user__' };
clientMock.getUser.mockResolvedValue(user);
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
});
await act(async () => {
await result.current.revokeRefreshToken({ audience: 'https://api.example.com' });
Expand All @@ -620,21 +668,68 @@ describe('Auth0Provider', () => {
});
});

it('should propagate errors from revokeRefreshToken', async () => {
clientMock.revokeRefreshToken.mockRejectedValue(new Error('__test_error__'));
it('should reflect cleared session state after revokeRefreshToken in online mode', async () => {
// In online mode, revokeRefreshToken() clears the entire local session server-side.
// getUser() reflects this by resolving to undefined afterward.
const user = { name: '__test_user__' };
clientMock.getUser.mockResolvedValueOnce(user);
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
});
clientMock.getUser.mockResolvedValueOnce(undefined);
await act(async () => {
await result.current.revokeRefreshToken();
});
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeUndefined();
});
});

it('should not change session state after revokeRefreshToken in offline mode', async () => {
// In offline mode, revokeRefreshToken() only invalidates the refresh token;
// the cached user/access token remain valid until they expire.
const user = { name: '__test_user__' };
clientMock.getUser.mockResolvedValue(user);
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitFor(() => {
expect(result.current.revokeRefreshToken).toBeInstanceOf(Function);
expect(result.current.isAuthenticated).toBe(true);
});
await act(async () => {
await expect(result.current.revokeRefreshToken()).rejects.toThrow(
'__test_error__'
);
await result.current.revokeRefreshToken();
});
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toBe(user);
});

it('should rethrow errors from revokeRefreshToken', async () => {
const user = { name: '__test_user__' };
clientMock.getUser.mockResolvedValue(user);
clientMock.revokeRefreshToken.mockRejectedValueOnce(
new Error('The token has been revoked')
);
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
});
await expect(
act(async () => {
await result.current.revokeRefreshToken();
})
).rejects.toThrow('The token has been revoked');
});

it('should memoize the revokeRefreshToken method', async () => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,6 @@
"react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
},
"dependencies": {
"@auth0/auth0-spa-js": "^2.22.0"
"@auth0/auth0-spa-js": "^2.23.0"
}
}
20 changes: 16 additions & 4 deletions src/auth0-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,25 @@ export interface Auth0ContextInterface<TUser extends User = User>
* await revokeRefreshToken({ audience: 'https://api.example.com' });
* ```
*
* Revokes the refresh token for the current session by calling the
* `/oauth/revoke` endpoint. Once revoked, the token can no longer be
* used to obtain new access tokens.
* Revokes the refresh token via the `/oauth/revoke` endpoint. This invalidates the
* refresh token so it can no longer be used to obtain new access tokens.
*
* If `useRefreshTokens` is not enabled this is a no-op.
* If `useRefreshTokens` is disabled, this method does nothing.
* When multiple audiences share the same refresh token, revoking for
* one audience invalidates it for all of them.
*
* **Online mode** (`refreshTokenMode: RefreshTokenMode.Online`): revoking the Online
* Refresh Token also terminates the Auth0 session server-side and clears the entire
* local cache. `isAuthenticated` and `user` update immediately to reflect the
* terminated session — no redirect required. Use `logout()` instead if you want a
* redirect-based sign-out.
*
* **Offline mode**: only the refresh token is invalidated; the cached access token
* and user profile remain valid until the access token expires. `isAuthenticated`
* and `user` are unaffected until then.
*
* @param options - Optional parameters to identify which refresh token to revoke.
* Defaults to the audience configured in `authorizationParams`.
*/
revokeRefreshToken: (options?: RevokeRefreshTokenOptions) => Promise<void>;

Expand Down
13 changes: 11 additions & 2 deletions src/auth0-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,17 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
);

const revokeRefreshToken = useCallback(
(options?: RevokeRefreshTokenOptions): Promise<void> =>
client.revokeRefreshToken(options),
async (opts?: RevokeRefreshTokenOptions): Promise<void> => {
await client.revokeRefreshToken(opts);
// Online mode clears the entire local session as part of revocation; offline
// mode leaves the cached access token/user untouched. Re-reading the user from
// the client after either case keeps isAuthenticated/user consistent with
// whatever the SDK actually did, without assuming which mode is active.
dispatch({
type: 'GET_ACCESS_TOKEN_COMPLETE',
user: await client.getUser(),
});
},
[client]
);

Expand Down
3 changes: 3 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export {
PopupOpenError,
AuthenticationError,
MissingRefreshTokenError,
MissingScopesError,
InvalidConfigurationError,
GenericError,
UseDpopNonceError,
RedirectConnectAccountOptions,
Expand All @@ -50,6 +52,7 @@ export {
TokenEndpointResponse,
ActClaim,
ClientConfiguration,
RefreshTokenMode,
// MFA Errors
MfaError,
MfaListAuthenticatorsError,
Expand Down
Loading