Skip to content

Commit a82e71f

Browse files
authored
feat(auth0-server-js): add Session Transfer Token support for CTE impersonation (#215)
1 parent b970077 commit a82e71f

8 files changed

Lines changed: 1486 additions & 4 deletions

File tree

packages/auth0-server-js/EXAMPLES.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@
4848
- [Performing a delegation exchange without a session](#performing-a-delegation-exchange-without-a-session)
4949
- [Using actor tokens for delegation](#using-actor-tokens-for-delegation)
5050
- [Passing `StoreOptions`](#passing-storeoptions-6)
51+
- [Impersonation via Session Transfer](#impersonation-via-session-transfer)
52+
- [Initiator: requesting a Session Transfer Token and redirecting](#initiator-requesting-a-session-transfer-token-and-redirecting)
53+
- [Target: redeeming the Session Transfer Token](#target-redeeming-the-session-transfer-token)
54+
- [Reading the `act` claim on the impersonation session](#reading-the-act-claim-on-the-impersonation-session)
5155
- [Retrieving the logged-in User](#retrieving-the-logged-in-user)
5256
- [Passing `StoreOptions`](#passing-storeoptions-7)
5357
- [Retrieving the Session Data](#retrieving-the-session-data)
@@ -1159,6 +1163,111 @@ const tokenResponse = await serverClient.customTokenExchange({ subjectToken, sub
11591163
11601164
Read more above in [Configuring the Store](#configuring-the-store)
11611165
1166+
## Impersonation via Session Transfer
1167+
1168+
Custom Token Exchange Impersonation via Session Transfer lets a support/admin application log an agent **into a target web application as a customer** — for example, so a support engineer can reproduce a customer's exact experience without ever knowing their password. It builds on Custom Token Exchange and involves two roles and two applications:
1169+
1170+
- **Initiator** — your support/admin app (this SDK). It requests a short-lived, single-use **Session Transfer Token (STT)** and redirects the agent's browser to the target app's login URL carrying the STT.
1171+
- **Target** — the customer's own web app. Its login route forwards the STT to `/authorize`, where Auth0 redeems it and establishes an ephemeral, device-bound session **as the customer**, recording the agent in the `act` (actor) claim.
1172+
1173+
The STT is **opaque, single-use, and short-lived (~60s)**. The SDK requests it, surfaces it, and helps you build the redirect — it never decodes, validates, caches, or persists it.
1174+
1175+
> [!IMPORTANT]
1176+
> This is a two-client, two-role flow with tenant prerequisites configured out of band (via the Management API / Dashboard):
1177+
>
1178+
> - The feature flag `cte_session_transfer_token` must be enabled on the tenant.
1179+
> - The **issuing (initiator) client** needs `session_transfer.can_create_session_transfer_token: true` and a Custom Token Exchange profile / Action that calls `setActor`.
1180+
> - The **redeeming (target) client** needs `session_transfer.delegation.allow_delegated_access: true`, `session_transfer.allowed_authentication_methods` including `"query"`, and `session_transfer.delegation.enforce_device_binding` (`"ip"` by default).
1181+
>
1182+
> See the [Custom Token Exchange documentation](https://auth0.com/docs/authenticate/custom-token-exchange) for the full setup.
1183+
1184+
### Initiator: requesting a Session Transfer Token and redirecting
1185+
1186+
The agent must be **logged in** to the initiator app: the SDK sources the actor from the agent's current session ID token by default (refreshing it if it has expired). Run your own authorization check — _is this agent allowed to impersonate this customer, right now?_ — before calling the SDK.
1187+
1188+
```ts
1189+
// In your support console's "View as customer" route (agent is already logged in):
1190+
const result = await serverClient.requestSessionTransferToken(
1191+
{
1192+
// Your own proof of which customer to impersonate — validated by your Action.
1193+
// The SDK never produces this; you supply it in whatever form your Action expects.
1194+
subjectToken,
1195+
subjectTokenType: 'urn:acme:customer-subject',
1196+
// Optional: forward custom context to your Action (e.g. an audited reason).
1197+
extra: { reason: 'Investigating TCK-4821' },
1198+
},
1199+
storeOptions
1200+
);
1201+
1202+
// Build the redirect to the TARGET app's login URL (a trusted, app-controlled value).
1203+
const url = serverClient.buildSessionTransferRedirect('https://app.example.com/auth/login', result);
1204+
1205+
// Hand the URL to your framework's redirect (e.g. Fastify: reply.redirect(url.toString())).
1206+
```
1207+
1208+
By default the actor is the agent session's ID token. To supply the acting party explicitly, pass `actor`:
1209+
1210+
```ts
1211+
const result = await serverClient.requestSessionTransferToken(
1212+
{
1213+
subjectToken,
1214+
subjectTokenType: 'urn:acme:customer-subject',
1215+
actor: { token: agentIdToken }, // type defaults to the ID token URN
1216+
},
1217+
storeOptions
1218+
);
1219+
```
1220+
1221+
If the customer belongs to an organization, forward it on the redirect so it reaches the target's `/authorize`:
1222+
1223+
```ts
1224+
const url = serverClient.buildSessionTransferRedirect('https://app.example.com/auth/login', result, {
1225+
organization: 'org_globex',
1226+
});
1227+
```
1228+
1229+
> [!IMPORTANT]
1230+
> An **actor is mandatory** for an STT — that is what makes this auditable impersonation ("X acting as Y") rather than a silent takeover. If no explicit `actor` is passed and no usable session ID token can be resolved (no logged-in agent, or an expired ID token with no refresh token), the SDK throws a `TokenExchangeError` with code `actor_unavailable` **before any network call**. The agent's session ID token must also be unexpired; the SDK refreshes it automatically when a refresh token is available.
1231+
1232+
> [!NOTE]
1233+
> `buildSessionTransferRedirect` attaches a single-use credential to the URL, so `targetLoginUrl` **must be a trusted, app-controlled value** and use `https` (`http` is allowed only for the loopback hosts `localhost`, `127.0.0.1`, and `[::1]`). Never derive it from untrusted input such as a `returnTo` parameter, or the token could leak to an attacker-controlled host.
1234+
1235+
The exchange is **stateless** — the STT is never written to the session or state store. Do not cache or persist it; hand it straight to the redirect and discard it.
1236+
1237+
### Target: redeeming the Session Transfer Token
1238+
1239+
On the target app, the STT is redeemed as part of a **standard interactive login** — you just forward `session_transfer_token` (and `organization`, when present) to `/authorize` via `startInteractiveLogin`'s `authorizationParams`:
1240+
1241+
```ts
1242+
// In the target app's login route, reading session_transfer_token from the query string:
1243+
const url = await serverClient.startInteractiveLogin(
1244+
{
1245+
authorizationParams: {
1246+
session_transfer_token: sessionTransferToken,
1247+
organization, // only when the STT was issued in an organization context
1248+
},
1249+
},
1250+
storeOptions
1251+
);
1252+
1253+
// Redirect the browser to `url`; the callback completes with a standard authorization-code login.
1254+
```
1255+
1256+
> [!NOTE]
1257+
> The `session_transfer_token` is redeemed as a **query** parameter, so the redeeming client's `session_transfer.allowed_authentication_methods` must include `"query"`. The established session is short-lived (hard-capped at 2 hours) and **cannot mint a refresh token** — to continue, re-run the whole flow. If the target needs `online_access`, set it through the SDK's `scope` configuration.
1258+
1259+
> [!NOTE]
1260+
> Because the STT travels as a query parameter, it can land in places that log or retain full URLs — web-server access logs, proxy/CDN logs, and the browser's history. This is inherent to the redemption mechanism and is mitigated by the token being **single-use and short-lived (~60s)** and, when configured, **device-bound** (`enforce_device_binding`): a leaked STT is worthless once redeemed or expired. Even so, avoid logging redemption URLs verbatim, and never persist or forward the STT beyond the immediate redirect.
1261+
1262+
### Reading the `act` claim on the impersonation session
1263+
1264+
Once the target session is established, the acting agent is available as the `act` claim on the session user — read it through the existing session surface to drive UI such as an impersonation banner:
1265+
1266+
```ts
1267+
const session = await serverClient.getSession(storeOptions);
1268+
const actor = session?.user?.act; // { sub: 'support-agent-007', ... } when impersonated
1269+
```
1270+
11621271
## Passwordless Authentication
11631272
11641273
The server client exposes passwordless as session-aware methods around the Authentication API. The surface mirrors `@auth0/nextjs-auth0` (`passwordless.start()` / `passwordless.verify()`): `startPasswordless` sends the code or link; `completePasswordless` exchanges the one-time code and persists the resulting session to the configured state store (no redirect, no PKCE). Both methods are discriminated on `connection` (`'email' | 'sms'`).

packages/auth0-server-js/src/errors.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from 'vitest';
2-
import { SessionExpiredError, StartLinkUserError } from './errors.js';
2+
import { SessionExpiredError, StartLinkUserError, TokenExchangeErrorCode } from './errors.js';
33

44
describe('StartLinkUserError', () => {
55
test('sets name and code', () => {
@@ -27,3 +27,11 @@ describe('SessionExpiredError', () => {
2727
expect(error.code).toBe('session_expired');
2828
});
2929
});
30+
31+
describe('TokenExchangeErrorCode', () => {
32+
test('exposes the Session Transfer Token codes', () => {
33+
expect(TokenExchangeErrorCode.ACTOR_UNAVAILABLE).toBe('actor_unavailable');
34+
expect(TokenExchangeErrorCode.SETACTOR_REQUIRED).toBe('setactor_required');
35+
expect(TokenExchangeErrorCode.SESSION_TRANSFER_DISABLED).toBe('session_transfer_disabled');
36+
});
37+
});

packages/auth0-server-js/src/errors.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1+
/**
2+
* Codes carried on the `code` field of a `TokenExchangeError` raised by the Session
3+
* Transfer Token (STT) flow. These are specific to Custom Token Exchange Impersonation
4+
* via Session Transfer:
5+
*
6+
* - `actor_unavailable` — raised client-side, before any network call, when a Session
7+
* Transfer Token is requested but no actor could be resolved (no explicit actor and
8+
* no usable session ID token).
9+
* - `setactor_required` — the server rejected the exchange because the Action did not
10+
* call `setActor` (an actor is mandatory for a Session Transfer Token).
11+
* - `session_transfer_disabled` — the server rejected the exchange because the tenant
12+
* feature flag is off.
13+
*
14+
* Only `actor_unavailable` is raised by the SDK itself. `setactor_required` and
15+
* `session_transfer_disabled` are surfaced from the raw server response via the
16+
* error's `cause.error` / `cause.error_description`; they are defined here as named
17+
* constants for documentation and for the day the platform returns a machine-readable
18+
* code.
19+
*/
20+
export const TokenExchangeErrorCode = {
21+
ACTOR_UNAVAILABLE: 'actor_unavailable',
22+
SETACTOR_REQUIRED: 'setactor_required',
23+
SESSION_TRANSFER_DISABLED: 'session_transfer_disabled',
24+
} as const;
25+
26+
export type TokenExchangeErrorCode = (typeof TokenExchangeErrorCode)[keyof typeof TokenExchangeErrorCode];
27+
128
/**
229
* Error thrown when there is no transaction available.
330
*/

packages/auth0-server-js/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export { StatefulStateStore } from './store/stateful-state-store.js';
1919
export type { StatefulStateStoreOptions } from './store/stateful-state-store.js';
2020
export { StatelessStateStore } from './store/stateless-state-store.js';
2121

22+
// Explicitly surface the STT error-code constant (also covered by `export * from './errors.js'`),
23+
// for parity with the explicitly re-exported error classes above.
24+
export { TokenExchangeErrorCode } from './errors.js';
25+
2226
export * from './errors.js';
2327
export * from './types.js';
2428
export * from './mfa/index.js';

0 commit comments

Comments
 (0)