Skip to content

Commit 4c90235

Browse files
committed
feat: add support for Online Access (Online Refresh Tokens)
Adds revokeRefreshToken() to the useAuth0 hook, wrapping the underlying Auth0Client method and keeping isAuthenticated/user in sync after the call. This is the one gap in Online Access support — refreshTokenMode, useDpop, and useMrrt already pass through Auth0Provider's existing Auth0ClientOptions config surface. Also exports RefreshTokenMode, InvalidConfigurationError, MissingScopesError, and RevokeRefreshTokenOptions from the package root, and documents the feature in EXAMPLES.md.
1 parent 04818a5 commit 4c90235

6 files changed

Lines changed: 286 additions & 3 deletions

File tree

EXAMPLES.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- [Protecting a route with a claims check](#protecting-a-route-with-a-claims-check)
1212
- [Device-bound tokens with DPoP](#device-bound-tokens-with-dpop)
1313
- [Using Multi Resource Refresh Tokens](#using-multi-resource-refresh-tokens)
14+
- [Online Access (Online Refresh Tokens)](#online-access-online-refresh-tokens)
1415
- [Connect Accounts for using Token Vault](#connect-accounts-for-using-token-vault)
1516
- [Access SDK Configuration](#access-sdk-configuration)
1617
- [Multi-Factor Authentication (MFA)](#multi-factor-authentication-mfa)
@@ -783,6 +784,99 @@ MRRT is disabled by default. To enable it, set the `useMrrt` option to `true` wh
783784
> In order MRRT to work, it needs a previous configuration setting the refresh token policies.
784785
> Visit [configure and implement MRRT.](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token/configure-and-implement-multi-resource-refresh-token)
785786
787+
## Online Access (Online Refresh Tokens)
788+
789+
**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:
790+
791+
- **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.
792+
- **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.
793+
794+
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.
795+
796+
> [!IMPORTANT]
797+
> 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.
798+
>
799+
> 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).
800+
801+
### Enabling Online Access
802+
803+
Set `refreshTokenMode` to `RefreshTokenMode.Online` together with `useRefreshTokens={true}` and `useDpop={true}`:
804+
805+
```jsx
806+
import { Auth0Provider, RefreshTokenMode } from '@auth0/auth0-react';
807+
808+
<Auth0Provider
809+
domain="YOUR_AUTH0_DOMAIN"
810+
clientId="YOUR_AUTH0_CLIENT_ID"
811+
useRefreshTokens={true} // required — online access is a refresh-token grant
812+
refreshTokenMode={RefreshTokenMode.Online} // 👈
813+
useDpop={true} // required — DPoP is mandatory for online access
814+
authorizationParams={{ redirect_uri: window.location.origin }}
815+
>
816+
```
817+
818+
`refreshTokenMode` defaults to `RefreshTokenMode.Offline` (the rotating refresh tokens described above). Enabling online mode causes the underlying SDK to:
819+
820+
- Send the `online_access` scope to the authorization server (instead of `offline_access`) — you do **not** need to add it to `authorizationParams.scope` yourself.
821+
- Route token renewal through the `refresh_token` grant against `/oauth/token` rather than a hidden iframe.
822+
- Store the non-rotating ORT in the existing cache and reuse it on every refresh, never replacing it.
823+
824+
### Configuration validation
825+
826+
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:
827+
828+
```jsx
829+
import { InvalidConfigurationError } from '@auth0/auth0-react';
830+
831+
try {
832+
// Constructing without useDpop={true} throws InvalidConfigurationError
833+
} catch (e) {
834+
if (e instanceof InvalidConfigurationError) {
835+
console.error(e.error_description); // includes the suggested fix
836+
}
837+
}
838+
```
839+
840+
### Revoking the Online Refresh Token
841+
842+
Use `revokeRefreshToken()` from the `useAuth0` hook to explicitly revoke the refresh token via the `/oauth/revoke` endpoint:
843+
844+
```jsx
845+
const { revokeRefreshToken } = useAuth0();
846+
847+
await revokeRefreshToken();
848+
// Revoke for a specific audience:
849+
await revokeRefreshToken({ audience: 'https://api.example.com' });
850+
```
851+
852+
> [!WARNING]
853+
> In online mode, `revokeRefreshToken()` behaves differently from offline mode:
854+
> - 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.
855+
> - The entire local cache is cleared immediately — `isAuthenticated` becomes `false` and `user` becomes `undefined` right away, without waiting for the access token to expire.
856+
>
857+
> In **offline mode**, only the refresh token is invalidated — the cached access token and user profile remain valid until the access token expires.
858+
>
859+
> After calling `revokeRefreshToken()` in online mode, redirect the user to login. For a redirect-based sign-out in either mode, use `logout()` instead.
860+
861+
### Using Online Access with MRRT
862+
863+
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.
864+
865+
```jsx
866+
<Auth0Provider
867+
domain="YOUR_AUTH0_DOMAIN"
868+
clientId="YOUR_AUTH0_CLIENT_ID"
869+
useRefreshTokens={true}
870+
refreshTokenMode={RefreshTokenMode.Online}
871+
useDpop={true}
872+
useMrrt={true} // 👈
873+
authorizationParams={{
874+
redirect_uri: window.location.origin,
875+
audience: 'https://api.example.com'
876+
}}
877+
>
878+
```
879+
786880
## Connect Accounts for using Token Vault
787881
788882
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.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const loginWithPopup = jest.fn();
1616
const loginWithRedirect = jest.fn();
1717
const connectAccountWithRedirect = jest.fn();
1818
const logout = jest.fn();
19+
const revokeRefreshToken = jest.fn();
1920
const getDpopNonce = jest.fn();
2021
const setDpopNonce = jest.fn();
2122
const generateDpopProof = jest.fn();
@@ -56,6 +57,7 @@ export const Auth0Client = jest.fn(() => {
5657
loginWithRedirect,
5758
connectAccountWithRedirect,
5859
logout,
60+
revokeRefreshToken,
5961
getDpopNonce,
6062
setDpopNonce,
6163
generateDpopProof,
@@ -85,6 +87,9 @@ export const Auth0Client = jest.fn(() => {
8587
});
8688

8789
export const ResponseType = actual.ResponseType;
90+
export const RefreshTokenMode = actual.RefreshTokenMode;
91+
export const InvalidConfigurationError = actual.InvalidConfigurationError;
92+
export const MissingScopesError = actual.MissingScopesError;
8893

8994
export const MfaError = actual.MfaError;
9095
export const MfaListAuthenticatorsError = actual.MfaListAuthenticatorsError;

__tests__/auth-provider.test.tsx

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
22
Auth0Client, ConnectAccountRedirectResult,
33
GetTokenSilentlyVerboseResponse,
4-
ResponseType
4+
ResponseType,
5+
RefreshTokenMode
56
} from '@auth0/auth0-spa-js';
67
import '@testing-library/jest-dom';
78
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
@@ -61,6 +62,33 @@ describe('Auth0Provider', () => {
6162
});
6263
});
6364

65+
it('should forward online-access options to Auth0Client', async () => {
66+
const opts = {
67+
clientId: 'foo',
68+
domain: 'bar',
69+
useRefreshTokens: true,
70+
useDpop: true,
71+
useMrrt: true,
72+
refreshTokenMode: RefreshTokenMode.Online,
73+
};
74+
const wrapper = createWrapper(opts);
75+
renderHook(() => useContext(Auth0Context), {
76+
wrapper,
77+
});
78+
await waitFor(() => {
79+
expect(Auth0Client).toHaveBeenCalledWith(
80+
expect.objectContaining({
81+
clientId: 'foo',
82+
domain: 'bar',
83+
useRefreshTokens: true,
84+
useDpop: true,
85+
useMrrt: true,
86+
refreshTokenMode: 'online',
87+
})
88+
);
89+
});
90+
});
91+
6492
it('should support redirectUri', async () => {
6593
const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
6694
const opts = {
@@ -603,6 +631,107 @@ describe('Auth0Provider', () => {
603631
});
604632
});
605633

634+
it('should provide a revokeRefreshToken method', async () => {
635+
const user = { name: '__test_user__' };
636+
clientMock.getUser.mockResolvedValue(user);
637+
const wrapper = createWrapper();
638+
const { result } = renderHook(
639+
() => useContext(Auth0Context),
640+
{ wrapper }
641+
);
642+
await waitFor(() => {
643+
expect(result.current.revokeRefreshToken).toBeInstanceOf(Function);
644+
expect(result.current.isAuthenticated).toBe(true);
645+
});
646+
await act(async () => {
647+
await result.current.revokeRefreshToken();
648+
});
649+
expect(clientMock.revokeRefreshToken).toHaveBeenCalled();
650+
});
651+
652+
it('should forward options to revokeRefreshToken', async () => {
653+
const user = { name: '__test_user__' };
654+
clientMock.getUser.mockResolvedValue(user);
655+
const wrapper = createWrapper();
656+
const { result } = renderHook(
657+
() => useContext(Auth0Context),
658+
{ wrapper }
659+
);
660+
await waitFor(() => {
661+
expect(result.current.isAuthenticated).toBe(true);
662+
});
663+
await act(async () => {
664+
await result.current.revokeRefreshToken({ audience: 'https://api.example.com' });
665+
});
666+
expect(clientMock.revokeRefreshToken).toHaveBeenCalledWith({
667+
audience: 'https://api.example.com',
668+
});
669+
});
670+
671+
it('should reflect cleared session state after revokeRefreshToken in online mode', async () => {
672+
// In online mode, revokeRefreshToken() clears the entire local session server-side.
673+
// getUser() reflects this by resolving to undefined afterward.
674+
const user = { name: '__test_user__' };
675+
clientMock.getUser.mockResolvedValueOnce(user);
676+
const wrapper = createWrapper();
677+
const { result } = renderHook(
678+
() => useContext(Auth0Context),
679+
{ wrapper }
680+
);
681+
await waitFor(() => {
682+
expect(result.current.isAuthenticated).toBe(true);
683+
});
684+
clientMock.getUser.mockResolvedValueOnce(undefined);
685+
await act(async () => {
686+
await result.current.revokeRefreshToken();
687+
});
688+
await waitFor(() => {
689+
expect(result.current.isAuthenticated).toBe(false);
690+
expect(result.current.user).toBeUndefined();
691+
});
692+
});
693+
694+
it('should not change session state after revokeRefreshToken in offline mode', async () => {
695+
// In offline mode, revokeRefreshToken() only invalidates the refresh token;
696+
// the cached user/access token remain valid until they expire.
697+
const user = { name: '__test_user__' };
698+
clientMock.getUser.mockResolvedValue(user);
699+
const wrapper = createWrapper();
700+
const { result } = renderHook(
701+
() => useContext(Auth0Context),
702+
{ wrapper }
703+
);
704+
await waitFor(() => {
705+
expect(result.current.isAuthenticated).toBe(true);
706+
});
707+
await act(async () => {
708+
await result.current.revokeRefreshToken();
709+
});
710+
expect(result.current.isAuthenticated).toBe(true);
711+
expect(result.current.user).toBe(user);
712+
});
713+
714+
it('should rethrow errors from revokeRefreshToken', async () => {
715+
const user = { name: '__test_user__' };
716+
clientMock.getUser.mockResolvedValue(user);
717+
clientMock.revokeRefreshToken.mockRejectedValueOnce(
718+
new Error('The token has been revoked')
719+
);
720+
const wrapper = createWrapper();
721+
const { result } = renderHook(
722+
() => useContext(Auth0Context),
723+
{ wrapper }
724+
);
725+
await waitFor(() => {
726+
expect(result.current.isAuthenticated).toBe(true);
727+
});
728+
await expect(
729+
act(async () => {
730+
await result.current.revokeRefreshToken();
731+
})
732+
).rejects.toThrow('The token has been revoked');
733+
});
734+
606735
it('should provide a getAccessTokenSilently method', async () => {
607736
clientMock.getTokenSilently.mockResolvedValue('token');
608737
const wrapper = createWrapper();

src/auth0-context.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
TokenEndpointResponse,
1717
type MfaApiClient,
1818
type PasskeyApiClient,
19-
type MyAccountApiClient
19+
type MyAccountApiClient,
20+
type RevokeRefreshTokenOptions
2021
} from '@auth0/auth0-spa-js';
2122
import { createContext } from 'react';
2223
import { AuthState, initialAuthState } from './auth-state';
@@ -273,6 +274,37 @@ export interface Auth0ContextInterface<TUser extends User = User>
273274
*/
274275
logout: (options?: LogoutOptions) => Promise<void>;
275276

277+
/**
278+
* ```js
279+
* await revokeRefreshToken();
280+
* ```
281+
*
282+
* Revokes the refresh token via the `/oauth/revoke` endpoint. This invalidates the
283+
* refresh token so it can no longer be used to obtain new access tokens.
284+
*
285+
* If `useRefreshTokens` is disabled, this method does nothing.
286+
*
287+
* **Online mode** (`refreshTokenMode: RefreshTokenMode.Online`): revoking the Online
288+
* Refresh Token also terminates the Auth0 session server-side and clears the entire
289+
* local cache. `isAuthenticated` and `user` update immediately to reflect the
290+
* terminated session — no redirect required. Use `logout()` instead if you want a
291+
* redirect-based sign-out.
292+
*
293+
* **Offline mode**: only the refresh token is invalidated; the cached access token
294+
* and user profile remain valid until the access token expires. `isAuthenticated`
295+
* and `user` are unaffected until then.
296+
*
297+
* @param options - Optional parameters to identify which refresh token to revoke.
298+
* Defaults to the audience configured in `authorizationParams`.
299+
*
300+
* @example
301+
* ```js
302+
* const { revokeRefreshToken } = useAuth0();
303+
* await revokeRefreshToken();
304+
* ```
305+
*/
306+
revokeRefreshToken: (options?: RevokeRefreshTokenOptions) => Promise<void>;
307+
276308
/**
277309
* After the browser redirects back to the callback page,
278310
* call `handleRedirectCallback` to handle success and error
@@ -470,6 +502,7 @@ export const initialContext = {
470502
loginWithPopup: stub,
471503
connectAccountWithRedirect: stub,
472504
logout: stub,
505+
revokeRefreshToken: stub,
473506
handleRedirectCallback: stub,
474507
getDpopNonce: stub,
475508
setDpopNonce: stub,

src/auth0-provider.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
TokenEndpointResponse,
2323
type PasskeyApiClient,
2424
type PasskeySignupOptions,
25-
type PasskeyLoginOptions
25+
type PasskeyLoginOptions,
26+
type RevokeRefreshTokenOptions
2627
} from '@auth0/auth0-spa-js';
2728
import Auth0Context, {
2829
Auth0ContextInterface,
@@ -259,6 +260,21 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
259260
[client]
260261
);
261262

263+
const revokeRefreshToken = useCallback(
264+
async (opts?: RevokeRefreshTokenOptions): Promise<void> => {
265+
await client.revokeRefreshToken(opts);
266+
// Online mode clears the entire local session as part of revocation; offline
267+
// mode leaves the cached access token/user untouched. Re-reading the user from
268+
// the client after either case keeps isAuthenticated/user consistent with
269+
// whatever the SDK actually did, without assuming which mode is active.
270+
dispatch({
271+
type: 'GET_ACCESS_TOKEN_COMPLETE',
272+
user: await client.getUser(),
273+
});
274+
},
275+
[client]
276+
);
277+
262278
const getAccessTokenSilently = useCallback(
263279
// eslint-disable-next-line @typescript-eslint/no-explicit-any
264280
async (opts?: GetTokenSilentlyOptions): Promise<any> => {
@@ -449,6 +465,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
449465
loginWithPopup,
450466
connectAccountWithRedirect,
451467
logout,
468+
revokeRefreshToken,
452469
handleRedirectCallback,
453470
getDpopNonce,
454471
setDpopNonce,
@@ -471,6 +488,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
471488
loginWithPopup,
472489
connectAccountWithRedirect,
473490
logout,
491+
revokeRefreshToken,
474492
handleRedirectCallback,
475493
getDpopNonce,
476494
setDpopNonce,

0 commit comments

Comments
 (0)