Skip to content

Commit c3d94d2

Browse files
committed
Add support for connected accounts
1 parent b64c897 commit c3d94d2

12 files changed

Lines changed: 1206 additions & 20 deletions

EXAMPLES.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- [Data Caching Options](#creating-a-custom-cache)
77
- [Organizations](#organizations)
88
- [Device-bound tokens with DPoP](#device-bound-tokens-with-dpop)
9+
- [Connect Accounts for using Token Vault](#connect-accounts-for-using-token-vault)
910

1011
## Logging Out
1112

@@ -563,3 +564,81 @@ client.createFetcher({
563564
})
564565
});
565566
```
567+
568+
## Connect Accounts for using Token Vault
569+
570+
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.
571+
572+
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.
573+
574+
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.
575+
576+
This is particularly useful for applications that require access to different resources on behalf of a user, like AI Agents.
577+
578+
### Configure the SDK
579+
580+
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.
581+
582+
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.
583+
584+
The My Account API requires DPoP tokens, so we also need to enable DPoP.
585+
586+
```js
587+
const auth0 = new Auth0Client({
588+
domain: '<AUTH0_DOMAIN>',
589+
clientId: '<AUTH0_CLIENT_ID>',
590+
useRefreshTokens: true,
591+
useMrrt: true,
592+
useDpop: true,
593+
authorizationParams: {
594+
redirect_uri: '<MY_CALLBACK_URL>'
595+
}
596+
});
597+
```
598+
599+
### Login to the application
600+
601+
Use the login methods to authenticate to the application and get a refresh and access token for the API.
602+
603+
```js
604+
// Login specifying any scopes for the Auth0 API
605+
await auth0.loginWithRedirect({
606+
authorizationParams: {
607+
audience: '<AUTH0 API IDENTIFIER>',
608+
scope: 'openid profile email read:calendar'
609+
}
610+
});
611+
612+
// Handle redirect callback on login.
613+
const query = new URLSearchParams(window.location.search);
614+
if ((query.has('code') || query.has('error')) && query.has('state')) {
615+
await auth0.handleRedirectCallback();
616+
const user = await auth0.getUser();
617+
console.log(user);
618+
}
619+
```
620+
621+
### Connect to a third party account
622+
623+
Use the new `connectAccountWithRedirect` method to redirect the user to the third party Identity Provider to connect their account.
624+
625+
```js
626+
// Start the connect flow by redirecting to the thrid party API's login, defined as an Auth0 connection
627+
await auth0.connectAccountWithRedirect({
628+
connection: '<CONNECTION eg, google-apps-connection>',
629+
authorization_params: {
630+
scope: '<SCOPE eg https://www.googleapis.com/auth/calendar.acls.readonly>'
631+
}
632+
});
633+
634+
// Handle redirect callback on connect. *Note* the `connect_code` param
635+
const query = new URLSearchParams(window.location.search);
636+
if ((query.has('connect_code') || query.has('error')) && query.has('state')) {
637+
const result = await auth0.handleRedirectCallback();
638+
if (result.connection) {
639+
console.log(`You are connected to ${result.connection}!`)
640+
}
641+
}
642+
```
643+
644+
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.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { Auth0Client, RedirectConnectAccountOptions } from '../../src';
2+
3+
(<any>global).crypto = {
4+
subtle: {
5+
digest: () => ''
6+
},
7+
getRandomValues: () => ''
8+
};
9+
10+
describe('Auth0Client', () => {
11+
let client: Auth0Client;
12+
let mockMyAccountApi: any;
13+
let mockTransactionManager: any;
14+
const oldLocation = window.location;
15+
16+
beforeEach(() => {
17+
delete (window as any).location;
18+
window.location = {
19+
...oldLocation,
20+
assign: jest.fn()
21+
} as Location;
22+
mockMyAccountApi = {
23+
connectAccount: jest.fn().mockResolvedValue({
24+
connect_uri: 'https://connect.example.com',
25+
connect_params: { ticket: 'test-ticket' },
26+
auth_session: 'test-session'
27+
})
28+
};
29+
mockTransactionManager = {
30+
create: jest.fn()
31+
};
32+
client = new Auth0Client({
33+
domain: 'test',
34+
clientId: 'abc',
35+
useDpop: true,
36+
useMrrt: true,
37+
authorizationParams: {}
38+
} as any);
39+
(client as any).myAccountApi = mockMyAccountApi;
40+
(client as any).transactionManager =
41+
mockTransactionManager;
42+
});
43+
44+
afterEach(() => {
45+
window.location = oldLocation;
46+
});
47+
48+
describe('connectAccountWithRedirect', () => {
49+
it('should call myAccountApi.connectAccount with correct params', async () => {
50+
const options: RedirectConnectAccountOptions<any> = {
51+
connection: 'google-oauth2',
52+
authorization_params: { scope: 'profile email' }
53+
};
54+
55+
await client.connectAccountWithRedirect(options);
56+
57+
expect(mockMyAccountApi.connectAccount).toHaveBeenCalledWith(
58+
expect.objectContaining({
59+
connection: 'google-oauth2',
60+
authorization_params: { scope: 'profile email' },
61+
state: expect.any(String),
62+
code_challenge: expect.any(String),
63+
code_challenge_method: 'S256',
64+
})
65+
);
66+
});
67+
68+
it('should create a transaction with correct state and code_verifier', async () => {
69+
const options: RedirectConnectAccountOptions<any> = {
70+
connection: 'github',
71+
appState: { 'returnTo': '/dashboard' }
72+
};
73+
74+
await client.connectAccountWithRedirect(options);
75+
76+
expect(mockTransactionManager.create).toHaveBeenCalledWith(
77+
expect.objectContaining({
78+
state: expect.any(String),
79+
code_verifier: expect.any(String),
80+
auth_session: 'test-session',
81+
redirect_uri: expect.any(String),
82+
appState: { 'returnTo': '/dashboard' },
83+
connection: 'github',
84+
response_type: 'connect_code'
85+
})
86+
);
87+
});
88+
89+
it('should use openUrl if provided', async () => {
90+
const openUrl = jest.fn();
91+
const options: RedirectConnectAccountOptions<any> = {
92+
connection: 'github',
93+
openUrl
94+
};
95+
96+
await client.connectAccountWithRedirect(options);
97+
98+
expect(openUrl).toHaveBeenCalledWith(
99+
'https://connect.example.com/?ticket=test-ticket'
100+
);
101+
});
102+
103+
it('should fallback to window.location.assign if openUrl is not provided', async () => {
104+
const options: RedirectConnectAccountOptions<any> = {
105+
connection: 'github'
106+
};
107+
108+
await client.connectAccountWithRedirect(options);
109+
110+
expect(window.location.assign).toHaveBeenCalledWith(
111+
expect.objectContaining({ href: 'https://connect.example.com/?ticket=test-ticket' })
112+
);
113+
});
114+
115+
it('should throw if connection is not provided', async () => {
116+
await expect((client as any).connectAccountWithRedirect({})).rejects.toThrow(
117+
'connection is required'
118+
);
119+
});
120+
121+
it('should throw if myAccountApi.connectAccount fails', async () => {
122+
mockMyAccountApi.connectAccount.mockRejectedValue(
123+
new Error('API error')
124+
);
125+
const options: RedirectConnectAccountOptions<any> = {
126+
connection: 'github'
127+
};
128+
129+
await expect(client.connectAccountWithRedirect(options)).rejects.toThrow(
130+
'API error'
131+
);
132+
});
133+
134+
it('should throw if useDpop is not enabled', async () => {
135+
(client as any).options.useDpop = false;
136+
(client as any).options.useMrrt = true;
137+
await expect(client.connectAccountWithRedirect({ connection: 'github' }))
138+
.rejects.toThrow('`useDpop` option must be enabled before using connectAccountWithRedirect.');
139+
});
140+
141+
it('should throw if useMrrt is not enabled', async () => {
142+
(client as any).options.useDpop = true;
143+
(client as any).options.useMrrt = false;
144+
await expect(client.connectAccountWithRedirect({ connection: 'github' }))
145+
.rejects.toThrow('`useMrrt` option must be enabled before using connectAccountWithRedirect.');
146+
});
147+
});
148+
});

__tests__/Auth0Client/handleRedirectCallback.test.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ import {
3030
} from '../constants';
3131

3232
import { DEFAULT_AUTH0_CLIENT } from '../../src/constants';
33-
import { GenericError } from '../../src';
33+
import { Auth0Client, ConnectError, GenericError } from '../../src';
34+
import { CompleteResponse } from '../../src/MyAccountApiClient';
3435

3536
jest.mock('es-cookie');
3637
jest.mock('../../src/jwt');
@@ -522,4 +523,103 @@ describe('Auth0Client', () => {
522523
);
523524
});
524525
});
526+
527+
describe('handleRedirectCallback with connect_code', () => {
528+
let client: Auth0Client;
529+
let myAccountApi: any;
530+
let url: URL;
531+
let completeResponse: CompleteResponse;
532+
let transaction: any;
533+
534+
beforeEach(() => {
535+
url = new URL('https://example.com/callback');
536+
client = new Auth0Client({ domain: 'test', clientId: 'abc', authorizationParams: {} });
537+
transaction = {
538+
state: 'state123',
539+
code_verifier: 'verifier',
540+
auth_session: 'session',
541+
redirect_uri: 'uri',
542+
appState: { foo: 'bar' },
543+
response_type: 'connect_code',
544+
connection: 'google-oauth2'
545+
};
546+
completeResponse = {
547+
id: 'account_123',
548+
connection: 'google-oauth2',
549+
access_type: 'offline',
550+
scopes: ['email', 'profile'],
551+
created_at: '2024-06-01T12:00:00Z',
552+
expires_at: '2025-06-01T12:00:00Z'
553+
};
554+
myAccountApi = {
555+
completeAccount: jest.fn().mockResolvedValue(completeResponse)
556+
};
557+
(client as any).myAccountApi = myAccountApi;
558+
(client as any).transactionManager = {
559+
get: jest.fn(),
560+
remove: jest.fn()
561+
};
562+
});
563+
564+
it('returns appState and data on success', async () => {
565+
(client as any).transactionManager.get.mockReturnValue(transaction);
566+
567+
url.searchParams.set('state', 'state123');
568+
url.searchParams.set('connect_code', 'code');
569+
570+
const result = await client.handleRedirectCallback(url.toString());
571+
572+
expect(myAccountApi.completeAccount).toHaveBeenCalledWith({
573+
auth_session: 'session',
574+
connect_code: 'code',
575+
redirect_uri: 'uri',
576+
code_verifier: 'verifier',
577+
});
578+
expect(result).toEqual({ appState: { foo: 'bar' },
579+
response_type: 'connect_code', ...completeResponse });
580+
expect((client as any).transactionManager.remove).toHaveBeenCalled();
581+
});
582+
583+
it('throws GenericError if transaction is missing', async () => {
584+
(client as any).transactionManager.get.mockReturnValue(undefined);
585+
url.searchParams.set('state', 'state123');
586+
url.searchParams.set('connect_code', 'code');
587+
await expect(client.handleRedirectCallback(url.toString())).rejects.toThrow(GenericError);
588+
});
589+
590+
it('throws GenericError if connect_code is missing', async () => {
591+
(client as any).transactionManager.get.mockReturnValue(transaction);
592+
url.searchParams.set('state', 'state123');
593+
await expect(client.handleRedirectCallback(url.toString())).rejects.toThrow(GenericError);
594+
});
595+
596+
it('throws ConnectError if error is present', async () => {
597+
(client as any).transactionManager.get.mockReturnValue(transaction);
598+
599+
url.searchParams.set('error', 'err');
600+
url.searchParams.set('error_description', 'desc');
601+
url.searchParams.set('state', 'state123');
602+
await expect(client.handleRedirectCallback(url.toString())).rejects.toThrow(ConnectError);
603+
expect((client as any).transactionManager.remove).toHaveBeenCalled();
604+
});
605+
606+
it('throws GenericError on state mismatch', async () => {
607+
(client as any).transactionManager.get.mockReturnValue(transaction);
608+
609+
url.searchParams.set('state', 'wrong-state');
610+
url.searchParams.set('connect_code', 'code');
611+
await expect(client.handleRedirectCallback(url.toString())).rejects.toThrow(GenericError);
612+
});
613+
614+
it('throws MyAccountApiError if completeAccount fails', async () => {
615+
(client as any).transactionManager.get.mockReturnValue(transaction);
616+
const apiError = new Error('API error');
617+
myAccountApi.completeAccount.mockRejectedValue(apiError);
618+
619+
url.searchParams.set('state', 'state123');
620+
url.searchParams.set('connect_code', 'code');
621+
await expect(client.handleRedirectCallback(url.toString())).rejects.toThrow('API error');
622+
expect((client as any).transactionManager.remove).toHaveBeenCalled();
623+
});
624+
});
525625
});

0 commit comments

Comments
 (0)