diff --git a/EXAMPLES.md b/EXAMPLES.md index 90cb2e03..77abfaad 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -864,13 +864,13 @@ The My Account API requires DPoP tokens, so we also need to enable DPoP. ```ts AuthModule.forRoot({ - domain: '', - clientId: '', + domain: 'YOUR_AUTH0_DOMAIN', + clientId: 'YOUR_AUTH0_CLIENT_ID', useRefreshTokens: true, useMrrt: true, useDpop: true, authorizationParams: { - redirect_uri: '', + redirect_uri: window.location.origin, }, }); ``` @@ -884,7 +884,7 @@ Use the login methods to authenticate to the application and get a refresh and a this.auth .loginWithRedirect({ authorizationParams: { - audience: '', + audience: 'YOUR_AUTH0_API_IDENTIFIER', scope: 'openid profile email read:calendar', }, }) @@ -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** diff --git a/projects/auth0-angular/src/lib/auth.config.ts b/projects/auth0-angular/src/lib/auth.config.ts index cb41f704..c532e50b 100644 --- a/projects/auth0-angular/src/lib/auth.config.ts +++ b/projects/auth0-angular/src/lib/auth.config.ts @@ -3,6 +3,8 @@ import { CacheLocation, GetTokenSilentlyOptions, ICache, + ConnectAccountRedirectResult, + ResponseType, } from '@auth0/auth0-spa-js'; import { InjectionToken, Injectable, Optional, Inject } from '@angular/core'; @@ -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 { /** @@ -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 */ diff --git a/projects/auth0-angular/src/lib/auth.service.spec.ts b/projects/auth0-angular/src/lib/auth.service.spec.ts index 64588ccc..5f8cf165 100644 --- a/projects/auth0-angular/src/lib/auth.service.spec.ts +++ b/projects/auth0-angular/src/lib/auth.service.spec.ts @@ -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, @@ -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(); @@ -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; @@ -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', () => { diff --git a/projects/auth0-angular/src/lib/auth.service.ts b/projects/auth0-angular/src/lib/auth.service.ts index a691a792..69507866 100644 --- a/projects/auth0-angular/src/lib/auth.service.ts +++ b/projects/auth0-angular/src/lib/auth.service.ts @@ -15,6 +15,7 @@ import { FetcherConfig, CustomTokenExchangeOptions, TokenEndpointResponse, + ResponseType, } from '@auth0/auth0-spa-js'; import { @@ -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'; @@ -390,10 +391,16 @@ export class AuthService 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); } @@ -482,7 +489,9 @@ export class AuthService 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 ); diff --git a/projects/auth0-angular/src/public-api.ts b/projects/auth0-angular/src/public-api.ts index e4d14964..77d815b1 100644 --- a/projects/auth0-angular/src/public-api.ts +++ b/projects/auth0-angular/src/public-api.ts @@ -21,6 +21,7 @@ export { GetTokenWithPopupOptions, GetTokenSilentlyOptions, RedirectConnectAccountOptions, + ConnectAccountRedirectResult, ICache, Cacheable, LocalStorageCache, @@ -34,10 +35,12 @@ export { AuthenticationError, PopupCancelledError, MissingRefreshTokenError, + ConnectError, Fetcher, FetcherConfig, CustomFetchMinimalOutput, UseDpopNonceError, CustomTokenExchangeOptions, TokenEndpointResponse, + ResponseType, } from '@auth0/auth0-spa-js';