Skip to content

Commit 3f853a7

Browse files
feat: add loginWithCustomTokenExchange method
Adds `loginWithCustomTokenExchange()` method following the PRD naming convention for Custom Token Exchange operations, implementing the spa-js 2.14.0 feature. Changes: - Added `loginWithCustomTokenExchange()` method to AuthService - Exchanges external tokens and establishes authenticated session - Bumped @auth0/auth0-spa-js from 2.13.0 to 2.14.0 - Added comprehensive tests (5 new tests) - Updated EXAMPLES.md with new method usage Implementation follows RFC 8693 token exchange grant type.
1 parent a0658ee commit 3f853a7

File tree

6 files changed

+218
-7
lines changed

6 files changed

+218
-7
lines changed

EXAMPLES.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [Display the user profile](#display-the-user-profile)
77
- [Protect a route](#protect-a-route)
88
- [Call an API](#call-an-api)
9+
- [Custom token exchange](#custom-token-exchange)
910
- [Wrapping the interceptor for granular control](#wrapping-the-interceptor-for-granular-control)
1011
- [Handling errors](#handling-errors)
1112
- [Organizations](#organizations)
@@ -291,6 +292,62 @@ AuthModule.forRoot({
291292
292293
You might want to do this in scenarios where you need the token on multiple endpoints, but want to exclude it from only a few other endpoints. Instead of explicitly listing all endpoints that do need a token, a uriMatcher can be used to include all but the few endpoints that do not need a token attached to its requests.
293294
295+
## Custom token exchange
296+
297+
Exchange an external subject token for Auth0 tokens and establish an authenticated session using the token exchange flow (RFC 8693):
298+
299+
```ts
300+
import { Component } from '@angular/core';
301+
import { AuthService, CustomTokenExchangeOptions } from '@auth0/auth0-angular';
302+
303+
@Component({
304+
selector: 'app-token-exchange',
305+
template: `
306+
<button (click)="handleExchange()">Exchange Token</button>
307+
<div *ngIf="tokens">Token exchange successful!</div>
308+
<div *ngIf="error">Error: {{ error }}</div>
309+
`,
310+
})
311+
export class TokenExchangeComponent {
312+
tokens: any = null;
313+
error: string | null = null;
314+
315+
constructor(private auth: AuthService) {}
316+
317+
handleExchange() {
318+
const options: CustomTokenExchangeOptions = {
319+
subject_token: 'your-external-token',
320+
subject_token_type: 'urn:your-company:legacy-system-token',
321+
audience: 'https://api.example.com/',
322+
scope: 'openid profile email',
323+
};
324+
325+
this.auth.loginWithCustomTokenExchange(options).subscribe({
326+
next: (tokenResponse) => {
327+
this.tokens = tokenResponse;
328+
this.error = null;
329+
330+
// Use the returned tokens
331+
console.log('Access Token:', tokenResponse.access_token);
332+
console.log('ID Token:', tokenResponse.id_token);
333+
},
334+
error: (err) => {
335+
console.error('Token exchange failed:', err);
336+
this.error = err.message;
337+
},
338+
});
339+
}
340+
}
341+
```
342+
343+
**Important Notes:**
344+
345+
- The `subject_token_type` must be a namespaced URI under your organization's control
346+
- The external token must be validated in Auth0 Actions using strong cryptographic verification
347+
- This method implements RFC 8693 token exchange grant type
348+
- The audience and scope can be provided directly in the options or will fall back to SDK defaults
349+
- **State Management:** This method updates the SDK's authentication state after a successful exchange, ensuring that `isLoading$`, `isAuthenticated$`, and `user$` observables behave identically to the standard `getAccessTokenSilently()` flow
350+
294351
## Wrapping the interceptor for granular control
295352

296353
While the `allowedList` configuration and `uriMatcher` provide flexible ways to control which requests receive access tokens, there may be scenarios where you need even more granular control on a per-request basis. For example:

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"@angular/platform-browser": "^19.2.18",
3333
"@angular/platform-browser-dynamic": "^19.2.18",
3434
"@angular/router": "^19.2.18",
35-
"@auth0/auth0-spa-js": "^2.13.1",
35+
"@auth0/auth0-spa-js": "^2.14.0",
3636
"rxjs": "^6.6.7",
3737
"tslib": "^2.8.1",
3838
"zone.js": "~0.15.1"

projects/auth0-angular/src/lib/auth.service.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ describe('AuthService', () => {
7777
.spyOn(auth0Client, 'getTokenWithPopup')
7878
.mockResolvedValue('__access_token_from_popup__');
7979

80+
jest.spyOn(auth0Client, 'loginWithCustomTokenExchange').mockResolvedValue({
81+
access_token: '__exchanged_access_token__',
82+
id_token: '__exchanged_id_token__',
83+
token_type: 'Bearer',
84+
expires_in: 86400,
85+
});
86+
8087
jest
8188
.spyOn(auth0Client, 'getDpopNonce')
8289
.mockResolvedValue('test-nonce-value');
@@ -922,6 +929,107 @@ describe('AuthService', () => {
922929
});
923930
});
924931

932+
describe('loginWithCustomTokenExchange', () => {
933+
it('should call the underlying SDK', (done) => {
934+
const service = createService();
935+
const options = {
936+
subject_token: '__test_token__',
937+
subject_token_type: 'urn:test:token-type',
938+
};
939+
940+
service
941+
.loginWithCustomTokenExchange(options)
942+
.subscribe((tokenResponse) => {
943+
expect(auth0Client.loginWithCustomTokenExchange).toHaveBeenCalledWith(
944+
options
945+
);
946+
done();
947+
});
948+
});
949+
950+
it('should return the token response', (done) => {
951+
const service = createService();
952+
const options = {
953+
subject_token: '__test_token__',
954+
subject_token_type: 'urn:test:token-type',
955+
scope: 'openid profile email',
956+
};
957+
958+
service
959+
.loginWithCustomTokenExchange(options)
960+
.subscribe((tokenResponse) => {
961+
expect(tokenResponse).toEqual({
962+
access_token: '__exchanged_access_token__',
963+
id_token: '__exchanged_id_token__',
964+
token_type: 'Bearer',
965+
expires_in: 86400,
966+
});
967+
done();
968+
});
969+
});
970+
971+
it('should update auth state after successful token exchange', (done) => {
972+
const service = createService();
973+
const options = {
974+
subject_token: '__test_token__',
975+
subject_token_type: 'urn:test:token-type',
976+
};
977+
978+
jest.spyOn(authState, 'setAccessToken');
979+
980+
service.loginWithCustomTokenExchange(options).subscribe(() => {
981+
expect(authState.setAccessToken).toHaveBeenCalledWith(
982+
'__exchanged_access_token__'
983+
);
984+
done();
985+
});
986+
});
987+
988+
it('should record errors in the error$ observable', (done) => {
989+
const errorObj = new Error('Token exchange failed');
990+
991+
(
992+
auth0Client.loginWithCustomTokenExchange as unknown as jest.SpyInstance
993+
).mockRejectedValue(errorObj);
994+
995+
const service = createService();
996+
service
997+
.loginWithCustomTokenExchange({
998+
subject_token: '__test_token__',
999+
subject_token_type: 'urn:test:token-type',
1000+
})
1001+
.subscribe({
1002+
error: () => {},
1003+
});
1004+
1005+
service.error$.subscribe((err: Error) => {
1006+
expect(err).toBe(errorObj);
1007+
done();
1008+
});
1009+
});
1010+
1011+
it('should bubble errors', (done) => {
1012+
const errorObj = new Error('Token exchange failed');
1013+
1014+
(
1015+
auth0Client.loginWithCustomTokenExchange as unknown as jest.SpyInstance
1016+
).mockRejectedValue(errorObj);
1017+
1018+
const service = createService();
1019+
service
1020+
.loginWithCustomTokenExchange({
1021+
subject_token: '__test_token__',
1022+
subject_token_type: 'urn:test:token-type',
1023+
})
1024+
.subscribe({
1025+
error: (err: Error) => {
1026+
expect(err).toBe(errorObj);
1027+
done();
1028+
},
1029+
});
1030+
});
1031+
});
1032+
9251033
describe('handleRedirectCallback', () => {
9261034
let navigator: AbstractNavigator;
9271035

projects/auth0-angular/src/lib/auth.service.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
CustomFetchMinimalOutput,
1414
Fetcher,
1515
FetcherConfig,
16+
CustomTokenExchangeOptions,
17+
TokenEndpointResponse,
1618
} from '@auth0/auth0-spa-js';
1719

1820
import {
@@ -319,6 +321,48 @@ export class AuthService<TAppState extends AppState = AppState>
319321
);
320322
}
321323

324+
/**
325+
* ```js
326+
* loginWithCustomTokenExchange(options).subscribe(tokenResponse => ...)
327+
* ```
328+
*
329+
* Exchanges an external subject token for Auth0 tokens and establishes an authenticated session.
330+
*
331+
* This method implements the token exchange grant as specified in RFC 8693.
332+
* It performs a token exchange by sending a request to the `/oauth/token` endpoint
333+
* with the external token and returns Auth0 tokens (access token, ID token, etc.).
334+
*
335+
* The request includes the following parameters:
336+
* - `grant_type`: Hard-coded to "urn:ietf:params:oauth:grant-type:token-exchange"
337+
* - `subject_token`: The external token to be exchanged
338+
* - `subject_token_type`: A namespaced URI identifying the token type (must be under your organization's control)
339+
* - `audience`: The target audience (falls back to the SDK's default audience if not provided)
340+
* - `scope`: Space-separated list of scopes (merged with the SDK's default scopes)
341+
*
342+
* After a successful token exchange, this method updates the authentication state
343+
* to ensure consistency with the standard authentication flows.
344+
*
345+
* @param options The options required to perform the token exchange
346+
* @returns An Observable that emits the token endpoint response containing Auth0 tokens
347+
*/
348+
loginWithCustomTokenExchange(
349+
options: CustomTokenExchangeOptions
350+
): Observable<TokenEndpointResponse> {
351+
return of(this.auth0Client).pipe(
352+
concatMap((client) => client.loginWithCustomTokenExchange(options)),
353+
tap((tokenResponse) => {
354+
if (tokenResponse.access_token) {
355+
this.authState.setAccessToken(tokenResponse.access_token);
356+
}
357+
}),
358+
catchError((error) => {
359+
this.authState.setError(error);
360+
this.authState.refresh();
361+
return throwError(error);
362+
})
363+
);
364+
}
365+
322366
/**
323367
* ```js
324368
* handleRedirectCallback(url).subscribe(result => ...)

projects/auth0-angular/src/public-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,6 @@ export {
3838
FetcherConfig,
3939
CustomFetchMinimalOutput,
4040
UseDpopNonceError,
41+
CustomTokenExchangeOptions,
42+
TokenEndpointResponse,
4143
} from '@auth0/auth0-spa-js';

0 commit comments

Comments
 (0)