Skip to content

feat(sessionTransferToken): add CTE impersonation via Session Transfer Token#850

Open
cschetan77 wants to merge 15 commits into
masterfrom
feat/session-transfer-token
Open

feat(sessionTransferToken): add CTE impersonation via Session Transfer Token#850
cschetan77 wants to merge 15 commits into
masterfrom
feat/session-transfer-token

Conversation

@cschetan77

@cschetan77 cschetan77 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Custom Token Exchange (CTE) — Impersonation via Session Transfer.

This extends the existing req.oidc.customTokenExchange surface with two new methods that implement the initiator role of the session transfer flow: an application requests a short-lived, single-use Session Transfer Token (STT) via CTE, then redirects the user's browser to a target app's login URL carrying that STT, which is redeemed at /authorize to establish an ephemeral web session as the subject user with the actor recorded in the act claim.

New methods on req.oidc

requestSessionTransferToken(options)

Performs a token exchange against the urn:{domain}:session_transfer audience and returns a SessionTransferTokenResult. The SDK handles the protocol plumbing automatically:

  • Builds the audience from the configured issuerBaseURL hostname
  • Sources the actor_token from the current session id_token, checking the id_token's own exp claim (with a 30s skew). If expired and a refresh token is available, the session is refreshed automatically; if the refresh returns no new id_token, raises actor_unavailable. If expired with no refresh token, raises actor_unavailable before any network call. An explicit actor_token override is accepted to bypass session sourcing entirely.
  • Raises actor_unavailable (HTTP 400) client-side before any network call when no actor can be resolved.
  • Validates that the AS response carries issued_token_type === "urn:auth0:params:oauth:token-type:session_transfer_token" — an unexpected or missing value raises HTTP 500 (invalid_token_response).
  • The returned STT is never written to the session — it is a one-shot handle to be forwarded immediately.
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));
});

buildSessionTransferRedirect(targetLoginUrl, result, opts?)

Pure URL builder — appends session_transfer_token (and optionally organization) as query parameters to targetLoginUrl. Makes no network call and writes nothing to the session. The caller passes the returned string to res.redirect().

Security: targetLoginUrl must be a trusted, app-controlled value — never derive it from untrusted input (e.g. 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] for local development; any other scheme or non-loopback http: host throws a TypeError.

Target side (no SDK changes needed)

The existing login path already forwards authorizationParams to /authorize, so redeeming an inbound STT requires no new SDK code:

app.get('/login', (req, res) =>
  res.oidc.login({
    authorizationParams: {
      session_transfer_token: req.query.session_transfer_token,
    },
  })
);

New TypeScript types

  • SessionTransferTokenResult — result type returned by requestSessionTransferToken
  • SessionTransferTokenOptions — input options for requestSessionTransferToken
  • SessionTransferRedirectOptions — options for buildSessionTransferRedirect

Error codes

Code HTTP Where When
actor_unavailable 400 client-side No actor passed and no usable session id_token (not logged in, expired with no refresh token, refresh returned no new id_token, or no id_token in session)
setactor_required 400 server CTE Action did not call setActor
session_transfer_disabled 400 server Tenant feature flag is off
invalid_token_response 500 client-side AS returned an unexpected or missing issued_token_type

Implementation notes

  • Fully additive — no existing types, methods, or behavior changed. All existing customTokenExchange tests continue to pass.
  • Reuses validateSubjectToken, validateTokenExchangeExtras, TOKEN_EXCHANGE_GRANT_TYPE, and the genericGrantRequest CTE machinery.
  • isIdTokenExpired(exp) added to lib/utils/sessionExpiry.js, reusing the existing SESSION_EXPIRY_LEEWAY (30s skew). Guards against non-numeric exp (fails closed — treats as expired).
  • SESSION_TRANSFER_TOKEN_IDENTIFIER defined as an internal constant in lib/context.js — not exported; the URN is documented in EXAMPLES.md and the type declarations for developers who need to branch on issued_token_type.

Test plan

  • 35 tests in test/sessionTransferToken.tests.js covering: actor resolution (session sourcing, explicit override, blank actor, no session, no id_token in session, expired id_token with and without refresh token, refresh returning no new id_token, non-numeric exp), subject_token validation, audience construction, optional params (scope, organization, extra), result shape (STT in session_transfer_token, no access_token, no act), issued_token_type validation at exchange (unexpected and missing both throw 500), no-persistence guarantee, server error code mapping, and buildSessionTransferRedirect URL construction (including https enforcement and all three loopback variants).
  • All existing tests pass without modification.

@cschetan77
cschetan77 requested a review from a team as a code owner July 9, 2026 04:16
Comment thread index.d.ts Outdated
Comment thread lib/context.js
Comment thread lib/context.js Outdated
Comment thread lib/context.js

let redirectUrl;
try {
redirectUrl = new URL(targetLoginUrl);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

new URL(targetLoginUrl) accepts any scheme, so an http: (or javascript:/data:) target parses fine and the STT gets appended as a query param. The JSDoc already says to use https: in production, and since this helper is the one place that hardening can live, an http:/https: allowlist (with a localhost exception for dev) would make that guidance actually hold.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. buildSessionTransferRedirect now rejects any targetLoginUrl that isn't https:, with an exception for http: on localhost, 127.0.0.1, and [::1] to support local development. Any other scheme (http: on a non-loopback host, ftp:, javascript:, data:, etc.) throws a TypeError. Aligns with the same check in auth0-server-js.
Five tests added covering the rejection cases and all three loopback variants.

Comment thread lib/utils/sessionExpiry.js
Comment thread lib/context.js Outdated
Comment thread EXAMPLES.md
…SessionTransferRedirect

Adds CTE Phase 2 — Impersonation via Session Transfer — to the SDK.

- req.oidc.requestSessionTransferToken({ subject_token, subject_token_type, ... })
  performs a token exchange against urn:{domain}:session_transfer, automatically
  sourcing the actor from the agent's session id_token (refreshed if expired).
  Returns a SessionTransferTokenResult. Never writes the STT to the session.

- req.oidc.buildSessionTransferRedirect(targetLoginUrl, result, opts?)
  builds the redirect URL with session_transfer_token (and optional organization)
  as query params. Pure helper — no network call, no session writes.

New TypeScript types: SessionTransferTokenResult, SessionTransferTokenOptions,
SessionTransferRedirectOptions. New error codes surfaced via existing createError:
actor_unavailable (client-side), setactor_required and session_transfer_disabled
(server 400s).
…alidation and types

- Validate issued_token_type matches the STT URN before accepting a result — prevents
  accidentally passing a regular customTokenExchange response
- Add typeof check on session_transfer_token to catch non-string values
- Switch all buildSessionTransferRedirect throws from createError to TypeError — this
  is a pure synchronous helper, not an HTTP operation
- Use global URL (WHATWG) instead of url.URL in both STT methods
- Tighten SessionTransferTokenOptions docs: remove SDK-internal wording, keep general
- Add https recommendation to buildSessionTransferRedirect doc comment
…ry check, empty issued_token_type fallback, extra params over reason
@cschetan77
cschetan77 force-pushed the feat/session-transfer-token branch from 7e98028 to 5d5ff81 Compare July 22, 2026 08:26
…clarations

SESSION_TRANSFER_TOKEN_IDENTIFIER and ID_TOKEN_IDENTIFIER were exported
from index.d.ts but never from the runtime entry, so importing them would
yield undefined at runtime. Both were tagged @internal and have no
consumer use case — removed from the type declarations entirely.
…s no new id_token

After a successful refresh, re-check the id_token expiry before using it
as the actor_token. If the refresh grant returned no new id_token the old
expired token is kept in the session and would be rejected server-side on
the exp check. Adds a test covering this path.
…t at redirect

Check that the AS response carries the STT URN immediately after the
exchange succeeds. A missing or unexpected issued_token_type now throws
500 (valid upstream response, unexpected payload) rather than silently
returning a result that buildSessionTransferRedirect would reject later.
Refactored the try/catch to only wrap the network call so the validation
sits clearly outside it.
Reject any targetLoginUrl that is not https, except http on localhost,
127.0.0.1, and [::1] for local development. The STT is a single-use
credential — appending it to an http or non-http/https URL would expose
it over an insecure or untrusted channel. Aligns with auth0-server-js.
…meric exp

A missing or malformed exp claim (undefined, NaN, string) evaluated as
false in the <= comparison, treating the token as unexpired and forwarding
it as the actor token. Added a typeof + isFinite guard so any non-numeric
exp is treated as expired, triggering a refresh or actor_unavailable.
If the stored id_token is present but undecodable, ts.claims() returns
undefined and destructuring exp would throw a TypeError (500) instead of
staying within the documented actor_unavailable 400 path. The || {}
fallback makes exp undefined, which isIdTokenExpired now treats as
expired, triggering refresh or actor_unavailable as expected.
…ken_type branching note

Two developer-facing gaps in the EXAMPLES.md guide:
- Explicit actor_token must be unexpired and RS256/PS256 signed; HS256
  and expired tokens are rejected server-side.
- Branch on issued_token_type, not token_type — token_type is "N_A" for
  STT responses and buildSessionTransferRedirect requires the exact URN.
…oken test, update types

- Remove duplicate 'strips protocol' audience test — identical assertion
  to the preceding 'sets audience' test
- Add test for actor_unavailable when session exists but has no id_token
- Update index.d.ts: document HTTP 500 invalid_token_response error and
  make https enforcement explicit (not just advisory) in JSDoc
Comment thread EXAMPLES.md

> **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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants