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
43 changes: 39 additions & 4 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -864,13 +864,13 @@ The My Account API requires DPoP tokens, so we also need to enable DPoP.

```ts
AuthModule.forRoot({
domain: '<AUTH0_DOMAIN>',
clientId: '<AUTH0_CLIENT_ID>',
domain: 'YOUR_AUTH0_DOMAIN',
clientId: 'YOUR_AUTH0_CLIENT_ID',
useRefreshTokens: true,
useMrrt: true,
useDpop: true,
authorizationParams: {
redirect_uri: '<MY_CALLBACK_URL>',
redirect_uri: window.location.origin,
},
});
```
Expand All @@ -884,7 +884,7 @@ Use the login methods to authenticate to the application and get a refresh and a
this.auth
.loginWithRedirect({
authorizationParams: {
audience: '<AUTH0_API_IDENTIFIER>',
audience: 'YOUR_AUTH0_API_IDENTIFIER',
scope: 'openid profile email read:calendar',
},
})
Expand All @@ -908,6 +908,41 @@ this.auth
.subscribe();
```

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:

```ts
ngOnInit() {
this.auth.appState$.subscribe((appState) => {
if (appState?.connectedAccount) {
console.log(`You've connected to ${appState.connectedAccount.connection}`);
// Handle the connected account details
// appState.connectedAccount contains: id, connection, access_type, created_at, expires_at
}
});
}
```

### List connected accounts

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:

```ts
this.auth
.getAccessTokenSilently({
authorizationParams: {
audience: `https://YOUR_AUTH0_DOMAIN/me/`,
scope: 'read:me:connected_accounts',
},
})
.subscribe(async (token) => {
const res = await fetch(`https://YOUR_AUTH0_DOMAIN/me/v1/connected-accounts/accounts`, {
headers: { Authorization: `Bearer ${token}` },
});
const { accounts } = await res.json();
// accounts contains: id, connection, access_type, scopes, created_at
});
```

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.

> **Important**
Expand Down
22 changes: 22 additions & 0 deletions projects/auth0-angular/src/lib/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
CacheLocation,
GetTokenSilentlyOptions,
ICache,
ConnectAccountRedirectResult,
ResponseType,
} from '@auth0/auth0-spa-js';

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

/**
* The account that has been connected during the connect flow.
*/
export type ConnectedAccount = Omit<
ConnectAccountRedirectResult,
'appState' | 'response_type'
>;

/**
* Angular specific state to be stored before redirect
* and any account that the user may have connected to.
*/
export interface AppState {
/**
Expand All @@ -146,6 +157,17 @@ export interface AppState {
*/
target?: string;

/**
* The connected account information when the user has completed
* a connect account flow.
*/
connectedAccount?: ConnectedAccount;

/**
* The response type returned from the authentication server.
*/
response_type?: ResponseType;

/**
* Any custom parameter to be stored in appState
*/
Expand Down
99 changes: 98 additions & 1 deletion projects/auth0-angular/src/lib/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { fakeAsync, TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
import { Auth0ClientService } from './auth.client';
import { Auth0Client, IdToken } from '@auth0/auth0-spa-js';
import {
Auth0Client,
IdToken,
ResponseType,
ConnectAccountRedirectResult,
} from '@auth0/auth0-spa-js';
import { AbstractNavigator } from './abstract-navigator';
import {
bufferCount,
Expand Down Expand Up @@ -60,6 +65,7 @@ describe('AuthService', () => {

jest.spyOn(auth0Client, 'handleRedirectCallback').mockResolvedValue({
appState: undefined,
response_type: ResponseType.Code,
} as any);
jest.spyOn(auth0Client, 'loginWithRedirect').mockResolvedValue();
jest.spyOn(auth0Client, 'connectAccountWithRedirect').mockResolvedValue();
Expand Down Expand Up @@ -513,6 +519,16 @@ describe('AuthService', () => {
});
});

it('should handle the callback when connect_code and state are available', (done) => {
mockWindow.location.search = '?connect_code=123&state=456';
const localService = createService();

loaded(localService).subscribe(() => {
expect(auth0Client.handleRedirectCallback).toHaveBeenCalledTimes(1);
done();
});
});

it('should not handle the callback when skipRedirectCallback is true', (done) => {
mockWindow.location.search = '?code=123&state=456';
authConfig.skipRedirectCallback = true;
Expand Down Expand Up @@ -1126,6 +1142,87 @@ describe('AuthService', () => {
});
});
});

it('should preserve appState as-is for regular login', (done) => {
const appState = {
myValue: 'State to Preserve',
};

(
auth0Client.handleRedirectCallback as unknown as jest.SpyInstance
).mockResolvedValue({
appState,
response_type: ResponseType.Code,
});

const localService = createService();
localService.handleRedirectCallback().subscribe(() => {
localService.appState$.subscribe((receivedState) => {
expect(receivedState).toEqual(appState);
done();
});
});
});

it('should extract connected account data when response_type is ConnectCode', (done) => {
const appState = {
myValue: 'State to Preserve',
};

const connectedAccount = {
id: 'abc123',
connection: 'google-oauth2',
access_type: 'offline' as ConnectAccountRedirectResult['access_type'],
created_at: '2024-01-01T00:00:00.000Z',
expires_at: '2024-01-02T00:00:00.000Z',
};

(
auth0Client.handleRedirectCallback as unknown as jest.SpyInstance
).mockResolvedValue({
appState,
response_type: ResponseType.ConnectCode,
...connectedAccount,
});

const localService = createService();
localService.handleRedirectCallback().subscribe(() => {
localService.appState$.subscribe((receivedState) => {
expect(receivedState).toEqual({
...appState,
response_type: ResponseType.ConnectCode,
connectedAccount,
});
done();
});
});
});

it('should handle connected account redirect without initial appState', (done) => {
const connectedAccount = {
id: 'xyz789',
connection: 'github',
access_type: 'offline' as ConnectAccountRedirectResult['access_type'],
created_at: '2024-02-01T00:00:00.000Z',
expires_at: '2024-02-02T00:00:00.000Z',
};

(
auth0Client.handleRedirectCallback as unknown as jest.SpyInstance
).mockResolvedValue({
response_type: ResponseType.ConnectCode,
...connectedAccount,
});

const localService = createService();
localService.handleRedirectCallback().subscribe(() => {
localService.appState$.subscribe((receivedState) => {
expect(receivedState.response_type).toBe(ResponseType.ConnectCode);
expect(receivedState.connectedAccount).toEqual(connectedAccount);
done();
});
});
});
});

describe('getDpopNonce', () => {
Expand Down
17 changes: 13 additions & 4 deletions projects/auth0-angular/src/lib/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
FetcherConfig,
CustomTokenExchangeOptions,
TokenEndpointResponse,
ResponseType,
} from '@auth0/auth0-spa-js';

import {
Expand All @@ -40,7 +41,7 @@ import {

import { Auth0ClientService } from './auth.client';
import { AbstractNavigator } from './abstract-navigator';
import { AuthClientConfig, AppState } from './auth.config';
import { AuthClientConfig, AppState, ConnectedAccount } from './auth.config';
import { AuthState } from './auth.state';
import { LogoutOptions, RedirectLoginOptions } from './interfaces';

Expand Down Expand Up @@ -390,10 +391,16 @@ export class AuthService<TAppState extends AppState = AppState>
if (!isLoading) {
this.authState.refresh();
}
const appState = result?.appState;
const { appState, response_type, ...rest } = result;
const target = appState?.target ?? '/';

if (appState) {
if (response_type === ResponseType.ConnectCode) {
this.appStateSubject$.next({
...(appState ?? {}),
response_type,
connectedAccount: rest as ConnectedAccount,
} as TAppState);
} else if (appState) {
this.appStateSubject$.next(appState);
}

Expand Down Expand Up @@ -482,7 +489,9 @@ export class AuthService<TAppState extends AppState = AppState>
map((search) => {
const searchParams = new URLSearchParams(search);
return (
(searchParams.has('code') || searchParams.has('error')) &&
(searchParams.has('code') ||
searchParams.has('connect_code') ||
searchParams.has('error')) &&
searchParams.has('state') &&
!this.configFactory.get().skipRedirectCallback
);
Expand Down
3 changes: 3 additions & 0 deletions projects/auth0-angular/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
GetTokenWithPopupOptions,
GetTokenSilentlyOptions,
RedirectConnectAccountOptions,
ConnectAccountRedirectResult,
ICache,
Cacheable,
LocalStorageCache,
Expand All @@ -34,10 +35,12 @@ export {
AuthenticationError,
PopupCancelledError,
MissingRefreshTokenError,
ConnectError,
Fetcher,
FetcherConfig,
CustomFetchMinimalOutput,
UseDpopNonceError,
CustomTokenExchangeOptions,
TokenEndpointResponse,
ResponseType,
} from '@auth0/auth0-spa-js';