Skip to content
43 changes: 27 additions & 16 deletions packages/auth0-api-js/src/api-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,8 @@ test('getAccessTokenForConnection - should return a token set when the exchange
clientSecret: 'my-client-secret',
});

const newAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com');

server.use(
http.post(`https://${domain}/oauth/token`, async ({ request }) => {
const body = await request.formData();
Expand All @@ -977,7 +979,7 @@ test('getAccessTokenForConnection - should return a token set when the exchange
) {
return HttpResponse.json(
{
access_token: 'new-access-token',
access_token: newAccessToken,
expires_in: 86400,
scope: 'openid profile email',
token_type: 'Bearer',
Expand All @@ -1000,7 +1002,7 @@ test('getAccessTokenForConnection - should return a token set when the exchange
});

expect(tokenSet).toStrictEqual({
accessToken: 'new-access-token',
accessToken: newAccessToken,
expiresAt: expect.any(Number),
scope: 'openid profile email',
connection: 'my-connection',
Expand Down Expand Up @@ -1045,6 +1047,8 @@ test('getTokenByExchangeProfile - should return tokens when exchange succeeds',
clientSecret: 'my-client-secret',
});

const exchangedAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com');

server.use(
http.post(`https://${domain}/oauth/token`, async ({ request }) => {
const body = await request.formData();
Expand All @@ -1059,7 +1063,7 @@ test('getTokenByExchangeProfile - should return tokens when exchange succeeds',
) {
return HttpResponse.json(
{
access_token: 'exchanged-access-token',
access_token: exchangedAccessToken,
expires_in: 3600,
scope: 'read:data write:data',
token_type: 'Bearer',
Expand All @@ -1086,7 +1090,7 @@ test('getTokenByExchangeProfile - should return tokens when exchange succeeds',
);

expect(result).toMatchObject({
accessToken: 'exchanged-access-token',
accessToken: exchangedAccessToken,
expiresAt: expect.any(Number),
scope: 'read:data write:data',
});
Expand All @@ -1101,12 +1105,13 @@ test('getTokenByExchangeProfile - should include idToken and refreshToken when p
clientSecret: 'my-client-secret',
});
const idToken = await generateToken(domain, 'user_123', 'my-client-id');
const exchangedAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com');

server.use(
http.post(`https://${domain}/oauth/token`, async () => {
return HttpResponse.json(
{
access_token: 'exchanged-access-token',
access_token: exchangedAccessToken,
expires_in: 3600,
token_type: 'Bearer',
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',
Expand Down Expand Up @@ -1178,11 +1183,13 @@ test('getTokenByExchangeProfile - should propagate issued_token_type from token
clientSecret: 'my-client-secret',
});

const exchangedAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com');

server.use(
http.post(`https://${domain}/oauth/token`, async () => {
return HttpResponse.json(
{
access_token: 'exchanged-access-token',
access_token: exchangedAccessToken,
expires_in: 3600,
token_type: 'Bearer',
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',
Expand Down Expand Up @@ -1212,12 +1219,13 @@ test('getTokenByExchangeProfile - should include organization parameter when pro
clientSecret: 'my-client-secret',
});

const exchangedAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com');
let capturedOrganization: string | null = null;
server.use(
http.post(`https://${domain}/oauth/token`, async ({ request }) => {
const body = await request.formData();
capturedOrganization = body.get('organization') as string;

if (
body.get('grant_type') === 'urn:ietf:params:oauth:grant-type:token-exchange' &&
body.get('client_id') === 'my-client-id' &&
Expand All @@ -1229,7 +1237,7 @@ test('getTokenByExchangeProfile - should include organization parameter when pro
) {
return HttpResponse.json(
{
access_token: 'exchanged-access-token',
access_token: exchangedAccessToken,
expires_in: 3600,
scope: 'read:data write:data',
token_type: 'Bearer',
Expand Down Expand Up @@ -1258,7 +1266,7 @@ test('getTokenByExchangeProfile - should include organization parameter when pro

expect(capturedOrganization).toBe('org_abc123');
expect(result).toMatchObject({
accessToken: 'exchanged-access-token',
accessToken: exchangedAccessToken,
expiresAt: expect.any(Number),
scope: 'read:data write:data',
});
Expand All @@ -1272,20 +1280,21 @@ test('getTokenByExchangeProfile - should work without organization parameter (ba
clientSecret: 'my-client-secret',
});

const exchangedAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com');
let capturedOrganization: string | null = null;
server.use(
http.post(`https://${domain}/oauth/token`, async ({ request }) => {
const body = await request.formData();
capturedOrganization = body.get('organization') as string;

if (
body.get('grant_type') === 'urn:ietf:params:oauth:grant-type:token-exchange' &&
body.get('subject_token') === 'my-subject-token' &&
body.get('subject_token_type') === 'urn:my-company:mcp-token'
) {
return HttpResponse.json(
{
access_token: 'exchanged-access-token',
access_token: exchangedAccessToken,
expires_in: 3600,
token_type: 'Bearer',
},
Expand All @@ -1309,7 +1318,7 @@ test('getTokenByExchangeProfile - should work without organization parameter (ba
);

expect(capturedOrganization).toBeNull();
expect(result.accessToken).toBe('exchanged-access-token');
expect(result.accessToken).toBe(exchangedAccessToken);
});

test('getTokenOnBehalfOf - should throw when no clientId configured', async () => {
Expand Down Expand Up @@ -1347,6 +1356,7 @@ test('getTokenOnBehalfOf - should exchange an access token using fixed OBO token
clientSecret: 'my-client-secret',
});

const oboAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com');
let capturedOrganization: string | null = null;
server.use(
http.post(`https://${domain}/oauth/token`, async ({ request }) => {
Expand All @@ -1365,7 +1375,7 @@ test('getTokenOnBehalfOf - should exchange an access token using fixed OBO token
) {
return HttpResponse.json(
{
access_token: 'obo-access-token',
access_token: oboAccessToken,
expires_in: 3600,
scope: 'read:data write:data',
token_type: 'Bearer',
Expand All @@ -1389,7 +1399,7 @@ test('getTokenOnBehalfOf - should exchange an access token using fixed OBO token

expect(capturedOrganization).toBeNull();
expect(result).toMatchObject({
accessToken: 'obo-access-token',
accessToken: oboAccessToken,
expiresAt: expect.any(Number),
scope: 'read:data write:data',
issuedTokenType: 'urn:ietf:params:oauth:token-type:access_token',
Expand All @@ -1405,12 +1415,13 @@ test('getTokenOnBehalfOf - should not expose idToken or refreshToken', async ()
clientSecret: 'my-client-secret',
});
const idToken = await generateToken(domain, 'user_123', 'my-client-id');
const oboAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com');

server.use(
http.post(`https://${domain}/oauth/token`, async () => {
return HttpResponse.json(
{
access_token: 'obo-access-token',
access_token: oboAccessToken,
expires_in: 3600,
token_type: 'Bearer',
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',
Expand All @@ -1428,7 +1439,7 @@ test('getTokenOnBehalfOf - should not expose idToken or refreshToken', async ()

expect(result).not.toHaveProperty('idToken');
expect(result).not.toHaveProperty('refreshToken');
expect(result.accessToken).toBe('obo-access-token');
expect(result.accessToken).toBe(oboAccessToken);
});

test('getTokenOnBehalfOf - should handle exchange errors', async () => {
Expand Down
103 changes: 103 additions & 0 deletions packages/auth0-auth-js/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
- [Requesting a Login Challenge](#requesting-a-login-challenge)
- [Exchanging a Credential for Tokens](#exchanging-a-credential-for-tokens)
- [Error Handling](#error-handling)
- [Custom Token Exchange](#custom-token-exchange)
- [Basic Exchange](#basic-exchange)
- [Delegation Exchange with Actor Token](#delegation-exchange-with-actor-token)
- [Reading the act Claim](#reading-the-act-claim)
- [M2M Delegation (No ID Token)](#m2m-delegation-no-id-token)
- [Error Handling](#error-handling-1)

## Configuration

Expand Down Expand Up @@ -1072,3 +1078,100 @@ try {
}
}
```

## Custom Token Exchange

`exchangeToken` implements [RFC 8693 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) via an Auth0 Token Exchange Profile. It lets you swap a token issued by an external system (an MCP server, a legacy IdP, a partner service) for Auth0 tokens, preserving the user's identity.

### Basic Exchange

```ts
import { AuthClient } from '@auth0/auth0-auth-js';

const authClient = new AuthClient({
domain: 'your-tenant.auth0.com',
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET',
});

const tokens = await authClient.exchangeToken({
subjectToken: externalToken,
subjectTokenType: 'urn:acme:legacy-token',
audience: 'https://api.example.com',
scope: 'openid profile read:data',
});

console.log(tokens.accessToken);
```

### Delegation Exchange with Actor Token

When an intermediate service acts on behalf of a user, pass the service's own token as `actorToken`. Both `actorToken` and `actorTokenType` must be provided together.

```ts
const tokens = await authClient.exchangeToken({
subjectToken: userToken,
subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token',
actorToken: serviceAccountToken,
actorTokenType: 'urn:ietf:params:oauth:token-type:access_token',
audience: 'https://api.example.com',
});
```

### Reading the act Claim

When a delegation exchange succeeds, the `act` claim on `TokenResponse` identifies the acting party. It is sourced from the ID token when one is issued, or from the JWT access token in M2M flows where no ID token is returned.

```ts
const tokens = await authClient.exchangeToken({
subjectToken: userToken,
subjectTokenType: 'urn:acme:user-token',
actorToken: serviceToken,
actorTokenType: 'urn:acme:service-token',
audience: 'https://api.example.com',
scope: 'openid',
});

if (tokens.act) {
console.log(tokens.act.sub); // Subject of the acting party
console.log(tokens.act.iss); // Optional issuer of the actor token
}
```

### M2M Delegation (No ID Token)

In machine-to-machine flows the `openid` scope is not requested, so no ID token is issued. The SDK automatically falls back to reading the `act` claim from the JWT access token. If the access token is opaque, `act` will be `undefined`.

```ts
const tokens = await authClient.exchangeToken({
subjectToken: serviceAToken,
subjectTokenType: 'urn:acme:service-token',
actorToken: serviceBToken,
actorTokenType: 'urn:acme:service-token',
audience: 'https://api.example.com',
// no 'openid' in scope — no id_token will be returned
});

// act is populated from the JWT access token if it carries the claim
console.log(tokens.act?.sub);
```

### Error Handling

```ts
import { AuthClient, TokenExchangeError } from '@auth0/auth0-auth-js';

try {
const tokens = await authClient.exchangeToken({
subjectToken: externalToken,
subjectTokenType: 'urn:acme:legacy-token',
audience: 'https://api.example.com',
});
} catch (error) {
if (error instanceof TokenExchangeError) {
console.error(error.message); // Human-readable error message
console.error(error.code); // 'token_exchange_error'
console.error(error.cause?.error); // e.g., 'invalid_grant', 'access_denied'
}
}
```
Loading
Loading