Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
258a3d2
feat(sessionTransferToken): add requestSessionTransferToken and build…
cschetan77 Jul 9, 2026
8910d46
refactor(sessionTransferToken): harden buildSessionTransferRedirect v…
cschetan77 Jul 13, 2026
9813acb
refactor(sessionTransferToken): remove agent-specific language from e…
cschetan77 Jul 13, 2026
4583411
fix(sessionTransferToken): align with auth0-server-js — id_token expi…
cschetan77 Jul 13, 2026
4b3319f
docs(sessionTransferToken): add Impersonation via Session Transfer To…
cschetan77 Jul 13, 2026
5d5ff81
fix(tests): clean up leaked nock interceptor after httpTimeout test t…
cschetan77 Jul 13, 2026
dc799d2
revert(tests): remove redundant nock.cleanAll() — flaky CI test fixed…
cschetan77 Jul 22, 2026
db42b17
fix(types): remove @internal token-type constants from public type de…
cschetan77 Jul 22, 2026
5534b36
fix(sessionTransferToken): fail actor_unavailable when refresh return…
cschetan77 Jul 23, 2026
824fd6a
fix(sessionTransferToken): validate issued_token_type at exchange, no…
cschetan77 Jul 23, 2026
5de6dad
fix(sessionTransferToken): enforce https in buildSessionTransferRedirect
cschetan77 Jul 23, 2026
8ba821a
fix(sessionTransferToken): fail closed in isIdTokenExpired for non-nu…
cschetan77 Jul 23, 2026
a5d5cde
fix(sessionTransferToken): guard against ts.claims() returning undefined
cschetan77 Jul 23, 2026
50520ab
docs(sessionTransferToken): add actor_token constraints and issued_to…
cschetan77 Jul 23, 2026
2561c33
fix(tests/types): remove duplicate audience test, add missing no-id_t…
cschetan77 Jul 23, 2026
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
123 changes: 119 additions & 4 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
10. [Use a custom session store](#10-use-a-custom-session-store)
11. [Back-Channel Logout](#11-back-channel-logout)
12. [Custom Token Exchange](#12-custom-token-exchange)
13. [Use a proxy for OIDC requests](#13-use-a-proxy-for-oidc-requests)
14. [Session expiry from upstream IdP (IPSIE `session_expiry`)](#14-session-expiry-from-upstream-idp-ipsie-session_expiry)
13. [Impersonation via Session Transfer Token](#13-impersonation-via-session-transfer-token)
14. [Use a proxy for OIDC requests](#14-use-a-proxy-for-oidc-requests)
15. [Session expiry from upstream IdP (IPSIE `session_expiry`)](#15-session-expiry-from-upstream-idp-ipsie-session_expiry)

## 1. Basic setup

Expand Down Expand Up @@ -450,7 +451,121 @@ const tokenSet = await req.oidc.customTokenExchange({
});
```

## 13. Use a proxy for OIDC requests
## 13. Impersonation via Session Transfer Token

Custom Token Exchange Impersonation via Session Transfer lets a support or admin application log a user into a target web application as a customer — so a support engineer can reproduce the customer's exact experience without knowing their password. The agent is recorded in the `act` claim on the impersonated session, making every impersonation auditable.

The flow involves two roles:

- **Initiator** — your support/admin app. It requests a short-lived, single-use Session Transfer Token (STT) and redirects the user's browser to the target app carrying the STT.
- **Target** — the customer's web app. It forwards the STT to `/authorize`, where Auth0 redeems it and establishes a session as the customer.

> **Prerequisites (configured via Management API / Dashboard):**
>
> - Feature flag `cte_session_transfer_token` must be enabled on the tenant.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can avoid calling out the flag name as it's an internal detail

> - The issuing (initiator) client needs `can_create_session_transfer_token: true` and a Custom Token Exchange Action that calls `setActor`.
> - The redeeming (target) client needs `allow_delegated_access: true` and `"query"` in its allowed authentication methods.

### Initiator: requesting an STT and redirecting

The user must be logged in to the initiator app — the SDK sources the actor from the session's ID token by default (refreshing it automatically if expired). Run your own authorization check before calling the SDK.

```js
const { auth, requiresAuth } = require('express-openid-connect');

app.post('/impersonate', requiresAuth(), async (req, res, next) => {
try {
// Your own proof of which customer to impersonate — validated by your Action.
const { customerToken, customerTokenType } = req.body;

const result = await req.oidc.requestSessionTransferToken({
subject_token: customerToken,
subject_token_type: customerTokenType,
// Optional: pass custom context to your Action via event.request.body
extra: { reason: 'Investigating ticket TCK-1234' },
});

// targetLoginUrl must be a trusted, app-controlled value — never derived from
// untrusted input such as a returnTo param, or the STT could leak to an attacker host.
const redirectUrl = req.oidc.buildSessionTransferRedirect(
'https://app.example.com/auth/login',
result,
);

res.redirect(redirectUrl);
} catch (err) {
// err.error === 'actor_unavailable' — user not logged in or session expired
// err.error === 'setactor_required' — Action did not call setActor
// err.error === 'session_transfer_disabled' — tenant feature flag is off
next(err);
}
});
```

The STT is opaque, single-use, and lives ~60 seconds. The SDK never stores it — hand it straight to `buildSessionTransferRedirect` and discard it.
Comment thread
kishore7snehil marked this conversation as resolved.

> **Branch on `result.issued_token_type`**, not `result.token_type`. The `token_type` field is `"N_A"` for an STT response — it is informational only. `issued_token_type` is always `"urn:auth0:params:oauth:token-type:session_transfer_token"` for a successful STT exchange, and `buildSessionTransferRedirect` requires exactly that value.

If the customer belongs to an organization, forward it on the redirect:

```js
const redirectUrl = req.oidc.buildSessionTransferRedirect(
'https://app.example.com/auth/login',
result,
{ organization: 'org_globex' },
);
```

To supply the acting party explicitly instead of using the session ID token:

```js
const result = await req.oidc.requestSessionTransferToken({
subject_token: customerToken,
subject_token_type: customerTokenType,
actor_token: agentIdToken,
actor_token_type: 'urn:ietf:params:oauth:token-type:id_token',
});
```

> **Explicit `actor_token` requirements:** Auth0 validates the actor token server-side before running the exchange. It must be unexpired and signed with an asymmetric algorithm (RS256 or PS256) — an HS256 token or an expired token will be rejected. An Auth0 session ID token already satisfies both requirements; if you source `actor_token` from elsewhere, ensure it meets them.

### Target: redeeming the STT

On the target app, forward the `session_transfer_token` query parameter (and `organization` when present) to `/authorize` through the existing `res.oidc.login()` call — no new SDK methods required:

```js
app.get('/auth/login', async (req, res) => {
const authorizationParams = {};

if (req.query.session_transfer_token) {
authorizationParams.session_transfer_token =
req.query.session_transfer_token;
}
if (req.query.organization) {
authorizationParams.organization = req.query.organization;
}

await res.oidc.login({ authorizationParams });
});
```

The established session is short-lived (hard-capped at 2 hours) and cannot mint a refresh token.

### Reading the `act` claim

Once the impersonation session is established, the acting party is available as `req.oidc.user.act`:

```js
app.get('/dashboard', requiresAuth(), (req, res) => {
const actor = req.oidc.user.act; // { sub: 'support-agent-007' } when impersonated
if (actor) {
// Render an impersonation banner so the agent knows they are acting as the customer.
}
res.render('dashboard');
});
```

## 14. Use a proxy for OIDC requests

If you need to route all OIDC HTTP requests (discovery, token, userinfo, etc.) through a proxy, use the `customFetch` option with `undici`'s `ProxyAgent`:

Expand All @@ -473,7 +588,7 @@ app.use(

The SDK wraps your `customFetch` function to add required headers (User-Agent, Auth0-Client telemetry) before making requests.

## 14. Session expiry from upstream IdP (IPSIE `session_expiry`)
## 15. Session expiry from upstream IdP (IPSIE `session_expiry`)

When an upstream IdP supports the IPSIE SL1 spec, it can include a `session_expiry` claim in the ID token — an absolute Unix timestamp (seconds) marking the latest moment the IdP considers the session valid.

Expand Down
118 changes: 118 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,68 @@ interface TokenExchangeResponse {
[key: string]: unknown;
}

/**
* Result of a successful {@link RequestContext.requestSessionTransferToken} call.
* The STT is opaque, single-use, and expires in ~60 seconds.
* Pass it directly to {@link RequestContext.buildSessionTransferRedirect} — never decode or store it.
*/
interface SessionTransferTokenResult {
/** The opaque session transfer token. Hand to `buildSessionTransferRedirect`. */
session_transfer_token: string;
/**
* The issued token type URN. Always `"urn:auth0:params:oauth:token-type:session_transfer_token"`.
*/
issued_token_type: string;
/** Token lifetime in seconds (~60). */
expires_in: number;
/** Informational only — typically `"N_A"`. */
token_type?: string;
/** Granted scopes, if returned by the authorization server. */
scope?: string;
}

/**
* Options for {@link RequestContext.requestSessionTransferToken}.
*/
interface SessionTransferTokenOptions {
/**
* The token identifying the subject of the exchange. Validated by your CTE Action.
*/
subject_token: string;
/**
* URI identifying the type of `subject_token`.
*/
subject_token_type: string;
/**
* The acting party's token. Omit to use the current session's id_token (default).
* When provided, `actor_token_type` defaults to `urn:ietf:params:oauth:token-type:id_token`.
*/
actor_token?: string;
/**
* URI identifying the type of `actor_token`.
* Defaults to `urn:ietf:params:oauth:token-type:id_token` when `actor_token` is provided.
*/
actor_token_type?: string;
/** Organization ID or name; forwarded to `/authorize` by `buildSessionTransferRedirect`. */
organization?: string;
/** Scopes for the established session's tokens. */
scope?: string;
/**
* Additional parameters forwarded to the token endpoint and available in your CTE Action
* via `event.request.body` (e.g. `{ reason: 'Investigating TCK-1234' }`).
* Cannot override reserved OAuth parameters.
*/
extra?: Record<string, string | string[] | number | boolean>;
}

/**
* Options for {@link RequestContext.buildSessionTransferRedirect}.
*/
interface SessionTransferRedirectOptions {
/** Organization to append to the redirect URL (when the STT is org-scoped). */
organization?: string;
}

/**
* The request authentication context found on the Express request when
* OpenID Connect auth middleware is added to your application.
Expand Down Expand Up @@ -295,6 +357,62 @@ interface RequestContext {
customTokenExchange?: (
options?: CustomTokenExchangeOptions,
) => Promise<TokenExchangeResponse>;

/**
* Requests a Session Transfer Token (STT) for impersonation via session transfer.
*
* Performs a CTE call against the `urn:{domain}:session_transfer` audience.
* The agent's session id_token is used as the actor automatically (refreshed if expired);
* pass `actor_token` to override.
*
* The returned STT is opaque and single-use (~60s). Pass it to
* `buildSessionTransferRedirect` — never decode or store it.
*
* ```js
* app.post('/impersonate', requiresAuth(), async (req, res) => {
* const result = await req.oidc.requestSessionTransferToken({
* subject_token: req.body.customerToken,
* subject_token_type: 'urn:mycompany:customer-subject',
* extra: { reason: 'Investigating ticket TCK-1234' },
* });
* res.redirect(req.oidc.buildSessionTransferRedirect('https://app.example.com/login', result));
* });
* ```
*
* **Errors thrown:**
* - `error: 'actor_unavailable'` (HTTP 400) — no actor resolved (agent not authenticated or session expired with no refresh token)
* - `error: 'setactor_required'` (HTTP 400) — CTE Action did not call `setActor`
* - `error: 'session_transfer_disabled'` (HTTP 400) — tenant feature flag is off
* - `error: 'invalid_token_response'` (HTTP 500) — AS returned an unexpected `issued_token_type` (not the STT URN)
*/
requestSessionTransferToken?: (
options: SessionTransferTokenOptions,
) => Promise<SessionTransferTokenResult>;

/**
* Builds the redirect URL that hands the STT to the target app's login endpoint.
*
* Returns `targetLoginUrl?session_transfer_token=<encoded>[&organization=…]`.
* The developer passes the returned URL to `res.redirect()`.
*
* ```js
* const url = req.oidc.buildSessionTransferRedirect('https://app.example.com/login', result, {
* organization: 'org_globex',
* });
* res.redirect(url);
* ```
*
* The `targetLoginUrl` must be a trusted, app-controlled value — never derive it
* from untrusted input such as a query parameter, as the STT would be forwarded to
* an attacker-controlled host. The URL must use `https:` (`http:` is accepted only
* for `localhost`, `127.0.0.1`, and `[::1]` to support local development); any other
* scheme or non-loopback `http:` host throws a `TypeError`.
*/
buildSessionTransferRedirect?: (
targetLoginUrl: string,
result: SessionTransferTokenResult,
opts?: SessionTransferRedirectOptions,
) => string;
}

/**
Expand Down
Loading
Loading