diff --git a/packages/auth0-api-js/src/api-client.spec.ts b/packages/auth0-api-js/src/api-client.spec.ts index 5015920..294ed3a 100644 --- a/packages/auth0-api-js/src/api-client.spec.ts +++ b/packages/auth0-api-js/src/api-client.spec.ts @@ -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(); @@ -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', @@ -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', @@ -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(); @@ -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', @@ -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', }); @@ -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', @@ -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', @@ -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' && @@ -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', @@ -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', }); @@ -1272,12 +1280,13 @@ 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' && @@ -1285,7 +1294,7 @@ test('getTokenByExchangeProfile - should work without organization parameter (ba ) { return HttpResponse.json( { - access_token: 'exchanged-access-token', + access_token: exchangedAccessToken, expires_in: 3600, token_type: 'Bearer', }, @@ -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 () => { @@ -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 }) => { @@ -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', @@ -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', @@ -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', @@ -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 () => { diff --git a/packages/auth0-auth-js/EXAMPLES.md b/packages/auth0-auth-js/EXAMPLES.md index 2e0608b..87174a1 100644 --- a/packages/auth0-auth-js/EXAMPLES.md +++ b/packages/auth0-auth-js/EXAMPLES.md @@ -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 @@ -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' + } +} +``` diff --git a/packages/auth0-auth-js/src/auth-client.spec.ts b/packages/auth0-auth-js/src/auth-client.spec.ts index 93a92a9..17d55d5 100644 --- a/packages/auth0-auth-js/src/auth-client.spec.ts +++ b/packages/auth0-auth-js/src/auth-client.spec.ts @@ -2910,6 +2910,181 @@ describe('exchangeToken with Token Exchange Profile', () => { }); }); +describe('exchangeToken — actor token support', () => { + const baseOptions = { + subjectToken: 'subject-token-123', + subjectTokenType: 'urn:acme:custom-token', + audience: 'https://api.example.com', + }; + + test('should send actor_token and actor_token_type when provided', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + let capturedActorToken: string | null = null; + let capturedActorTokenType: string | null = null; + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async ({ request }) => { + const info = await request.formData(); + capturedActorToken = info.get('actor_token') as string; + capturedActorTokenType = info.get('actor_token_type') as string; + return HttpResponse.json({ + access_token: accessToken, + id_token: await generateToken(domain, 'user_cte', '', undefined, undefined, undefined, { + act: { sub: 'service-account-123' }, + }), + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:default', + }); + }) + ); + + const result = await authClient.exchangeToken({ + ...baseOptions, + actorToken: 'actor-token-abc', + actorTokenType: 'urn:acme:actor-token', + }); + + expect(capturedActorToken).toBe('actor-token-abc'); + expect(capturedActorTokenType).toBe('urn:acme:actor-token'); + expect(result.act).toEqual({ sub: 'service-account-123' }); + }); + + test('should expose act claim from id_token on TokenResponse', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async () => { + return HttpResponse.json({ + access_token: accessToken, + id_token: await generateToken(domain, 'user_cte', '', undefined, undefined, undefined, { + act: { sub: 'actor-sub-456', iss: 'https://actor.example.com' }, + }), + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:default', + }); + }) + ); + + const result = await authClient.exchangeToken(baseOptions); + + expect(result.act).toBeDefined(); + expect(result.act?.sub).toBe('actor-sub-456'); + expect(result.act?.iss).toBe('https://actor.example.com'); + }); + + test('should not send actor_token or actor_token_type when omitted', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + let hasActorToken = false; + let hasActorTokenType = false; + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async ({ request }) => { + const info = await request.formData(); + hasActorToken = info.has('actor_token'); + hasActorTokenType = info.has('actor_token_type'); + return HttpResponse.json({ + access_token: accessToken, + id_token: await generateToken(domain, 'user_cte', ''), + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:default', + }); + }) + ); + + const result = await authClient.exchangeToken(baseOptions); + + expect(hasActorToken).toBe(false); + expect(hasActorTokenType).toBe(false); + expect(result.act).toBeUndefined(); + }); + + test('should throw TokenExchangeError when actorToken is provided without actorTokenType', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + await expect( + authClient.exchangeToken({ ...baseOptions, actorToken: 'actor-token-abc' }) + ).rejects.toMatchObject({ + name: 'TokenExchangeError', + code: 'token_exchange_error', + message: 'actorTokenType is required when actorToken is provided', + }); + }); + + test('should not set act when access token is opaque and no id_token is returned', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async () => { + return HttpResponse.json({ + access_token: 'opaque-access-token-string', + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:data', + }); + }) + ); + + const result = await authClient.exchangeToken(baseOptions); + + expect(result.act).toBeUndefined(); + }); + + test('should prefer act from id_token over act from access token when both are present', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + const accessTokenWithAct = await generateToken(domain, 'user_cte', 'https://api.example.com', undefined, undefined, undefined, { + act: { sub: 'at-actor' }, + }); + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async () => { + return HttpResponse.json({ + access_token: accessTokenWithAct, + id_token: await generateToken(domain, 'user_cte', '', undefined, undefined, undefined, { + act: { sub: 'id-token-actor' }, + }), + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:default', + }); + }) + ); + + const result = await authClient.exchangeToken(baseOptions); + + expect(result.act?.sub).toBe('id-token-actor'); + }); + + test('should expose act claim from access token when no id_token is returned (M2M delegation)', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + const accessTokenWithAct = await generateToken(domain, 'user_cte', 'https://api.example.com', undefined, undefined, undefined, { + act: { sub: 'service-account-123' }, + }); + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async () => { + return HttpResponse.json({ + access_token: accessTokenWithAct, + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:data', + // no id_token — M2M flow, openid scope not requested + }); + }) + ); + + const result = await authClient.exchangeToken(baseOptions); + + expect(result.act).toBeDefined(); + expect(result.act?.sub).toBe('service-account-123'); + }); + +}); + describe('Telemetry', () => { test('should include Auth0-Client header in discovery requests', async () => { let capturedHeader: string | null = null; diff --git a/packages/auth0-auth-js/src/auth-client.ts b/packages/auth0-auth-js/src/auth-client.ts index 336f98c..905f7fe 100644 --- a/packages/auth0-auth-js/src/auth-client.ts +++ b/packages/auth0-auth-js/src/auth-client.ts @@ -1,5 +1,5 @@ import * as client from 'openid-client'; -import { createRemoteJWKSet, importPKCS8, jwtVerify, customFetch, jwksCache } from 'jose'; +import { createRemoteJWKSet, importPKCS8, jwtVerify, customFetch, jwksCache, decodeJwt } from 'jose'; import type { JWKSCacheInput } from 'jose'; import { BackchannelAuthenticationError, @@ -40,6 +40,7 @@ import { TokenByRefreshTokenOptions, TokenForConnectionOptions, TokenResponse, + ActClaim, VerifyLogoutTokenOptions, VerifyLogoutTokenResult, } from './types.js'; @@ -77,8 +78,9 @@ const MAX_ARRAY_VALUES_PER_KEY = 20; * credentials must be managed through configuration, not request parameters * - subject_token, subject_token_type: Core token exchange parameters, overriding creates * ambiguity about which token is being exchanged + * - actor_token, actor_token_type: Actor token parameters for delegation exchanges, must use + * explicit typed parameters to ensure correct delegation semantics * - requested_token_type: Determines the type of token returned, must be explicit - * - actor_token, actor_token_type: Delegation parameters that affect authorization context * - audience, aud, resource, resources, resource_indicator: Target API parameters must use * explicit API parameters to prevent confusion about precedence and ensure correct routing * - scope: Overriding via extras bypasses the explicit scope parameter and creates ambiguity @@ -758,6 +760,10 @@ export class AuthClient { validateSubjectToken(options.subjectToken); + if (options.actorToken !== undefined && options.actorTokenType === undefined) { + throw new TokenExchangeError('actorTokenType is required when actorToken is provided'); + } + const tokenRequestParams = new URLSearchParams({ subject_token_type: options.subjectTokenType, subject_token: options.subjectToken, @@ -775,6 +781,10 @@ export class AuthClient { if (options.organization) { tokenRequestParams.append('organization', options.organization); } + if (options.actorToken) { + tokenRequestParams.append('actor_token', options.actorToken); + tokenRequestParams.append('actor_token_type', options.actorTokenType!); + } appendExtraParams(tokenRequestParams, options.extra); @@ -785,7 +795,18 @@ export class AuthClient { tokenRequestParams ); - return TokenResponse.fromTokenEndpointResponse(tokenEndpointResponse); + const tokenResponse = TokenResponse.fromTokenEndpointResponse(tokenEndpointResponse); + const idTokenClaims = tokenEndpointResponse.id_token ? tokenEndpointResponse.claims() : undefined; + if (idTokenClaims?.act) { + tokenResponse.act = idTokenClaims.act as ActClaim; + } else { + try { + tokenResponse.act = decodeJwt(tokenEndpointResponse.access_token).act as ActClaim | undefined; + } catch { + // opaque access token — act claim not available + } + } + return tokenResponse; } catch (e) { throw new TokenExchangeError( `Failed to exchange token of type '${options.subjectTokenType}'${options.audience ? ` for audience '${options.audience}'` : ''}.`, diff --git a/packages/auth0-auth-js/src/mfa/mfa-client.spec.ts b/packages/auth0-auth-js/src/mfa/mfa-client.spec.ts index 7ea05cf..55a3008 100644 --- a/packages/auth0-auth-js/src/mfa/mfa-client.spec.ts +++ b/packages/auth0-auth-js/src/mfa/mfa-client.spec.ts @@ -495,16 +495,24 @@ describe('MfaClient', () => { describe('verify', () => { let idToken: string; + let mfaAccessToken: string; + let oobAccessToken: string; + let recoveryAccessToken: string; + let genericAccessToken: string; beforeAll(async () => { idToken = await generateToken(domain, 'user|123', clientId); + mfaAccessToken = await generateToken(domain, 'user|123', clientId); + oobAccessToken = await generateToken(domain, 'user|123', clientId); + recoveryAccessToken = await generateToken(domain, 'user|123', clientId); + genericAccessToken = await generateToken(domain, 'user|123', clientId); }); test('should verify OTP and return TokenResponse', async () => { server.use( http.post(`https://${domain}/oauth/token`, async () => HttpResponse.json({ - access_token: 'mfa_access_token', + access_token: mfaAccessToken, id_token: idToken, refresh_token: 'mfa_refresh_token', token_type: 'Bearer', @@ -517,7 +525,7 @@ describe('MfaClient', () => { const client = new MfaClient({ domain, clientId, getConfiguration: makeGetConfiguration(domain, clientId) }); const result = await client.verify({ mfaToken, factorType: 'otp', otp: '123456' }); - expect(result.accessToken).toBe('mfa_access_token'); + expect(result.accessToken).toBe(mfaAccessToken); expect(result.idToken).toBe(idToken); expect(result.refreshToken).toBe('mfa_refresh_token'); expect(result.tokenType).toBe('bearer'); @@ -530,7 +538,7 @@ describe('MfaClient', () => { server.use( http.post(`https://${domain}/oauth/token`, async () => HttpResponse.json({ - access_token: 'oob_access_token', + access_token: oobAccessToken, id_token: idToken, token_type: 'Bearer', expires_in: 86400, @@ -541,14 +549,14 @@ describe('MfaClient', () => { const client = new MfaClient({ domain, clientId, getConfiguration: makeGetConfiguration(domain, clientId) }); const result = await client.verify({ mfaToken, factorType: 'oob', oobCode: 'oob_123' }); - expect(result.accessToken).toBe('oob_access_token'); + expect(result.accessToken).toBe(oobAccessToken); }); test('should verify recovery-code and set recoveryCode on TokenResponse', async () => { server.use( http.post(`https://${domain}/oauth/token`, async () => HttpResponse.json({ - access_token: 'recovery_access_token', + access_token: recoveryAccessToken, id_token: idToken, token_type: 'Bearer', expires_in: 86400, @@ -560,7 +568,7 @@ describe('MfaClient', () => { const client = new MfaClient({ domain, clientId, getConfiguration: makeGetConfiguration(domain, clientId) }); const result = await client.verify({ mfaToken, factorType: 'recovery-code', recoveryCode: 'OLD_CODE' }); - expect(result.accessToken).toBe('recovery_access_token'); + expect(result.accessToken).toBe(recoveryAccessToken); expect(result.recoveryCode).toBe('NEW_RECOVERY_CODE'); }); @@ -571,7 +579,7 @@ describe('MfaClient', () => { http.post(`https://${domain}/oauth/token`, async ({ request }) => { capturedBody = await request.formData(); return HttpResponse.json({ - access_token: 'token', + access_token: genericAccessToken, token_type: 'Bearer', expires_in: 86400, }); @@ -592,7 +600,7 @@ describe('MfaClient', () => { server.use( http.post(`https://${domain}/oauth/token`, async ({ request }) => { capturedBody = await request.formData(); - return HttpResponse.json({ access_token: 'token', token_type: 'Bearer', expires_in: 86400 }); + return HttpResponse.json({ access_token: genericAccessToken, token_type: 'Bearer', expires_in: 86400 }); }) ); @@ -610,7 +618,7 @@ describe('MfaClient', () => { server.use( http.post(`https://${domain}/oauth/token`, async ({ request }) => { capturedBody = await request.formData(); - return HttpResponse.json({ access_token: 'token', token_type: 'Bearer', expires_in: 86400 }); + return HttpResponse.json({ access_token: genericAccessToken, token_type: 'Bearer', expires_in: 86400 }); }) ); diff --git a/packages/auth0-auth-js/src/types.ts b/packages/auth0-auth-js/src/types.ts index 20ffa2b..6de257f 100644 --- a/packages/auth0-auth-js/src/types.ts +++ b/packages/auth0-auth-js/src/types.ts @@ -388,6 +388,32 @@ export interface ExchangeProfileOptions { */ organization?: string; + /** + * The actor token to include in the delegation exchange (RFC 8693). + * + * When provided, identifies the acting party (the intermediate service or agent) + * on whose behalf the exchange is being performed. The resulting token will carry + * an `act` claim describing the actor. + * + * Must be used together with `actorTokenType`. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc8693#section-2.1 RFC 8693 Section 2.1} + */ + actorToken?: string; + + /** + * A URI that identifies the type of the actor token (RFC 8693). + * + * Must be a syntactically valid URI. Reserved namespaces are validated by the + * Auth0 platform, not the SDK. + * + * Must be used together with `actorToken`. + * + * @example "urn:acme:actor-token" + * @example "http://acme.com/service-token" + */ + actorTokenType?: string; + /** * Additional custom parameters accessible in Auth0 Actions via event.request.body. * @@ -534,6 +560,23 @@ export interface AuthorizationDetails { readonly [parameter: string]: unknown; } +/** + * Represents the `act` (actor) claim in a token response (RFC 8693). + * + * Present when a token was issued via a delegation exchange, identifying the + * acting party (e.g., an intermediate service) that performed the exchange on + * behalf of the subject. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc8693#section-4.1 RFC 8693 Section 4.1} + */ +export interface ActClaim { + /** + * The subject identifier of the actor. + */ + sub: string; + [key: string]: unknown; +} + /** * Represents a successful token response from Auth0. * @@ -589,6 +632,18 @@ export class TokenResponse { */ recoveryCode?: string; + /** + * The actor claim from a delegation token exchange (RFC 8693). + * + * Present when an `actorToken` was provided. Sourced from the ID token when + * one is issued, or from the JWT access token in M2M flows where no ID token + * is returned. Identifies the acting party on whose behalf the subject token + * was exchanged. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc8693#section-4.1 RFC 8693 Section 4.1} + */ + act?: ActClaim; + constructor( accessToken: string, expiresAt: number,