Skip to content

Commit f8890ae

Browse files
feat: add support for online access (Online Refresh Tokens) (#1624)
## Summary Adds support for **Online Refresh Tokens (ORTs)** — non-rotating refresh tokens bound to the Auth0 session lifetime (unlike the existing rotating offline refresh tokens). Opt in via the new `refreshTokenMode` option: ```ts const auth0 = await createAuth0Client({ domain, clientId, useRefreshTokens: true, refreshTokenMode: RefreshTokenMode.Online, // defaults to Offline useDpop: true // required for online access }); ``` `refreshTokenMode` defaults to `RefreshTokenMode.Offline`, so **existing apps are unaffected**. ## What it does - Injects `online_access` (instead of `offline_access`) and routes renewal through the refresh-token grant. - Reuses the same ORT on every refresh (non-rotating). - Requires `useDpop: true`, enforced at compile time (typed overloads) and runtime (`InvalidConfigurationError`). Requires `online_refresh_tokens` (tenant) and `allow_online_access` (resource server, on by default). See [EXAMPLES.md](./EXAMPLES.md#online-access-online-refresh-tokens). --------- Co-authored-by: Nandan Prabhu <nandan.prabhup@okta.com>
1 parent be177f1 commit f8890ae

18 files changed

Lines changed: 1444 additions & 39 deletions

EXAMPLES.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- [Logging Out](#logging-out)
44
- [Calling an API](#calling-an-api)
55
- [Refresh Tokens](#refresh-tokens)
6+
- [Online Access (Online Refresh Tokens)](#online-access-online-refresh-tokens)
67
- [Data Caching Options](#creating-a-custom-cache)
78
- [Organizations](#organizations)
89
- [Native to Web SSO](#native-to-web-sso)
@@ -153,6 +154,9 @@ The `revokeRefreshToken()` method explicitly revokes a refresh token via the `/o
153154

154155
This method only has an effect when `useRefreshTokens` is `true`. If refresh tokens are disabled it returns immediately without doing anything.
155156

157+
> [!NOTE]
158+
> In [online access](#online-access-online-refresh-tokens) mode (`refreshTokenMode: RefreshTokenMode.Online`), `revokeRefreshToken()` only clears the cached refresh token locally — it does **not** revoke the Online Refresh Token at the authorization server. Online Refresh Tokens are non-rotating and session-bound, and the server does not support token-only revocation for them. To end an online session, call `logout()` instead, which terminates the session and thereby invalidates the ORT.
159+
156160
```js
157161
// Revoke the refresh token for the default audience
158162
await auth0.revokeRefreshToken();
@@ -205,6 +209,141 @@ When using [Multi-Resource Refresh Tokens (MRRT)](#using-multi-resource-refresh-
205209
await auth0.revokeRefreshToken();
206210
```
207211

212+
## Online Access (Online Refresh Tokens)
213+
214+
**Online Refresh Tokens (ORTs)** are a refresh token type bound to the lifetime of the user's Auth0 session. See the [Auth0 documentation on Online Refresh Tokens](https://auth0.com/docs/secure/tokens/refresh-tokens/online-refresh-tokens/online-refresh-tokens) for the full conceptual overview. Unlike the rotating [offline refresh tokens](#refresh-tokens) described above, an ORT is:
215+
216+
- **Session-bound** — it is 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.
217+
- **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.
218+
219+
This makes ORTs a good fit for SPAs that want a refresh-token renewal path whose lifetime tracks the SSO session rather than living independently of it.
220+
221+
> [!IMPORTANT]
222+
> 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.
223+
224+
### Enabling Online Access
225+
226+
Set `refreshTokenMode` to `RefreshTokenMode.Online` together with `useRefreshTokens: true` and `useDpop: true`:
227+
228+
```js
229+
import { createAuth0Client, RefreshTokenMode } from '@auth0/auth0-spa-js';
230+
231+
const auth0 = await createAuth0Client({
232+
domain: '<AUTH0_DOMAIN>',
233+
clientId: '<AUTH0_CLIENT_ID>',
234+
useRefreshTokens: true, // required — online access is a refresh-token grant
235+
refreshTokenMode: RefreshTokenMode.Online,
236+
useDpop: true, // required — DPoP is mandatory for online access
237+
authorizationParams: {
238+
redirect_uri: '<MY_CALLBACK_URL>'
239+
}
240+
});
241+
```
242+
243+
`refreshTokenMode` is a sub-option of `useRefreshTokens`. It defaults to `RefreshTokenMode.Offline` (the rotating [offline refresh tokens](#refresh-tokens) described above); setting it to `RefreshTokenMode.Online` opts into Online Refresh Tokens. Always reference the exported `RefreshTokenMode` enum rather than hard-coding the mode.
244+
245+
Enabling this option causes the SDK to:
246+
247+
- Send the `online_access` scope to the authorization server (instead of `offline_access`). You do **not** need to add it to `authorizationParams.scope` yourself — the SDK injects it.
248+
- Route token renewal through the `refresh_token` grant against `/oauth/token` (the same path used by offline refresh tokens), rather than a hidden iframe.
249+
- Store the non-rotating ORT in the existing cache and reuse it on every refresh, never replacing it.
250+
251+
> [!NOTE]
252+
> Online access is **opt-in**. When `refreshTokenMode` is unset or `RefreshTokenMode.Offline`, the SDK behaves exactly as before.
253+
254+
> [!NOTE]
255+
> This feature requires the `online_refresh_tokens` flag to be enabled for your tenant and `allow_online_access` to be enabled on the resource server (on by default).
256+
257+
### `RefreshTokenMode.Offline` vs. `RefreshTokenMode.Online`
258+
259+
`refreshTokenMode` selects which refresh-token type the refresh-token grant uses. It is a sub-option of `useRefreshTokens` (which must be `true` for either mode) and defaults to `RefreshTokenMode.Offline`:
260+
261+
| | `RefreshTokenMode.Offline` (default) | `RefreshTokenMode.Online` |
262+
| --- | --- | --- |
263+
| Requires | `useRefreshTokens: true` | `useRefreshTokens: true` + `useDpop: true` |
264+
| Scope injected | `offline_access` | `online_access` |
265+
| Token lifetime | Independent of the session (survives logout until revoked/expired) | Bound to the Auth0 session |
266+
| Rotation | Rotating (a new RT is issued on each refresh) | Non-rotating (same RT reused) |
267+
| DPoP | Optional | **Required** (`useDpop: true`) |
268+
269+
The two modes inject mutually exclusive scopes (`offline_access` vs. `online_access`), so the SDK emits only one — it never sends both. You select between them with `refreshTokenMode`, not by combining flags.
270+
271+
### Configuration validation
272+
273+
The SDK enforces the DPoP requirement at two layers:
274+
275+
1. **Compile-time (TypeScript).** When you call `createAuth0Client` with `refreshTokenMode: RefreshTokenMode.Online`, the compiler requires both `useRefreshTokens: true` and `useDpop: true`:
276+
277+
```ts
278+
import { createAuth0Client, RefreshTokenMode } from '@auth0/auth0-spa-js';
279+
280+
// ❌ compile error: `useRefreshTokens: true` and `useDpop: true` are required for online mode
281+
createAuth0Client({ domain, clientId, refreshTokenMode: RefreshTokenMode.Online });
282+
283+
// ❌ compile error: `useDpop: true` is still required
284+
createAuth0Client({ domain, clientId, refreshTokenMode: RefreshTokenMode.Online, useRefreshTokens: true });
285+
286+
// ✅ valid
287+
createAuth0Client({ domain, clientId, refreshTokenMode: RefreshTokenMode.Online, useRefreshTokens: true, useDpop: true });
288+
```
289+
290+
> [!NOTE]
291+
> The compile-time check narrows on the online mode value. A dynamically-typed value (e.g. a `refreshTokenMode` read from config at runtime), an `as any` cast, or plain JavaScript all bypass it — which is why the runtime check below exists too.
292+
293+
2. **Runtime (all consumers, including plain JS).** The `Auth0Client` constructor throws an `InvalidConfigurationError` when online mode is requested but `useRefreshTokens` or `useDpop` is not `true`. The error's `suggestion` tells you exactly which option to set:
294+
295+
```js
296+
import { createAuth0Client, RefreshTokenMode, InvalidConfigurationError } from '@auth0/auth0-spa-js';
297+
298+
try {
299+
const auth0 = await createAuth0Client({
300+
domain: '<AUTH0_DOMAIN>',
301+
clientId: '<AUTH0_CLIENT_ID>',
302+
refreshTokenMode: RefreshTokenMode.Online,
303+
useRefreshTokens: true // missing useDpop: true
304+
});
305+
} catch (e) {
306+
if (e instanceof InvalidConfigurationError) {
307+
console.error(e.error_description); // includes the suggested fix
308+
console.error(e.suggestion); // 'Set `useDpop: true` (DPoP is mandatory for online access).'
309+
}
310+
}
311+
```
312+
313+
### Logging out
314+
315+
Because an ORT is bound to the Auth0 session, the way to invalidate it is to end the session with `logout()`, which clears the local cache and redirects to `/v2/logout`:
316+
317+
```js
318+
await auth0.logout({ logoutParams: { returnTo: window.location.origin } });
319+
```
320+
321+
After logout, the ORT is no longer valid; a subsequent `getTokenSilently()` falls through to the [iframe fallback](#refresh-token-fallback) (if `useRefreshTokensFallback` is enabled) and ultimately to an interactive login.
322+
323+
> [!NOTE]
324+
> In online mode, [`revokeRefreshToken()`](#revoking-refresh-tokens) only clears the cached token locally — it does **not** revoke the ORT at the authorization server. The server has no token-only revocation for non-rotating ORTs, so `logout()` is the only way to invalidate an ORT.
325+
326+
### Using with Multi-Resource Refresh Tokens (MRRT)
327+
328+
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 — the same token is reused for every cross-audience exchange.
329+
330+
```js
331+
import { createAuth0Client, RefreshTokenMode } from '@auth0/auth0-spa-js';
332+
333+
const auth0 = await createAuth0Client({
334+
domain: '<AUTH0_DOMAIN>',
335+
clientId: '<AUTH0_CLIENT_ID>',
336+
useRefreshTokens: true,
337+
refreshTokenMode: RefreshTokenMode.Online,
338+
useDpop: true,
339+
useMrrt: true,
340+
authorizationParams: {
341+
redirect_uri: '<MY_CALLBACK_URL>',
342+
audience: 'https://api.example.com'
343+
}
344+
});
345+
```
346+
208347
## Data caching options
209348

210349
The SDK can be configured to cache ID tokens and access tokens either in memory or in local storage. The default is in memory. This setting can be controlled using the `cacheLocation` option when creating the Auth0 client.

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,30 @@ window.addEventListener('load', async () => {
114114
});
115115
```
116116

117+
### Online Access
118+
119+
Set `refreshTokenMode` to `RefreshTokenMode.Online` (together with the required `useRefreshTokens: true` and `useDpop: true`) to use **Online Refresh Tokens** — non-rotating refresh tokens bound to the Auth0 session lifetime. The SDK injects the `online_access` scope and routes renewal through the refresh-token grant.
120+
121+
```js
122+
import { createAuth0Client, RefreshTokenMode } from '@auth0/auth0-spa-js';
123+
124+
const auth0 = await createAuth0Client({
125+
domain: '<AUTH0_DOMAIN>',
126+
clientId: '<AUTH0_CLIENT_ID>',
127+
useRefreshTokens: true,
128+
refreshTokenMode: RefreshTokenMode.Online,
129+
useDpop: true,
130+
authorizationParams: {
131+
redirect_uri: '<MY_CALLBACK_URL>'
132+
}
133+
});
134+
```
135+
136+
`refreshTokenMode` is a sub-option of `useRefreshTokens`: it defaults to `RefreshTokenMode.Offline` (the rotating refresh tokens described above) and must be set to `RefreshTokenMode.Online` for Online Refresh Tokens. Online mode requires both `useRefreshTokens: true` and `useDpop: true`. See [Online Access](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md#online-access-online-refresh-tokens) in EXAMPLES.md for the full guide.
137+
117138
### More Examples
118139

119-
For comprehensive examples covering various scenarios including logging out, calling APIs, refresh tokens, organizations, passkeys, MFA, DPoP, and more, see the [EXAMPLES.md](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md) document.
140+
For comprehensive examples covering various scenarios including logging out, calling APIs, refresh tokens, online access, organizations, passkeys, MFA, DPoP, and more, see the [EXAMPLES.md](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md) document.
120141

121142
## API Reference
122143

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getMissingScopes } from '../src/Auth0Client.utils';
2+
3+
/**
4+
* `online_access` is stripped from the requested scopes only when `onlineAccess` is true:
5+
* in online mode the server reuses the same (non-rotating) ORT for an MRRT cross-audience
6+
* exchange and never echoes `online_access` back in the response scope, so comparing it
7+
* would flag a spurious missing scope. Outside online mode the strip is not applied, so a
8+
* genuinely missing `online_access` is still reported. `offline_access` is never excluded —
9+
* the server echoes it back, so a genuinely missing `offline_access` should still be reported.
10+
*/
11+
describe('getMissingScopes', () => {
12+
describe('in online mode', () => {
13+
it('does not report online_access as missing', () => {
14+
expect(
15+
getMissingScopes('openid online_access fs:read', 'openid fs:read', true)
16+
).toBe('');
17+
});
18+
19+
it('still reports genuinely missing resource scopes', () => {
20+
expect(
21+
getMissingScopes(
22+
'openid online_access fs:read fs:write',
23+
'openid fs:read',
24+
true
25+
)
26+
).toBe('fs:write');
27+
});
28+
});
29+
30+
describe('outside online mode', () => {
31+
it('reports a genuinely missing online_access', () => {
32+
expect(
33+
getMissingScopes('openid online_access fs:read', 'openid fs:read')
34+
).toBe('online_access');
35+
});
36+
37+
it('still reports a genuinely missing offline_access', () => {
38+
expect(
39+
getMissingScopes('openid offline_access fs:read', 'openid fs:read')
40+
).toBe('offline_access');
41+
});
42+
});
43+
});

0 commit comments

Comments
 (0)