Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: `
<button (click)="handleExchange()">Exchange Token</button>
<div *ngIf="tokens">Token exchange successful!</div>
<div *ngIf="error">Error: {{ error }}</div>
`,
})
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:
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
108 changes: 108 additions & 0 deletions projects/auth0-angular/src/lib/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;

Expand Down
44 changes: 44 additions & 0 deletions projects/auth0-angular/src/lib/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
CustomFetchMinimalOutput,
Fetcher,
FetcherConfig,
CustomTokenExchangeOptions,
TokenEndpointResponse,
} from '@auth0/auth0-spa-js';

import {
Expand Down Expand Up @@ -319,6 +321,48 @@ export class AuthService<TAppState extends AppState = AppState>
);
}

/**
* ```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<TokenEndpointResponse> {
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 => ...)
Expand Down
2 changes: 2 additions & 0 deletions projects/auth0-angular/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,6 @@ export {
FetcherConfig,
CustomFetchMinimalOutput,
UseDpopNonceError,
CustomTokenExchangeOptions,
TokenEndpointResponse,
} from '@auth0/auth0-spa-js';