Skip to content

Commit 520c5c0

Browse files
Fix: Token Exchange Ignoring Scope and Audience Parameters (#1365)
## Overview This PR fixes a bug in the `exchangeToken()` method where `scope` and `audience` parameters were completely ignored in HTTP requests to the `/oauth/token` endpoint. Impact: Token exchange requests would fail with authorization errors because the Auth0 backend received requests without the required `audience` and `scope` parameters. ## Root Cause The bug was in `src/api.ts` where `audience` and `scope` were removed from the options object even in the case of TokenExchange (where it's needed): ```typescript export async function oauthToken({ baseUrl, timeout, audience, scope, auth0Client, useFormData, ...options }: TokenEndpointOptions) { const body = useFormData ? createQueryParams(options) // Missing audience & scope in case of tokenExchange : JSON.stringify(options); // Missing audience & scope in case of tokenExchange } ``` ## Changes - `src/api.ts`: Now properly includes `audience` and `scope` in request body, if the grant_type of the request is ```urn:ietf:params:oauth:grant-type:token-exchange``` - `src/Auth0Client.ts`: Changed from always using client defaults to respecting user-provided values - `src/TokenExchange.ts`: Changed `audience: string` to `audience?: string` to properly reflect fallback behavior - `EXAMPLES.md`: Added comprehensive token exchange examples showing both default and custom audience usage - Added tests for the original bug and tests to ensure that Access Token Descoping does not happen. ## Reproduction Steps Code: ```typescript const result = await auth0.exchangeToken({ subject_token: 'external-token', subject_token_type: 'urn:custom:token-type', audience: 'https://target-api.com', // Ignored before scope: 'read:data write:data' // Ignored before }); ``` HTTP Request Body (Before): ``` grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange &subject_token=external-token &subject_token_type=urn%3Acustom%3Atoken-type &client_id=your-client-id // audience and scope completely missing ``` HTTP Request Body (After): ``` grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange &subject_token=external-token &subject_token_type=urn%3Acustom%3Atoken-type &audience=https%3A//target-api.com &scope=openid%20profile%20read%3Adata%20write%3Adata &client_id=your-client-id ``` ## Breaking Changes **None** - This is a bug fix that restores intended functionality without changing the public API.
1 parent 9437965 commit 520c5c0

File tree

6 files changed

+494
-10
lines changed

6 files changed

+494
-10
lines changed

EXAMPLES.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,20 @@ const auth0 = await createAuth0Client({
245245
// Exchange external token for Auth0 tokens
246246
async function performTokenExchange() {
247247
try {
248+
// Option 1: Use client's default audience
248249
const tokenResponse = await auth0.exchangeToken({
249250
subject_token: 'EXTERNAL_PROVIDER_TOKEN',
250251
subject_token_type: 'urn:example:external-token',
251252
scope: 'openid profile email'
253+
// audience will default to audience from client config
254+
});
255+
256+
// Option 2: Specify custom audience for this token exchange
257+
const customTokenResponse = await auth0.exchangeToken({
258+
subject_token: 'EXTERNAL_PROVIDER_TOKEN',
259+
subject_token_type: 'urn:example:external-token',
260+
audience: 'https://different-api.example.com',
261+
scope: 'openid profile read:records'
252262
});
253263

254264
console.log('Received tokens:', tokenResponse);

__tests__/Auth0Client/exchangeToken.test.ts

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,220 @@ describe('Auth0Client', () => {
107107
audience: 'https://api.test.com'
108108
};
109109
const result = await auth0.exchangeToken(cteOptions);
110-
console.log(result);
111110
expect(result.id_token).toEqual('fake_id_token');
112111
expect(result.access_token).toEqual('fake_access_token');
113112
expect(result.expires_in).toEqual(3600);
114113
expect(typeof result.scope).toBe('string');
115114
});
115+
116+
it('passes the correct scope and audience parameters to _requestToken', async () => {
117+
const auth0 = await localSetup({
118+
clientId: 'test-client-id',
119+
domain: 'test.auth0.com',
120+
authorizationParams: {
121+
audience: 'https://default-api.com', // client default audience
122+
scope: 'openid profile' // client default scope
123+
}
124+
});
125+
126+
// Mock _requestToken to capture the parameters
127+
let capturedRequestOptions: any;
128+
auth0['_requestToken'] = async function (requestOptions: any) {
129+
capturedRequestOptions = requestOptions;
130+
return {
131+
decodedToken: {
132+
encoded: {
133+
header: 'fake_header',
134+
payload: 'fake_payload',
135+
signature: 'fake_signature'
136+
},
137+
header: {},
138+
claims: { __raw: 'fake_raw' },
139+
user: {}
140+
},
141+
id_token: 'fake_id_token',
142+
access_token: 'fake_access_token',
143+
expires_in: 3600,
144+
scope: requestOptions.scope
145+
};
146+
};
147+
148+
const cteOptions: CustomTokenExchangeOptions = {
149+
subject_token: 'external_token_value',
150+
subject_token_type: 'urn:acme:legacy-system-token',
151+
scope: 'openid profile email read:records', // custom scope for token exchange
152+
audience: 'https://api.custom.com' // custom audience for token exchange
153+
};
154+
155+
await auth0.exchangeToken(cteOptions);
156+
157+
// Verify the parameters passed to _requestToken
158+
expect(capturedRequestOptions).toEqual({
159+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
160+
subject_token: 'external_token_value',
161+
subject_token_type: 'urn:acme:legacy-system-token',
162+
scope: 'openid profile email read:records', // Should use the custom scope
163+
audience: 'https://api.custom.com' // Should use the custom audience, NOT the client default
164+
});
165+
});
166+
167+
it('handles undefined scope correctly', async () => {
168+
const auth0 = await localSetup({
169+
clientId: 'test-client-id',
170+
domain: 'test.auth0.com',
171+
authorizationParams: {
172+
audience: 'https://default-api.com',
173+
scope: 'openid profile' // client default scope
174+
}
175+
});
176+
177+
let capturedRequestOptions: any;
178+
auth0['_requestToken'] = async function (requestOptions: any) {
179+
capturedRequestOptions = requestOptions;
180+
return {
181+
decodedToken: {
182+
encoded: {
183+
header: 'fake_header',
184+
payload: 'fake_payload',
185+
signature: 'fake_signature'
186+
},
187+
header: {},
188+
claims: { __raw: 'fake_raw' },
189+
user: {}
190+
},
191+
id_token: 'fake_id_token',
192+
access_token: 'fake_access_token',
193+
expires_in: 3600,
194+
scope: requestOptions.scope
195+
};
196+
};
197+
198+
const cteOptions: CustomTokenExchangeOptions = {
199+
subject_token: 'external_token_value',
200+
subject_token_type: 'urn:acme:legacy-system-token',
201+
audience: 'https://api.custom.com'
202+
// scope is undefined - should use client default
203+
};
204+
205+
await auth0.exchangeToken(cteOptions);
206+
207+
// When scope is undefined, should fallback to client default scope
208+
expect(capturedRequestOptions.scope).toEqual('openid profile');
209+
expect(capturedRequestOptions.audience).toEqual('https://api.custom.com');
210+
});
211+
212+
it('handles undefined audience correctly by falling back to client default', async () => {
213+
const auth0 = await localSetup({
214+
clientId: 'test-client-id',
215+
domain: 'test.auth0.com',
216+
authorizationParams: {
217+
audience: 'https://default-api.com', // client default audience
218+
scope: 'openid profile' // client default scope
219+
}
220+
});
221+
222+
let capturedRequestOptions: any;
223+
auth0['_requestToken'] = async function (requestOptions: any) {
224+
capturedRequestOptions = requestOptions;
225+
return {
226+
decodedToken: {
227+
encoded: {
228+
header: 'fake_header',
229+
payload: 'fake_payload',
230+
signature: 'fake_signature'
231+
},
232+
header: {},
233+
claims: { __raw: 'fake_raw' },
234+
user: {}
235+
},
236+
id_token: 'fake_id_token',
237+
access_token: 'fake_access_token',
238+
expires_in: 3600,
239+
scope: requestOptions.scope
240+
};
241+
};
242+
243+
const cteOptions: CustomTokenExchangeOptions = {
244+
subject_token: 'external_token_value',
245+
subject_token_type: 'urn:acme:legacy-system-token',
246+
scope: 'openid profile email read:records'
247+
// audience is undefined - should use client default
248+
};
249+
250+
await auth0.exchangeToken(cteOptions);
251+
252+
// When audience is undefined, should fallback to client default audience
253+
expect(capturedRequestOptions.scope).toEqual(
254+
'openid profile email read:records'
255+
);
256+
expect(capturedRequestOptions.audience).toEqual(
257+
'https://default-api.com'
258+
);
259+
});
260+
261+
it('demonstrates how wrong audience causes scope-related issues', async () => {
262+
const auth0 = await localSetup({
263+
clientId: 'test-client-id',
264+
domain: 'test.auth0.com',
265+
authorizationParams: {
266+
audience: 'https://basic-api.com', // API that only supports basic scopes
267+
scope: 'openid profile'
268+
}
269+
});
270+
271+
// Simulate Auth0's behavior: wrong audience = scope restrictions
272+
let capturedRequestOptions: any;
273+
auth0['_requestToken'] = async function (requestOptions: any) {
274+
capturedRequestOptions = requestOptions;
275+
276+
// Simulate what happens in Auth0 when audience/scope mismatch:
277+
if (
278+
requestOptions.audience === 'https://basic-api.com' &&
279+
requestOptions.scope.includes('read:sensitive')
280+
) {
281+
// Auth0 would return an error like this:
282+
throw new Error(
283+
'invalid_scope: Scope "read:sensitive" is not authorized for audience "https://basic-api.com"'
284+
);
285+
}
286+
287+
return {
288+
decodedToken: {
289+
encoded: {
290+
header: 'fake_header',
291+
payload: 'fake_payload',
292+
signature: 'fake_signature'
293+
},
294+
header: {},
295+
claims: { __raw: 'fake_raw' },
296+
user: {}
297+
},
298+
id_token: 'fake_id_token',
299+
access_token: 'fake_access_token',
300+
expires_in: 3600,
301+
scope: requestOptions.scope
302+
};
303+
};
304+
305+
const cteOptions: CustomTokenExchangeOptions = {
306+
subject_token: 'external_token_value',
307+
subject_token_type: 'urn:acme:legacy-system-token',
308+
audience: 'https://sensitive-api.com', // This is what user WANTS
309+
scope: 'openid profile read:sensitive' // Scope valid for sensitive-api
310+
};
311+
312+
// Before the fix: audience would be wrong (https://basic-api.com)
313+
// This would cause scope error because basic-api doesn't support read:sensitive
314+
315+
await auth0.exchangeToken(cteOptions);
316+
317+
// After the fix: correct audience is used, avoiding scope errors
318+
expect(capturedRequestOptions.audience).toEqual(
319+
'https://sensitive-api.com'
320+
);
321+
expect(capturedRequestOptions.scope).toEqual(
322+
'openid profile read:sensitive'
323+
);
324+
});
116325
});
117326
});

0 commit comments

Comments
 (0)