Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
79 changes: 79 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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: '<AUTH0_DOMAIN>',
clientId: '<AUTH0_CLIENT_ID>',
useRefreshTokens: true,
useMrrt: true,
useDpop: true,
authorizationParams: {
redirect_uri: '<MY_CALLBACK_URL>'
}
});
```

### 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: '<AUTH0 API IDENTIFIER>',
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: '<CONNECTION eg, google-apps-connection>',
authorization_params: {
scope: '<SCOPE eg https://www.googleapis.com/auth/calendar.acls.readonly>'
}
});

// 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.
148 changes: 148 additions & 0 deletions __tests__/Auth0Client/connectAccountWithRedirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Auth0Client, RedirectConnectAccountOptions } from '../../src';

(<any>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<any> = {
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<any> = {
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<any> = {
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<any> = {
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<any> = {
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.');
});
});
});
102 changes: 101 additions & 1 deletion __tests__/Auth0Client/handleRedirectCallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
});
});
});
Loading