diff --git a/EXAMPLES.md b/EXAMPLES.md index b47bfc9fc..311135c49 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -6,6 +6,7 @@ - [Data Caching Options](#creating-a-custom-cache) - [Organizations](#organizations) - [Device-bound tokens with DPoP](#device-bound-tokens-with-dpop) +- [Connect Accounts for using Token Vault](#connect-accounts-for-using-token-vault) ## Logging Out @@ -563,3 +564,81 @@ client.createFetcher({ }) }); ``` + +## Connect Accounts for using Token Vault + +The Connect Accounts feature uses the Auth0 My Account API to allow users to link multiple third party accounts to a single Auth0 user profile. + +When using Connected Accounts, Auth0 acquires tokens from upstream Identity Providers (like Google) and stores them in a secure [Token Vault](https://auth0.com/docs/secure/tokens/token-vault). These tokens can then be used to access third-party APIs (like Google Calendar) on behalf of the user. + +The tokens in the Token Vault are then accessible to [Resource Servers](https://auth0.com/docs/get-started/apis) (APIs) configured in Auth0. The SPA application can then issue requests to the API, which can retrieve the tokens from the Token Vault and use them to access the third-party APIs. + +This is particularly useful for applications that require access to different resources on behalf of a user, like AI Agents. + +### Configure the SDK + +The SDK must be configured with an audience (an API Identifier) - this will be the resource server that uses the tokens from the Token Vault. + +The SDK must also be configured to use refresh tokens and MRRT ([Multiple Resource Refresh Tokens](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token)) since we will use the refresh token grant to get Access Tokens for the My Account API in addition to the API we are calling. + +The My Account API requires DPoP tokens, so we also need to enable DPoP. + +```js +const auth0 = new Auth0Client({ + domain: '', + clientId: '', + useRefreshTokens: true, + useMrrt: true, + useDpop: true, + authorizationParams: { + redirect_uri: '' + } +}); +``` + +### Login to the application + +Use the login methods to authenticate to the application and get a refresh and access token for the API. + +```js +// Login specifying any scopes for the Auth0 API +await auth0.loginWithRedirect({ + authorizationParams: { + audience: '', + scope: 'openid profile email read:calendar' + } +}); + +// Handle redirect callback on login. +const query = new URLSearchParams(window.location.search); +if ((query.has('code') || query.has('error')) && query.has('state')) { + await auth0.handleRedirectCallback(); + const user = await auth0.getUser(); + console.log(user); +} +``` + +### Connect to a third party account + +Use the new `connectAccountWithRedirect` method to redirect the user to the third party Identity Provider to connect their account. + +```js +// Start the connect flow by redirecting to the thrid party API's login, defined as an Auth0 connection +await auth0.connectAccountWithRedirect({ + connection: '', + authorization_params: { + scope: '' + } +}); + +// Handle redirect callback on connect. *Note* the `connect_code` param +const query = new URLSearchParams(window.location.search); +if ((query.has('connect_code') || query.has('error')) && query.has('state')) { + const result = await auth0.handleRedirectCallback(); + if (result.connection) { + console.log(`You are connected to ${result.connection}!`) + } +} +``` + +You can now [call the API](#calling-an-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. \ No newline at end of file diff --git a/__tests__/Auth0Client/connectAccountWithRedirect.test.ts b/__tests__/Auth0Client/connectAccountWithRedirect.test.ts new file mode 100644 index 000000000..8080c7fbb --- /dev/null +++ b/__tests__/Auth0Client/connectAccountWithRedirect.test.ts @@ -0,0 +1,148 @@ +import { Auth0Client, RedirectConnectAccountOptions } from '../../src'; + +(global).crypto = { + subtle: { + digest: () => '' + }, + getRandomValues: () => '' +}; + +describe('Auth0Client', () => { + let client: Auth0Client; + let mockMyAccountApi: any; + let mockTransactionManager: any; + const oldLocation = window.location; + + beforeEach(() => { + delete (window as any).location; + window.location = { + ...oldLocation, + assign: jest.fn() + } as Location; + mockMyAccountApi = { + connectAccount: jest.fn().mockResolvedValue({ + connect_uri: 'https://connect.example.com', + connect_params: { ticket: 'test-ticket' }, + auth_session: 'test-session' + }) + }; + mockTransactionManager = { + create: jest.fn() + }; + client = new Auth0Client({ + domain: 'test', + clientId: 'abc', + useDpop: true, + useMrrt: true, + authorizationParams: {} + } as any); + (client as any).myAccountApi = mockMyAccountApi; + (client as any).transactionManager = + mockTransactionManager; + }); + + afterEach(() => { + window.location = oldLocation; + }); + + describe('connectAccountWithRedirect', () => { + it('should call myAccountApi.connectAccount with correct params', async () => { + const options: RedirectConnectAccountOptions = { + connection: 'google-oauth2', + authorization_params: { scope: 'profile email' } + }; + + await client.connectAccountWithRedirect(options); + + expect(mockMyAccountApi.connectAccount).toHaveBeenCalledWith( + expect.objectContaining({ + connection: 'google-oauth2', + authorization_params: { scope: 'profile email' }, + state: expect.any(String), + code_challenge: expect.any(String), + code_challenge_method: 'S256', + }) + ); + }); + + it('should create a transaction with correct state and code_verifier', async () => { + const options: RedirectConnectAccountOptions = { + connection: 'github', + appState: { 'returnTo': '/dashboard' } + }; + + await client.connectAccountWithRedirect(options); + + expect(mockTransactionManager.create).toHaveBeenCalledWith( + expect.objectContaining({ + state: expect.any(String), + code_verifier: expect.any(String), + auth_session: 'test-session', + redirect_uri: expect.any(String), + appState: { 'returnTo': '/dashboard' }, + connection: 'github', + response_type: 'connect_code' + }) + ); + }); + + it('should use openUrl if provided', async () => { + const openUrl = jest.fn(); + const options: RedirectConnectAccountOptions = { + connection: 'github', + openUrl + }; + + await client.connectAccountWithRedirect(options); + + expect(openUrl).toHaveBeenCalledWith( + 'https://connect.example.com/?ticket=test-ticket' + ); + }); + + it('should fallback to window.location.assign if openUrl is not provided', async () => { + const options: RedirectConnectAccountOptions = { + connection: 'github' + }; + + await client.connectAccountWithRedirect(options); + + expect(window.location.assign).toHaveBeenCalledWith( + expect.objectContaining({ href: 'https://connect.example.com/?ticket=test-ticket' }) + ); + }); + + it('should throw if connection is not provided', async () => { + await expect((client as any).connectAccountWithRedirect({})).rejects.toThrow( + 'connection is required' + ); + }); + + it('should throw if myAccountApi.connectAccount fails', async () => { + mockMyAccountApi.connectAccount.mockRejectedValue( + new Error('API error') + ); + const options: RedirectConnectAccountOptions = { + connection: 'github' + }; + + await expect(client.connectAccountWithRedirect(options)).rejects.toThrow( + 'API error' + ); + }); + + it('should throw if useDpop is not enabled', async () => { + (client as any).options.useDpop = false; + (client as any).options.useMrrt = true; + await expect(client.connectAccountWithRedirect({ connection: 'github' })) + .rejects.toThrow('`useDpop` option must be enabled before using connectAccountWithRedirect.'); + }); + + it('should throw if useMrrt is not enabled', async () => { + (client as any).options.useDpop = true; + (client as any).options.useMrrt = false; + await expect(client.connectAccountWithRedirect({ connection: 'github' })) + .rejects.toThrow('`useMrrt` option must be enabled before using connectAccountWithRedirect.'); + }); + }); +}); diff --git a/__tests__/Auth0Client/handleRedirectCallback.test.ts b/__tests__/Auth0Client/handleRedirectCallback.test.ts index 4ea5a5ca3..416bd192b 100644 --- a/__tests__/Auth0Client/handleRedirectCallback.test.ts +++ b/__tests__/Auth0Client/handleRedirectCallback.test.ts @@ -30,7 +30,8 @@ import { } from '../constants'; import { DEFAULT_AUTH0_CLIENT } from '../../src/constants'; -import { GenericError } from '../../src'; +import { Auth0Client, ConnectError, GenericError } from '../../src'; +import { CompleteResponse } from '../../src/MyAccountApiClient'; jest.mock('es-cookie'); jest.mock('../../src/jwt'); @@ -522,4 +523,103 @@ describe('Auth0Client', () => { ); }); }); + + describe('handleRedirectCallback with connect_code', () => { + let client: Auth0Client; + let myAccountApi: any; + let url: URL; + let completeResponse: CompleteResponse; + let transaction: any; + + beforeEach(() => { + url = new URL('https://example.com/callback'); + client = new Auth0Client({ domain: 'test', clientId: 'abc', authorizationParams: {} }); + transaction = { + state: 'state123', + code_verifier: 'verifier', + auth_session: 'session', + redirect_uri: 'uri', + appState: { foo: 'bar' }, + response_type: 'connect_code', + connection: 'google-oauth2' + }; + completeResponse = { + id: 'account_123', + connection: 'google-oauth2', + access_type: 'offline', + scopes: ['email', 'profile'], + created_at: '2024-06-01T12:00:00Z', + expires_at: '2025-06-01T12:00:00Z' + }; + myAccountApi = { + completeAccount: jest.fn().mockResolvedValue(completeResponse) + }; + (client as any).myAccountApi = myAccountApi; + (client as any).transactionManager = { + get: jest.fn(), + remove: jest.fn() + }; + }); + + it('returns appState and data on success', async () => { + (client as any).transactionManager.get.mockReturnValue(transaction); + + url.searchParams.set('state', 'state123'); + url.searchParams.set('connect_code', 'code'); + + const result = await client.handleRedirectCallback(url.toString()); + + expect(myAccountApi.completeAccount).toHaveBeenCalledWith({ + auth_session: 'session', + connect_code: 'code', + redirect_uri: 'uri', + code_verifier: 'verifier', + }); + expect(result).toEqual({ appState: { foo: 'bar' }, + response_type: 'connect_code', ...completeResponse }); + expect((client as any).transactionManager.remove).toHaveBeenCalled(); + }); + + it('throws GenericError if transaction is missing', async () => { + (client as any).transactionManager.get.mockReturnValue(undefined); + url.searchParams.set('state', 'state123'); + url.searchParams.set('connect_code', 'code'); + await expect(client.handleRedirectCallback(url.toString())).rejects.toThrow(GenericError); + }); + + it('throws GenericError if connect_code is missing', async () => { + (client as any).transactionManager.get.mockReturnValue(transaction); + url.searchParams.set('state', 'state123'); + await expect(client.handleRedirectCallback(url.toString())).rejects.toThrow(GenericError); + }); + + it('throws ConnectError if error is present', async () => { + (client as any).transactionManager.get.mockReturnValue(transaction); + + url.searchParams.set('error', 'err'); + url.searchParams.set('error_description', 'desc'); + url.searchParams.set('state', 'state123'); + await expect(client.handleRedirectCallback(url.toString())).rejects.toThrow(ConnectError); + expect((client as any).transactionManager.remove).toHaveBeenCalled(); + }); + + it('throws GenericError on state mismatch', async () => { + (client as any).transactionManager.get.mockReturnValue(transaction); + + url.searchParams.set('state', 'wrong-state'); + url.searchParams.set('connect_code', 'code'); + await expect(client.handleRedirectCallback(url.toString())).rejects.toThrow(GenericError); + }); + + it('throws MyAccountApiError if completeAccount fails', async () => { + (client as any).transactionManager.get.mockReturnValue(transaction); + const apiError = new Error('API error'); + myAccountApi.completeAccount.mockRejectedValue(apiError); + + url.searchParams.set('state', 'state123'); + url.searchParams.set('connect_code', 'code'); + await expect(client.handleRedirectCallback(url.toString())).rejects.toThrow('API error'); + expect((client as any).transactionManager.remove).toHaveBeenCalled(); + }); + }); }); diff --git a/__tests__/MyAccountApiClient.test.ts b/__tests__/MyAccountApiClient.test.ts new file mode 100644 index 000000000..4b539f24d --- /dev/null +++ b/__tests__/MyAccountApiClient.test.ts @@ -0,0 +1,126 @@ +import { + MyAccountApiClient, + MyAccountApiError +} from '../src/MyAccountApiClient'; +import { Fetcher } from '../src/fetcher'; + +const mockFetcher = { + fetchWithAuth: jest.fn() +} as unknown as Fetcher; + +const apiBase = 'https://api.example.com/'; +const api = new MyAccountApiClient(mockFetcher, apiBase); + +describe('MyAccountApiClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('connectAccount returns response on success', async () => { + const mockResponse = { + ok: true, + text: jest + .fn() + .mockResolvedValue(JSON.stringify({ + connect_uri: 'uri', + auth_session: 'session', + connect_params: { ticket: 'ticket' }, + expires_in: 3600 + })) + }; + mockFetcher.fetchWithAuth = jest.fn().mockResolvedValue(mockResponse); + + const params = { + connection: 'google-oauth2', + redirect_uri: 'https://redirect' + }; + const result = await api.connectAccount(params); + + expect(mockFetcher.fetchWithAuth).toHaveBeenCalledWith( + `${apiBase}v1/connected-accounts/connect`, + expect.objectContaining({ method: 'POST' }) + ); + expect(result.connect_uri).toBe('uri'); + }); + + it('completeAccount returns response on success', async () => { + const mockResponse = { + ok: true, + text: jest + .fn() + .mockResolvedValue(JSON.stringify({ + id: '123', + connection: 'google-oauth2', + access_type: 'offline', + created_at: '2024-01-01T00:00:00Z' + })) + }; + mockFetcher.fetchWithAuth = jest.fn().mockResolvedValue(mockResponse); + + const params = { + auth_session: 'session', + connect_code: 'code', + redirect_uri: 'https://redirect' + }; + const result = await api.completeAccount(params); + + expect(mockFetcher.fetchWithAuth).toHaveBeenCalledWith( + `${apiBase}v1/connected-accounts/complete`, + expect.objectContaining({ method: 'POST' }) + ); + expect(result.id).toBe('123'); + }); + + it('throws MyAccountApiError on API error response with validation errors', async () => { + const errorBody = { + type: 'error', + status: 400, + title: 'Bad Request', + detail: 'Invalid input', + validation_errors: [ + { detail: 'Connection is invalid', field: 'connection' }, + { detail: 'Redirect URI is missing', field: 'redirect_uri' } + ] + }; + const mockResponse = { + ok: false, + text: jest.fn().mockResolvedValue(JSON.stringify(errorBody)) + }; + mockFetcher.fetchWithAuth = jest.fn().mockResolvedValue(mockResponse); + + await expect( + api.connectAccount({ connection: 'bad', redirect_uri: 'uri' }) + ).rejects.toThrow(MyAccountApiError); + + try { + await api.connectAccount({ connection: 'bad', redirect_uri: 'uri' }); + } catch (err) { + expect(err).toBeInstanceOf(MyAccountApiError); + expect(err.validation_errors).toEqual(errorBody.validation_errors); + } + }); + + it('throws MyAccountApiError on invalid JSON', async () => { + const mockResponse = { + ok: false, + text: jest.fn().mockResolvedValue('Not JSON') + }; + mockFetcher.fetchWithAuth = jest.fn().mockResolvedValue(mockResponse); + + await expect( + api.connectAccount({ connection: 'bad', redirect_uri: 'uri' }) + ).rejects.toThrow(MyAccountApiError); + }); + + it('throws MyAccountApiError on empty response', async () => { + const mockResponse = { + ok: false, + text: jest.fn().mockResolvedValue('') + }; + mockFetcher.fetchWithAuth = jest.fn().mockResolvedValue(mockResponse); + + await expect( + api.connectAccount({ connection: 'bad', redirect_uri: 'uri' }) + ).rejects.toThrow('SyntaxError: Unexpected end of JSON input'); + }); +}); diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts index 44a96ebfe..8f391b009 100644 --- a/src/Auth0Client.ts +++ b/src/Auth0Client.ts @@ -31,10 +31,11 @@ import { DecodedToken } from './cache'; -import { TransactionManager } from './transaction-manager'; +import { ConnectAccountTransaction, LoginTransaction, TransactionManager } from './transaction-manager'; import { verify as verifyIdToken } from './jwt'; import { AuthenticationError, + ConnectError, GenericError, MissingRefreshTokenError, TimeoutError @@ -76,7 +77,11 @@ import { User, IdToken, GetTokenSilentlyVerboseResponse, - TokenEndpointResponse + TokenEndpointResponse, + AuthenticationResult, + ConnectAccountRedirectResult, + RedirectConnectAccountOptions, + ResponseType } from './global'; // @ts-ignore @@ -102,6 +107,7 @@ import { type FetcherConfig, type CustomFetchMinimalOutput } from './fetcher'; +import { MyAccountApiClient } from './MyAccountApiClient'; /** * @ignore @@ -138,6 +144,7 @@ export class Auth0Client { authorizationParams: AuthorizationParams; }; private readonly userCache: ICache = new InMemoryCache().enclosedCache; + private readonly myAccountApi: MyAccountApiClient; private worker?: Worker; private readonly defaultOptions: Partial = { @@ -238,6 +245,22 @@ export class Auth0Client { this.domainUrl = getDomain(this.options.domain); this.tokenIssuer = getTokenIssuer(this.options.issuer, this.domainUrl); + const myAccountApiIdentifier = `${this.domainUrl}/me/`; + const myAccountFetcher = this.createFetcher({ + ...(this.options.useDpop && { dpopNonceId: '__auth0_my_account_api__' }), + getAccessToken: () => + this.getTokenSilently({ + authorizationParams: { + scope: 'create:me:connected_accounts', + audience: myAccountApiIdentifier + } + }) + }); + this.myAccountApi = new MyAccountApiClient( + myAccountFetcher, + myAccountApiIdentifier + ); + // Don't use web workers unless using refresh tokens in memory if ( typeof window !== 'undefined' && @@ -477,9 +500,10 @@ export class Auth0Client { urlOptions.authorizationParams || {} ); - this.transactionManager.create({ + this.transactionManager.create({ ...transaction, appState, + response_type: ResponseType.Code, ...(organization && { organization }) }); @@ -500,18 +524,18 @@ export class Auth0Client { */ public async handleRedirectCallback( url: string = window.location.href - ): Promise> { + ): Promise< + RedirectLoginResult | ConnectAccountRedirectResult + > { const queryStringFragments = url.split('?').slice(1); if (queryStringFragments.length === 0) { throw new Error('There are no query params available for parsing.'); } - const { state, code, error, error_description } = parseAuthenticationResult( - queryStringFragments.join('') - ); - - const transaction = this.transactionManager.get(); + const transaction = this.transactionManager.get< + LoginTransaction | ConnectAccountTransaction + >(); if (!transaction) { throw new GenericError('missing_transaction', 'Invalid state'); @@ -519,6 +543,38 @@ export class Auth0Client { this.transactionManager.remove(); + const authenticationResult = parseAuthenticationResult( + queryStringFragments.join('') + ); + + if (transaction.response_type === ResponseType.ConnectCode) { + return this._handleConnectAccountRedirectCallback( + authenticationResult, + transaction + ); + } + return this._handleLoginRedirectCallback( + authenticationResult, + transaction + ); + } + + /** + * Handles the redirect callback from the login flow. + * + * @template AppState - The application state persisted from the /authorize redirect. + * @param {string} authenticationResult - The parsed authentication result from the URL. + * @param {string} transaction - The login transaction. + * + * @returns {RedirectLoginResult} Resolves with the persisted app state. + * @throws {GenericError | Error} If the transaction is missing, invalid, or the code exchange fails. + */ + private async _handleLoginRedirectCallback( + authenticationResult: AuthenticationResult, + transaction: LoginTransaction + ): Promise> { + const { code, state, error, error_description } = authenticationResult; + if (error) { throw new AuthenticationError( error, @@ -553,7 +609,63 @@ export class Auth0Client { ); return { - appState: transaction.appState + appState: transaction.appState, + response_type: ResponseType.Code + }; + } + + /** + * Handles the redirect callback from the connect account flow. + * This works the same as the redirect from the login flow expect it verifies the `connect_code` + * with the My Account API rather than the `code` with the Authorization Server. + * + * @template AppState - The application state persisted from the connect redirect. + * @param {string} connectResult - The parsed connect accounts result from the URL. + * @param {string} transaction - The login transaction. + * @returns {Promise} The result of the My Account API, including any persisted app state. + * @throws {GenericError | MyAccountApiError} If the transaction is missing, invalid, or an error is returned from the My Account API. + */ + private async _handleConnectAccountRedirectCallback( + connectResult: AuthenticationResult, + transaction: ConnectAccountTransaction + ): Promise> { + const { connect_code, state, error, error_description } = connectResult; + + if (error) { + throw new ConnectError( + error, + error_description || error, + transaction.connection, + state, + transaction.appState + ); + } + + if (!connect_code) { + throw new GenericError('missing_connect_code', 'Missing connect code'); + } + + if ( + !transaction.code_verifier || + !transaction.state || + !transaction.auth_session || + !transaction.redirect_uri || + transaction.state !== state + ) { + throw new GenericError('state_mismatch', 'Invalid state'); + } + + const data = await this.myAccountApi.completeAccount({ + auth_session: transaction.auth_session, + connect_code, + redirect_uri: transaction.redirect_uri, + code_verifier: transaction.code_verifier + }); + + return { + ...data, + appState: transaction.appState, + response_type: ResponseType.ConnectCode, }; } @@ -1032,7 +1144,7 @@ export class Auth0Client { } ); - // If is refreshed with MRRT, we update all entries that have the old + // If is refreshed with MRRT, we update all entries that have the old // refresh_token with the new one if the server responded with one if (tokenResult.refresh_token && this.options.useMrrt && cache?.refresh_token) { await this.cacheManager.updateEntry( @@ -1389,6 +1501,79 @@ export class Auth0Client { generateDpopProof: params => this.generateDpopProof(params) }); } + + /** + * Initiates a redirect to connect the user's account with a specified connection. + * This method generates PKCE parameters, creates a transaction, and redirects to the /connect endpoint. + * + * @template TAppState - The application state to persist through the transaction. + * @param {RedirectConnectAccountOptions} options - Options for the connect account redirect flow. + * @param {string} options.connection - The name of the connection to link (e.g. 'google-oauth2'). + * @param {AuthorizationParams} [options.authorization_params] - Additional authorization parameters for the request to the upstream IdP. + * @param {string} [options.redirectUri] - The URI to redirect back to after connecting the account. + * @param {TAppState} [options.appState] - Application state to persist through the transaction. + * @param {(url: string) => Promise} [options.openUrl] - Custom function to open the URL. + * + * @returns {Promise} Resolves when the redirect is initiated. + * @throws {MyAccountApiError} If the connect request to the My Account API fails. + */ + public async connectAccountWithRedirect( + options: RedirectConnectAccountOptions + ) { + if (!this.options.useDpop) { + throw new Error('`useDpop` option must be enabled before using connectAccountWithRedirect.'); + } + + if (!this.options.useMrrt) { + throw new Error('`useMrrt` option must be enabled before using connectAccountWithRedirect.'); + } + + const { + openUrl, + appState, + connection, + authorization_params, + redirectUri = this.options.authorizationParams.redirect_uri || + window.location.origin + } = options; + + if (!connection) { + throw new Error('connection is required'); + } + + const state = encode(createRandomString()); + const code_verifier = createRandomString(); + const code_challengeBuffer = await sha256(code_verifier); + const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer); + + const { connect_uri, connect_params, auth_session } = + await this.myAccountApi.connectAccount({ + connection, + redirect_uri: redirectUri, + state, + code_challenge, + code_challenge_method: 'S256', + authorization_params + }); + + this.transactionManager.create({ + state, + code_verifier, + auth_session, + redirect_uri: redirectUri, + appState, + connection, + response_type: ResponseType.ConnectCode + }); + + const url = new URL(connect_uri); + url.searchParams.set('ticket', connect_params.ticket); + if (openUrl) { + await openUrl(url.toString()); + } else { + window.location.assign(url); + } + } } interface BaseRequestTokenOptions { diff --git a/src/MyAccountApiClient.ts b/src/MyAccountApiClient.ts new file mode 100644 index 000000000..de6c5bf73 --- /dev/null +++ b/src/MyAccountApiClient.ts @@ -0,0 +1,158 @@ +import { AuthorizationParams } from './global'; +import { Fetcher } from './fetcher'; + +interface ConnectRequest { + /** The name of the connection to link the account with (e.g., 'google-oauth2', 'facebook'). */ + connection: string; + /** The URI to redirect to after the connection process completes. */ + redirect_uri: string; + /** An opaque value used to maintain state between the request and callback. */ + state?: string; + /** A string value used to associate a Client session with an ID Token, and to mitigate replay attacks. */ + nonce?: string; + /** The PKCE code challenge derived from the code verifier. */ + code_challenge?: string; + /** The method used to derive the code challenge. Required when code_challenge is provided. */ + code_challenge_method?: 'S256'; + authorization_params?: AuthorizationParams; +} + +interface ConnectResponse { + /** The base URI to initiate the account connection flow. */ + connect_uri: string; + /** The authentication session identifier. */ + auth_session: string; + /** Parameters to be used with the connect URI. */ + connect_params: { + /** The ticket identifier to be used with the connection URI. */ + ticket: string; + }; + /** The number of seconds until the ticket expires. */ + expires_in: number; +} + +interface CompleteRequest { + /** The authentication session identifier */ + auth_session: string; + /** The authorization code returned from the connect flow */ + connect_code: string; + /** The redirect URI used in the original request */ + redirect_uri: string; + /** The PKCE code verifier */ + code_verifier?: string; +} + +export interface CompleteResponse { + /** The unique identifier of the connected account */ + id: string; + /** The connection name */ + connection: string; + /** The access type, always 'offline' */ + access_type: 'offline'; + /** Array of scopes granted */ + scopes?: string[]; + /** ISO date string of when the connected account was created */ + created_at: string; + /** ISO date string of when the refresh token expires (optional) */ + expires_at?: string; +} + +// Validation error returned from MyAccount API +export interface ErrorResponse { + type: string; + status: number; + title: string; + detail: string; + validation_errors?: { + detail: string; + field?: string; + pointer?: string; + source?: string; + }[]; +} + +/** + * Subset of the MyAccount API that handles the connect accounts flow. + */ +export class MyAccountApiClient { + constructor( + private myAccountFetcher: Fetcher, + private apiBase: string + ) {} + + /** + * Get a ticket for the connect account flow. + */ + async connectAccount(params: ConnectRequest): Promise { + const res = await this.myAccountFetcher.fetchWithAuth( + `${this.apiBase}v1/connected-accounts/connect`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + } + ); + return this._handleResponse(res); + } + + /** + * Verify the redirect from the connect account flow and complete the connecting of the account. + */ + async completeAccount(params: CompleteRequest): Promise { + const res = await this.myAccountFetcher.fetchWithAuth( + `${this.apiBase}v1/connected-accounts/complete`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + } + ); + return this._handleResponse(res); + } + + private async _handleResponse(res: Response) { + let body: any; + try { + body = await res.text(); + body = JSON.parse(body); + } catch (err) { + throw new MyAccountApiError({ + type: 'invalid_json', + status: res.status, + title: 'Invalid JSON response', + detail: body || String(err) + }); + } + + if (res.ok) { + return body; + } else { + throw new MyAccountApiError(body); + } + } +} + +export class MyAccountApiError extends Error { + public readonly type: string; + public readonly status: number; + public readonly title: string; + public readonly detail: string; + public readonly validation_errors?: ErrorResponse['validation_errors']; + + constructor({ + type, + status, + title, + detail, + validation_errors + }: ErrorResponse) { + super(detail); + this.name = 'MyAccountApiError'; + this.type = type; + this.status = status; + this.title = title; + this.detail = detail; + this.validation_errors = validation_errors; + Object.setPrototypeOf(this, MyAccountApiError.prototype); + } +} diff --git a/src/errors.ts b/src/errors.ts index de3b509fb..0bc4b30f7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -35,6 +35,24 @@ export class AuthenticationError extends GenericError { } } +/** + * Thrown when handling the redirect callback for the connect flow fails, will be one of Auth0's + * Authentication API's Standard Error Responses: https://auth0.com/docs/api/authentication?javascript#standard-error-responses + */ +export class ConnectError extends GenericError { + constructor( + error: string, + error_description: string, + public connection: string, + public state: string, + public appState: any = null + ) { + super(error, error_description); + //https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf(this, ConnectError.prototype); + } +} + /** * Thrown when silent auth times out (usually due to a configuration issue) or * when network requests to the Auth server timeout. diff --git a/src/global.ts b/src/global.ts index bbd18a9a4..c510fec53 100644 --- a/src/global.ts +++ b/src/global.ts @@ -1,5 +1,6 @@ import { ICache } from './cache'; import type { Dpop } from './dpop/dpop'; +import { CompleteResponse } from './MyAccountApiClient'; export interface AuthorizationParams { /** @@ -265,10 +266,10 @@ export interface Auth0ClientOptions extends BaseLoginOptions { /** * If provided, the SDK will load the token worker from this URL instead of the integrated `blob`. An example of when this is useful is if you have strict - * Content-Security-Policy (CSP) and wish to avoid needing to set `worker-src: blob:`. We recommend either serving the worker, which you can find in the module - * at `/dist/auth0-spa-js.worker.production.js`, from the same host as your application or using the Auth0 CDN + * Content-Security-Policy (CSP) and wish to avoid needing to set `worker-src: blob:`. We recommend either serving the worker, which you can find in the module + * at `/dist/auth0-spa-js.worker.production.js`, from the same host as your application or using the Auth0 CDN * `https://cdn.auth0.com/js/auth0-spa-js//auth0-spa-js.worker.production.js`. - * + * * **Note**: The worker is only used when `useRefreshTokens: true`, `cacheLocation: 'memory'`, and the `cache` is not custom. */ workerUrl?: string; @@ -353,11 +354,26 @@ export interface RedirectLoginOptions openUrl?: (url: string) => Promise | void; } +/** + * The types of responses expected from the authorization server. + * - `code`: used for the standard login flow. + * - `connect_code`: used for the connect account flow. + */ +export enum ResponseType { + Code = 'code', + ConnectCode = 'connect_code' +} + export interface RedirectLoginResult { /** * State stored when the redirect request was made */ appState?: TAppState; + + /** + * The type of response, for login it will be `code` + */ + response_type: ResponseType.Code; } export interface PopupLoginOptions extends BaseLoginOptions { } @@ -523,12 +539,98 @@ export interface LogoutOptions extends LogoutUrlOptions { openUrl?: false | ((url: string) => Promise | void); } +export interface RedirectConnectAccountOptions { + /** + * The name of the connection to link (e.g. 'google-oauth2'). + */ + connection: string; + + /** + * Additional authorization parameters for the request. + * + * @example + * await auth0.connectAccountWithRedirect({ + * connection: 'google-oauth2', + * authorization_params: { + * scope: 'https://www.googleapis.com/auth/calendar' + * access_type: 'offline' + * } + * }); + * + * @example + * await auth0.connectAccountWithRedirect({ + * connection: 'github', + * authorization_params: { + * scope: 'repo user', + * audience: 'https://api.github.com' + * } + * }); + */ + authorization_params?: AuthorizationParams; + + /** + * The URI to redirect back to after connecting the account. + */ + redirectUri?: string; + + /** + * Optional application state to persist through the transaction. + * + * @example + * await auth0.connectAccountWithRedirect({ + * connection: 'google-oauth2', + * appState: { returnTo: '/settings' } + * }); + */ + appState?: TAppState; + + /** + * Optional function to handle the redirect URL. + * + * @example + * await auth0.connectAccountWithRedirect({ + * connection: 'google-oauth2', + * openUrl: async (url) => { myBrowserApi.open(url); } + * }); + */ + openUrl?: (url: string) => Promise; +} + +/** + * The result returned after a successful account connection redirect. + * + * Combines the redirect login result (including any persisted app state) + * with the complete response from the My Account API. + * + * @template TAppState - The type of application state persisted through the transaction. + * @example + * const result = await auth0.connectAccountWithRedirect(options); + * console.log(result.appState); // Access persisted app state + * console.log(result.connection); // The connection of the account you connected to. + * console.log(result.response_type === 'connect_code'); // The response type will be 'connect_code' + */ +export type ConnectAccountRedirectResult = CompleteResponse & { + /** + * State stored when the redirect request was made + */ + appState?: TAppState; + + /** + * The type of response, for connect account it will be `connect_code` + */ + response_type: ResponseType.ConnectCode; +}; + /** * @ignore */ export interface AuthenticationResult { state: string; code?: string; + /** + * This is for the redirect from the connect account flow. + */ + connect_code?: string; error?: string; error_description?: string; } diff --git a/src/index.ts b/src/index.ts index 8b4e79d47..d4ec91a26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export async function createAuth0Client(options: Auth0ClientOptions) { export { Auth0Client }; export { + ConnectError, GenericError, AuthenticationError, TimeoutError, @@ -48,3 +49,7 @@ export { } from './cache'; export { type FetcherConfig } from './fetcher'; + +export { + MyAccountApiError +} from './MyAccountApiClient'; \ No newline at end of file diff --git a/src/transaction-manager.ts b/src/transaction-manager.ts index 31f97a5b2..f26bdf256 100644 --- a/src/transaction-manager.ts +++ b/src/transaction-manager.ts @@ -2,7 +2,7 @@ import { ClientStorage } from './storage'; const TRANSACTION_STORAGE_KEY_PREFIX = 'a0.spajs.txs'; -interface Transaction { +export interface LoginTransaction { nonce: string; scope: string; audience: string; @@ -11,6 +11,19 @@ interface Transaction { redirect_uri?: string; organization?: string; state?: string; + response_type: 'code'; +} + +export interface ConnectAccountTransaction { + appState?: any; + audience?: string; + auth_session: string; + code_verifier: string; + redirect_uri: string; + scope?: string; + state: string; + connection: string; + response_type: 'connect_code'; } export class TransactionManager { @@ -24,14 +37,14 @@ export class TransactionManager { this.storageKey = `${TRANSACTION_STORAGE_KEY_PREFIX}.${this.clientId}`; } - public create(transaction: Transaction) { + public create(transaction: T) { this.storage.save(this.storageKey, transaction, { daysUntilExpire: 1, cookieDomain: this.cookieDomain }); } - public get(): Transaction | undefined { + public get(): T | undefined { return this.storage.get(this.storageKey); } @@ -40,4 +53,4 @@ export class TransactionManager { cookieDomain: this.cookieDomain }); } -} +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 61b5c1164..0a6ee5c8e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -24,6 +24,7 @@ export const parseAuthenticationResult = ( return { state: searchParams.get('state')!, code: searchParams.get('code') || undefined, + connect_code: searchParams.get('connect_code') || undefined, error: searchParams.get('error') || undefined, error_description: searchParams.get('error_description') || undefined }; diff --git a/static/connect_accounts.html b/static/connect_accounts.html new file mode 100644 index 000000000..9f5eaec42 --- /dev/null +++ b/static/connect_accounts.html @@ -0,0 +1,251 @@ + + + + Auth0 - Connected Accounts + + + + + + +
+
+
+
+
+

Auth0 Connected Accounts

+
+
+ + + + Use a tenant that supports Connected Accounts. + +
+
+ + +
+
+ + + + Example: https://my-first-party-api.com + +
+
+ + + + You need to request at least offline_access, e.g. openid profile email offline_access + +
+ +
+ +
+

Connections

+
+ + + + + + + + + + + + + + + + + +
NameTypeScope
{{ conn.name }}{{ conn.strategy }}{{ conn.scopes.join(',') }} + +
+
+
+
+

Connected Accounts

+
+ + + + + + + + + + + + + + + + + +
NameTypeStatus
{{ acc.connection }}{{ acc.access_type }}{{ acc.scopes.join(',') }} + +
+
+
+
{{ error }}
+
+
+
+
+
+ + +