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';