Skip to content

Commit d89a5cd

Browse files
feat: add connected accounts callback handling
- Add ConnectedAccount type and update AppState interface - Export ResponseType, ConnectError, and ConnectAccountRedirectResult - Update handleRedirectCallback to extract connected account data - Add connect_code parameter detection in shouldHandleCallback - Add comprehensive test coverage for callback handling - Update documentation with connected accounts usage examples
1 parent 1541968 commit d89a5cd

File tree

5 files changed

+154
-4
lines changed

5 files changed

+154
-4
lines changed

EXAMPLES.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -908,6 +908,20 @@ this.auth
908908
.subscribe();
909909
```
910910

911+
When the redirect completes, the user will be returned to the application and the tokens from the third party Identity Provider will be stored in the Token Vault. You can access the connected account details via the `appState$` observable:
912+
913+
```ts
914+
ngOnInit() {
915+
this.auth.appState$.subscribe((appState) => {
916+
if (appState.connectedAccount) {
917+
console.log(`You've connected to ${appState.connectedAccount.connection}`);
918+
// Handle the connected account details
919+
// appState.connectedAccount contains: id, connection, access_type, created_at, expires_at
920+
}
921+
});
922+
}
923+
```
924+
911925
You can now call the API with your access token and the API can use [Access Token Exchange with Token Vault](https://auth0.com/docs/secure/tokens/token-vault/access-token-exchange-with-token-vault) to get tokens from the Token Vault to access third party APIs on behalf of the user.
912926
913927
> **Important**

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
CacheLocation,
44
GetTokenSilentlyOptions,
55
ICache,
6+
ConnectAccountRedirectResult,
7+
ResponseType,
68
} from '@auth0/auth0-spa-js';
79

810
import { InjectionToken, Injectable, Optional, Inject } from '@angular/core';
@@ -136,8 +138,17 @@ export interface AuthConfig extends Auth0ClientOptions {
136138
errorPath?: string;
137139
}
138140

141+
/**
142+
* The account that has been connected during the connect flow.
143+
*/
144+
export type ConnectedAccount = Omit<
145+
ConnectAccountRedirectResult,
146+
'appState' | 'response_type'
147+
>;
148+
139149
/**
140150
* Angular specific state to be stored before redirect
151+
* and any account that the user may have connected to.
141152
*/
142153
export interface AppState {
143154
/**
@@ -146,6 +157,17 @@ export interface AppState {
146157
*/
147158
target?: string;
148159

160+
/**
161+
* The connected account information when the user has completed
162+
* a connect account flow.
163+
*/
164+
connectedAccount?: ConnectedAccount;
165+
166+
/**
167+
* The response type returned from the authentication server.
168+
*/
169+
response_type?: ResponseType;
170+
149171
/**
150172
* Any custom parameter to be stored in appState
151173
*/

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

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { fakeAsync, TestBed } from '@angular/core/testing';
22
import { AuthService } from './auth.service';
33
import { Auth0ClientService } from './auth.client';
4-
import { Auth0Client, IdToken } from '@auth0/auth0-spa-js';
4+
import {
5+
Auth0Client,
6+
IdToken,
7+
ResponseType,
8+
ConnectAccountRedirectResult,
9+
} from '@auth0/auth0-spa-js';
510
import { AbstractNavigator } from './abstract-navigator';
611
import {
712
bufferCount,
@@ -60,6 +65,7 @@ describe('AuthService', () => {
6065

6166
jest.spyOn(auth0Client, 'handleRedirectCallback').mockResolvedValue({
6267
appState: undefined,
68+
response_type: ResponseType.Code,
6369
} as any);
6470
jest.spyOn(auth0Client, 'loginWithRedirect').mockResolvedValue();
6571
jest.spyOn(auth0Client, 'connectAccountWithRedirect').mockResolvedValue();
@@ -513,6 +519,16 @@ describe('AuthService', () => {
513519
});
514520
});
515521

522+
it('should handle the callback when connect_code and state are available', (done) => {
523+
mockWindow.location.search = '?connect_code=123&state=456';
524+
const localService = createService();
525+
526+
loaded(localService).subscribe(() => {
527+
expect(auth0Client.handleRedirectCallback).toHaveBeenCalledTimes(1);
528+
done();
529+
});
530+
});
531+
516532
it('should not handle the callback when skipRedirectCallback is true', (done) => {
517533
mockWindow.location.search = '?code=123&state=456';
518534
authConfig.skipRedirectCallback = true;
@@ -1126,6 +1142,90 @@ describe('AuthService', () => {
11261142
});
11271143
});
11281144
});
1145+
1146+
it('should add response_type to appState for regular login', (done) => {
1147+
const appState = {
1148+
myValue: 'State to Preserve',
1149+
};
1150+
1151+
(
1152+
auth0Client.handleRedirectCallback as unknown as jest.SpyInstance
1153+
).mockResolvedValue({
1154+
appState,
1155+
response_type: ResponseType.Code,
1156+
});
1157+
1158+
const localService = createService();
1159+
localService.handleRedirectCallback().subscribe(() => {
1160+
localService.appState$.subscribe((receivedState) => {
1161+
expect(receivedState).toEqual({
1162+
...appState,
1163+
response_type: ResponseType.Code,
1164+
});
1165+
done();
1166+
});
1167+
});
1168+
});
1169+
1170+
it('should extract connected account data when response_type is ConnectCode', (done) => {
1171+
const appState = {
1172+
myValue: 'State to Preserve',
1173+
};
1174+
1175+
const connectedAccount = {
1176+
id: 'abc123',
1177+
connection: 'google-oauth2',
1178+
access_type: 'offline' as ConnectAccountRedirectResult['access_type'],
1179+
created_at: '2024-01-01T00:00:00.000Z',
1180+
expires_at: '2024-01-02T00:00:00.000Z',
1181+
};
1182+
1183+
(
1184+
auth0Client.handleRedirectCallback as unknown as jest.SpyInstance
1185+
).mockResolvedValue({
1186+
appState,
1187+
response_type: ResponseType.ConnectCode,
1188+
...connectedAccount,
1189+
});
1190+
1191+
const localService = createService();
1192+
localService.handleRedirectCallback().subscribe(() => {
1193+
localService.appState$.subscribe((receivedState) => {
1194+
expect(receivedState).toEqual({
1195+
...appState,
1196+
response_type: ResponseType.ConnectCode,
1197+
connectedAccount,
1198+
});
1199+
done();
1200+
});
1201+
});
1202+
});
1203+
1204+
it('should handle connected account redirect without initial appState', (done) => {
1205+
const connectedAccount = {
1206+
id: 'xyz789',
1207+
connection: 'github',
1208+
access_type: 'offline' as ConnectAccountRedirectResult['access_type'],
1209+
created_at: '2024-02-01T00:00:00.000Z',
1210+
expires_at: '2024-02-02T00:00:00.000Z',
1211+
};
1212+
1213+
(
1214+
auth0Client.handleRedirectCallback as unknown as jest.SpyInstance
1215+
).mockResolvedValue({
1216+
response_type: ResponseType.ConnectCode,
1217+
...connectedAccount,
1218+
});
1219+
1220+
const localService = createService();
1221+
localService.handleRedirectCallback().subscribe(() => {
1222+
localService.appState$.subscribe((receivedState) => {
1223+
expect(receivedState.response_type).toBe(ResponseType.ConnectCode);
1224+
expect(receivedState.connectedAccount).toEqual(connectedAccount);
1225+
done();
1226+
});
1227+
});
1228+
});
11291229
});
11301230

11311231
describe('getDpopNonce', () => {

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
FetcherConfig,
1616
CustomTokenExchangeOptions,
1717
TokenEndpointResponse,
18+
ResponseType,
1819
} from '@auth0/auth0-spa-js';
1920

2021
import {
@@ -40,7 +41,7 @@ import {
4041

4142
import { Auth0ClientService } from './auth.client';
4243
import { AbstractNavigator } from './abstract-navigator';
43-
import { AuthClientConfig, AppState } from './auth.config';
44+
import { AuthClientConfig, AppState, ConnectedAccount } from './auth.config';
4445
import { AuthState } from './auth.state';
4546
import { LogoutOptions, RedirectLoginOptions } from './interfaces';
4647

@@ -390,9 +391,17 @@ export class AuthService<TAppState extends AppState = AppState>
390391
if (!isLoading) {
391392
this.authState.refresh();
392393
}
393-
const appState = result?.appState;
394+
const { appState = {} as TAppState, response_type, ...rest } = result;
394395
const target = appState?.target ?? '/';
395396

397+
// Add response_type to appState
398+
(appState as AppState).response_type = response_type;
399+
400+
// If this is a connect account flow, add the connected account data to appState
401+
if (response_type === ResponseType.ConnectCode) {
402+
(appState as AppState).connectedAccount = rest as ConnectedAccount;
403+
}
404+
396405
if (appState) {
397406
this.appStateSubject$.next(appState);
398407
}
@@ -482,7 +491,9 @@ export class AuthService<TAppState extends AppState = AppState>
482491
map((search) => {
483492
const searchParams = new URLSearchParams(search);
484493
return (
485-
(searchParams.has('code') || searchParams.has('error')) &&
494+
(searchParams.has('code') ||
495+
searchParams.has('connect_code') ||
496+
searchParams.has('error')) &&
486497
searchParams.has('state') &&
487498
!this.configFactory.get().skipRedirectCallback
488499
);

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export {
2121
GetTokenWithPopupOptions,
2222
GetTokenSilentlyOptions,
2323
RedirectConnectAccountOptions,
24+
ConnectAccountRedirectResult,
2425
ICache,
2526
Cacheable,
2627
LocalStorageCache,
@@ -34,10 +35,12 @@ export {
3435
AuthenticationError,
3536
PopupCancelledError,
3637
MissingRefreshTokenError,
38+
ConnectError,
3739
Fetcher,
3840
FetcherConfig,
3941
CustomFetchMinimalOutput,
4042
UseDpopNonceError,
4143
CustomTokenExchangeOptions,
4244
TokenEndpointResponse,
45+
ResponseType,
4346
} from '@auth0/auth0-spa-js';

0 commit comments

Comments
 (0)