diff --git a/EXAMPLES.md b/EXAMPLES.md
index a7d66e8c..90cb2e03 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -6,6 +6,7 @@
- [Display the user profile](#display-the-user-profile)
- [Protect a route](#protect-a-route)
- [Call an API](#call-an-api)
+- [Custom token exchange](#custom-token-exchange)
- [Wrapping the interceptor for granular control](#wrapping-the-interceptor-for-granular-control)
- [Handling errors](#handling-errors)
- [Organizations](#organizations)
@@ -291,6 +292,62 @@ AuthModule.forRoot({
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.
+## Custom token exchange
+
+Exchange an external subject token for Auth0 tokens and establish an authenticated session using the token exchange flow (RFC 8693):
+
+```ts
+import { Component } from '@angular/core';
+import { AuthService, CustomTokenExchangeOptions } from '@auth0/auth0-angular';
+
+@Component({
+ selector: 'app-token-exchange',
+ template: `
+
+
Token exchange successful!
+
Error: {{ error }}
+ `,
+})
+export class TokenExchangeComponent {
+ tokens: any = null;
+ error: string | null = null;
+
+ constructor(private auth: AuthService) {}
+
+ handleExchange() {
+ const options: CustomTokenExchangeOptions = {
+ subject_token: 'your-external-token',
+ subject_token_type: 'urn:your-company:legacy-system-token',
+ audience: 'https://api.example.com/',
+ scope: 'openid profile email',
+ };
+
+ this.auth.loginWithCustomTokenExchange(options).subscribe({
+ next: (tokenResponse) => {
+ this.tokens = tokenResponse;
+ this.error = null;
+
+ // Use the returned tokens
+ console.log('Access Token:', tokenResponse.access_token);
+ console.log('ID Token:', tokenResponse.id_token);
+ },
+ error: (err) => {
+ console.error('Token exchange failed:', err);
+ this.error = err.message;
+ },
+ });
+ }
+}
+```
+
+**Important Notes:**
+
+- The `subject_token_type` must be a namespaced URI under your organization's control
+- The external token must be validated in Auth0 Actions using strong cryptographic verification
+- This method implements RFC 8693 token exchange grant type
+- The audience and scope can be provided directly in the options or will fall back to SDK defaults
+- **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
+
## Wrapping the interceptor for granular control
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:
diff --git a/package-lock.json b/package-lock.json
index dce5c802..9e302bae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,7 @@
"@angular/platform-browser": "^19.2.18",
"@angular/platform-browser-dynamic": "^19.2.18",
"@angular/router": "^19.2.18",
- "@auth0/auth0-spa-js": "^2.13.1",
+ "@auth0/auth0-spa-js": "^2.14.0",
"rxjs": "^6.6.7",
"tslib": "^2.8.1",
"zone.js": "~0.15.1"
@@ -806,9 +806,9 @@
}
},
"node_modules/@auth0/auth0-spa-js": {
- "version": "2.13.1",
- "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.13.1.tgz",
- "integrity": "sha512-H9N4QjBO8Dxr9hWT9NsAn60pPDGJy4gW5GKdYLpn4M33GocmrxoZ5wfYh99mMObZj3Ww4HiTyauNT2HGr9mx/A==",
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.14.0.tgz",
+ "integrity": "sha512-2XGd3j7SCTMWBTCAU6Xk9ZtQxcgz9mjMs28t0BMv3y1GfoO7qA9VAgElYb52CyCeiTGlOYAVZFsioojFdRwxcA==",
"license": "MIT",
"dependencies": {
"@auth0/auth0-auth-js": "^1.4.0",
diff --git a/package.json b/package.json
index 51f9848d..acbe05d2 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,7 @@
"@angular/platform-browser": "^19.2.18",
"@angular/platform-browser-dynamic": "^19.2.18",
"@angular/router": "^19.2.18",
- "@auth0/auth0-spa-js": "^2.13.1",
+ "@auth0/auth0-spa-js": "^2.14.0",
"rxjs": "^6.6.7",
"tslib": "^2.8.1",
"zone.js": "~0.15.1"
diff --git a/projects/auth0-angular/src/lib/auth.service.spec.ts b/projects/auth0-angular/src/lib/auth.service.spec.ts
index 428685c9..64588ccc 100644
--- a/projects/auth0-angular/src/lib/auth.service.spec.ts
+++ b/projects/auth0-angular/src/lib/auth.service.spec.ts
@@ -77,6 +77,13 @@ describe('AuthService', () => {
.spyOn(auth0Client, 'getTokenWithPopup')
.mockResolvedValue('__access_token_from_popup__');
+ jest.spyOn(auth0Client, 'loginWithCustomTokenExchange').mockResolvedValue({
+ access_token: '__exchanged_access_token__',
+ id_token: '__exchanged_id_token__',
+ token_type: 'Bearer',
+ expires_in: 86400,
+ });
+
jest
.spyOn(auth0Client, 'getDpopNonce')
.mockResolvedValue('test-nonce-value');
@@ -922,6 +929,107 @@ describe('AuthService', () => {
});
});
+ describe('loginWithCustomTokenExchange', () => {
+ it('should call the underlying SDK', (done) => {
+ const service = createService();
+ const options = {
+ subject_token: '__test_token__',
+ subject_token_type: 'urn:test:token-type',
+ };
+
+ service
+ .loginWithCustomTokenExchange(options)
+ .subscribe((tokenResponse) => {
+ expect(auth0Client.loginWithCustomTokenExchange).toHaveBeenCalledWith(
+ options
+ );
+ done();
+ });
+ });
+
+ it('should return the token response', (done) => {
+ const service = createService();
+ const options = {
+ subject_token: '__test_token__',
+ subject_token_type: 'urn:test:token-type',
+ scope: 'openid profile email',
+ };
+
+ service
+ .loginWithCustomTokenExchange(options)
+ .subscribe((tokenResponse) => {
+ expect(tokenResponse).toEqual({
+ access_token: '__exchanged_access_token__',
+ id_token: '__exchanged_id_token__',
+ token_type: 'Bearer',
+ expires_in: 86400,
+ });
+ done();
+ });
+ });
+
+ it('should update auth state after successful token exchange', (done) => {
+ const service = createService();
+ const options = {
+ subject_token: '__test_token__',
+ subject_token_type: 'urn:test:token-type',
+ };
+
+ jest.spyOn(authState, 'setAccessToken');
+
+ service.loginWithCustomTokenExchange(options).subscribe(() => {
+ expect(authState.setAccessToken).toHaveBeenCalledWith(
+ '__exchanged_access_token__'
+ );
+ done();
+ });
+ });
+
+ it('should record errors in the error$ observable', (done) => {
+ const errorObj = new Error('Token exchange failed');
+
+ (
+ auth0Client.loginWithCustomTokenExchange as unknown as jest.SpyInstance
+ ).mockRejectedValue(errorObj);
+
+ const service = createService();
+ service
+ .loginWithCustomTokenExchange({
+ subject_token: '__test_token__',
+ subject_token_type: 'urn:test:token-type',
+ })
+ .subscribe({
+ error: () => {},
+ });
+
+ service.error$.subscribe((err: Error) => {
+ expect(err).toBe(errorObj);
+ done();
+ });
+ });
+
+ it('should bubble errors', (done) => {
+ const errorObj = new Error('Token exchange failed');
+
+ (
+ auth0Client.loginWithCustomTokenExchange as unknown as jest.SpyInstance
+ ).mockRejectedValue(errorObj);
+
+ const service = createService();
+ service
+ .loginWithCustomTokenExchange({
+ subject_token: '__test_token__',
+ subject_token_type: 'urn:test:token-type',
+ })
+ .subscribe({
+ error: (err: Error) => {
+ expect(err).toBe(errorObj);
+ done();
+ },
+ });
+ });
+ });
+
describe('handleRedirectCallback', () => {
let navigator: AbstractNavigator;
diff --git a/projects/auth0-angular/src/lib/auth.service.ts b/projects/auth0-angular/src/lib/auth.service.ts
index 6e13a845..a691a792 100644
--- a/projects/auth0-angular/src/lib/auth.service.ts
+++ b/projects/auth0-angular/src/lib/auth.service.ts
@@ -13,6 +13,8 @@ import {
CustomFetchMinimalOutput,
Fetcher,
FetcherConfig,
+ CustomTokenExchangeOptions,
+ TokenEndpointResponse,
} from '@auth0/auth0-spa-js';
import {
@@ -319,6 +321,48 @@ export class AuthService
);
}
+ /**
+ * ```js
+ * loginWithCustomTokenExchange(options).subscribe(tokenResponse => ...)
+ * ```
+ *
+ * Exchanges an external subject token for Auth0 tokens and establishes an authenticated session.
+ *
+ * This method implements the token exchange grant as specified in RFC 8693.
+ * It performs a token exchange by sending a request to the `/oauth/token` endpoint
+ * with the external token and returns Auth0 tokens (access token, ID token, etc.).
+ *
+ * The request includes the following parameters:
+ * - `grant_type`: Hard-coded to "urn:ietf:params:oauth:grant-type:token-exchange"
+ * - `subject_token`: The external token to be exchanged
+ * - `subject_token_type`: A namespaced URI identifying the token type (must be under your organization's control)
+ * - `audience`: The target audience (falls back to the SDK's default audience if not provided)
+ * - `scope`: Space-separated list of scopes (merged with the SDK's default scopes)
+ *
+ * After a successful token exchange, this method updates the authentication state
+ * to ensure consistency with the standard authentication flows.
+ *
+ * @param options The options required to perform the token exchange
+ * @returns An Observable that emits the token endpoint response containing Auth0 tokens
+ */
+ loginWithCustomTokenExchange(
+ options: CustomTokenExchangeOptions
+ ): Observable {
+ return of(this.auth0Client).pipe(
+ concatMap((client) => client.loginWithCustomTokenExchange(options)),
+ tap((tokenResponse) => {
+ if (tokenResponse.access_token) {
+ this.authState.setAccessToken(tokenResponse.access_token);
+ }
+ }),
+ catchError((error) => {
+ this.authState.setError(error);
+ this.authState.refresh();
+ return throwError(error);
+ })
+ );
+ }
+
/**
* ```js
* handleRedirectCallback(url).subscribe(result => ...)
diff --git a/projects/auth0-angular/src/public-api.ts b/projects/auth0-angular/src/public-api.ts
index f02da255..e4d14964 100644
--- a/projects/auth0-angular/src/public-api.ts
+++ b/projects/auth0-angular/src/public-api.ts
@@ -38,4 +38,6 @@ export {
FetcherConfig,
CustomFetchMinimalOutput,
UseDpopNonceError,
+ CustomTokenExchangeOptions,
+ TokenEndpointResponse,
} from '@auth0/auth0-spa-js';