Skip to content

Commit a321bfa

Browse files
feat: add connected accounts callback handling (#817)
1 parent fa844c2 commit a321bfa

File tree

5 files changed

+175
-9
lines changed

5 files changed

+175
-9
lines changed

EXAMPLES.md

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -864,13 +864,13 @@ The My Account API requires DPoP tokens, so we also need to enable DPoP.
864864

865865
```ts
866866
AuthModule.forRoot({
867-
domain: '<AUTH0_DOMAIN>',
868-
clientId: '<AUTH0_CLIENT_ID>',
867+
domain: 'YOUR_AUTH0_DOMAIN',
868+
clientId: 'YOUR_AUTH0_CLIENT_ID',
869869
useRefreshTokens: true,
870870
useMrrt: true,
871871
useDpop: true,
872872
authorizationParams: {
873-
redirect_uri: '<MY_CALLBACK_URL>',
873+
redirect_uri: window.location.origin,
874874
},
875875
});
876876
```
@@ -884,7 +884,7 @@ Use the login methods to authenticate to the application and get a refresh and a
884884
this.auth
885885
.loginWithRedirect({
886886
authorizationParams: {
887-
audience: '<AUTH0_API_IDENTIFIER>',
887+
audience: 'YOUR_AUTH0_API_IDENTIFIER',
888888
scope: 'openid profile email read:calendar',
889889
},
890890
})
@@ -908,6 +908,41 @@ 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+
925+
### List connected accounts
926+
927+
To retrieve the accounts a user has connected, get an access token for the My Account API and call the `/v1/connected-accounts/accounts` endpoint:
928+
929+
```ts
930+
this.auth
931+
.getAccessTokenSilently({
932+
authorizationParams: {
933+
audience: `https://YOUR_AUTH0_DOMAIN/me/`,
934+
scope: 'read:me:connected_accounts',
935+
},
936+
})
937+
.subscribe(async (token) => {
938+
const res = await fetch(`https://YOUR_AUTH0_DOMAIN/me/v1/connected-accounts/accounts`, {
939+
headers: { Authorization: `Bearer ${token}` },
940+
});
941+
const { accounts } = await res.json();
942+
// accounts contains: id, connection, access_type, scopes, created_at
943+
});
944+
```
945+
911946
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.
912947
913948
> **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: 98 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,87 @@ describe('AuthService', () => {
11261142
});
11271143
});
11281144
});
1145+
1146+
it('should preserve appState as-is 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(appState);
1162+
done();
1163+
});
1164+
});
1165+
});
1166+
1167+
it('should extract connected account data when response_type is ConnectCode', (done) => {
1168+
const appState = {
1169+
myValue: 'State to Preserve',
1170+
};
1171+
1172+
const connectedAccount = {
1173+
id: 'abc123',
1174+
connection: 'google-oauth2',
1175+
access_type: 'offline' as ConnectAccountRedirectResult['access_type'],
1176+
created_at: '2024-01-01T00:00:00.000Z',
1177+
expires_at: '2024-01-02T00:00:00.000Z',
1178+
};
1179+
1180+
(
1181+
auth0Client.handleRedirectCallback as unknown as jest.SpyInstance
1182+
).mockResolvedValue({
1183+
appState,
1184+
response_type: ResponseType.ConnectCode,
1185+
...connectedAccount,
1186+
});
1187+
1188+
const localService = createService();
1189+
localService.handleRedirectCallback().subscribe(() => {
1190+
localService.appState$.subscribe((receivedState) => {
1191+
expect(receivedState).toEqual({
1192+
...appState,
1193+
response_type: ResponseType.ConnectCode,
1194+
connectedAccount,
1195+
});
1196+
done();
1197+
});
1198+
});
1199+
});
1200+
1201+
it('should handle connected account redirect without initial appState', (done) => {
1202+
const connectedAccount = {
1203+
id: 'xyz789',
1204+
connection: 'github',
1205+
access_type: 'offline' as ConnectAccountRedirectResult['access_type'],
1206+
created_at: '2024-02-01T00:00:00.000Z',
1207+
expires_at: '2024-02-02T00:00:00.000Z',
1208+
};
1209+
1210+
(
1211+
auth0Client.handleRedirectCallback as unknown as jest.SpyInstance
1212+
).mockResolvedValue({
1213+
response_type: ResponseType.ConnectCode,
1214+
...connectedAccount,
1215+
});
1216+
1217+
const localService = createService();
1218+
localService.handleRedirectCallback().subscribe(() => {
1219+
localService.appState$.subscribe((receivedState) => {
1220+
expect(receivedState.response_type).toBe(ResponseType.ConnectCode);
1221+
expect(receivedState.connectedAccount).toEqual(connectedAccount);
1222+
done();
1223+
});
1224+
});
1225+
});
11291226
});
11301227

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

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

Lines changed: 13 additions & 4 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,10 +391,16 @@ export class AuthService<TAppState extends AppState = AppState>
390391
if (!isLoading) {
391392
this.authState.refresh();
392393
}
393-
const appState = result?.appState;
394+
const { appState, response_type, ...rest } = result;
394395
const target = appState?.target ?? '/';
395396

396-
if (appState) {
397+
if (response_type === ResponseType.ConnectCode) {
398+
this.appStateSubject$.next({
399+
...(appState ?? {}),
400+
response_type,
401+
connectedAccount: rest as ConnectedAccount,
402+
} as TAppState);
403+
} else if (appState) {
397404
this.appStateSubject$.next(appState);
398405
}
399406

@@ -482,7 +489,9 @@ export class AuthService<TAppState extends AppState = AppState>
482489
map((search) => {
483490
const searchParams = new URLSearchParams(search);
484491
return (
485-
(searchParams.has('code') || searchParams.has('error')) &&
492+
(searchParams.has('code') ||
493+
searchParams.has('connect_code') ||
494+
searchParams.has('error')) &&
486495
searchParams.has('state') &&
487496
!this.configFactory.get().skipRedirectCallback
488497
);

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)