Skip to content

Commit faf6bf7

Browse files
authored
Add support for connected accounts (#912)
### Changes Add support for Connected Accounts to SPA JS: - Bump SPA-JS to include auth0/auth0-spa-js#1422 - Add a new `connectAccountWithRedirect` method that redirects the application to the `/connect` endpoint on the auth server (similar mechanics to the logging in with the `/authorize` endpoint) - Update the playground to enabled testing Connected Accounts <img width="50%" height="50%" alt="image" src="https://github.com/user-attachments/assets/7aecbc87-5f1b-47ee-a868-0d7c05a3ba56" /> ### References https://auth0team.atlassian.net/browse/AGAI-157 ### Testing Manual testing steps and demo video are in ESD-52475 - [x] This change adds unit test coverage - [ ] This change adds integration test coverage (integration tests are not possible since oidc-provider does not support the proprietary Auth0 connect flow) - [x] This change has been tested on the latest version of the platform/language ### Checklist - [x] I have read the [Auth0 general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md) - [x] I have read the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md) - [x] All code quality tools/guidelines have been run/followed
1 parent a6c5428 commit faf6bf7

File tree

15 files changed

+374
-51
lines changed

15 files changed

+374
-51
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,4 @@ test-results
108108

109109
cypress/screenshots
110110
cypress/videos
111-
.npmrc
111+
.npmrc

EXAMPLES.md

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
- [Use with Auth0 organizations](#use-with-auth0-organizations)
1010
- [Protecting a route with a claims check](#protecting-a-route-with-a-claims-check)
1111
- [Device-bound tokens with DPoP](#device-bound-tokens-with-dpop)
12-
- [Using Multi Resource Refresh Tokens]()
12+
- [Using Multi Resource Refresh Tokens](#using-multi-resource-refresh-tokens)
13+
- [Connect Accounts for using Token Vault](#connect-accounts-for-using-token-vault)
1314

1415
## Use with a Class Component
1516

@@ -597,3 +598,88 @@ MRRT is disabled by default. To enable it, set the `useMrrt` option to `true` wh
597598
> [!IMPORTANT]
598599
> In order MRRT to work, it needs a previous configuration setting the refresh token policies.
599600
> Visit [configure and implement MRRT.](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token/configure-and-implement-multi-resource-refresh-token)
601+
602+
## Connect Accounts for using Token Vault
603+
604+
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.
605+
606+
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.
607+
608+
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.
609+
610+
This is particularly useful for applications that require access to different resources on behalf of a user, like AI Agents.
611+
612+
### Configure the SDK
613+
614+
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.
615+
616+
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.
617+
618+
The My Account API requires DPoP tokens, so we also need to enable DPoP.
619+
620+
```jsx
621+
<Auth0Provider
622+
domain="YOUR_AUTH0_DOMAIN"
623+
clientId="YOUR_AUTH0_CLIENT_ID"
624+
authorizationParams={{
625+
redirect_uri: window.location.origin,
626+
audience: '<AUTH0 API IDENTIFIER>' // The API that will use the tokens from the Token Vault
627+
}}
628+
useRefreshTokens={true}
629+
useMrrt={true}
630+
useDpop={true}
631+
>
632+
<App />
633+
</Auth0Provider>
634+
```
635+
636+
### Login to the application
637+
638+
Use the login methods to authenticate to the application and get a refresh and access token for the API.
639+
640+
```jsx
641+
const Login = () => {
642+
const { loginWithRedirect } = useAuth0();
643+
return <button onClick={() => loginWithRedirect({
644+
authorizationParams: {
645+
audience: '<AUTH0 API IDENTIFIER>', // The API that will use the tokens from the Token Vault
646+
scope: 'openid profile email offline_access read:calendar' // Make sure you get a Refresh Token as you're using MRRT to get access to the My Account API
647+
}
648+
})}>Login</button>;
649+
};
650+
```
651+
652+
### Connect to a third party account
653+
654+
Use the new `connectAccountWithRedirect` method to redirect the user to the third party Identity Provider to connect their account.
655+
656+
```jsx
657+
const ConnectAccount = () => {
658+
const { connectAccountWithRedirect } = useAuth0();
659+
return <button onClick={() => connectAccountWithRedirect({
660+
connection: '<CONNECTION eg, google-apps-connection>',
661+
access_type: 'offline', // You must also request a refresh token from the third party Identity Provider for it to be stored in Token Vault.
662+
authorization_params: {
663+
scope: '<SCOPE eg https://www.googleapis.com/auth/calendar.acls.readonly>'
664+
}
665+
})}>Connect Google Calendar</button>;
666+
};
667+
```
668+
669+
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.
670+
671+
```jsx
672+
<Auth0Provider
673+
// ...
674+
onRedirectCallback={(appState) => {
675+
if (appState.connectedAccount) {
676+
console.log(`You've connected to ${appState.connectedAccount.connection}`);
677+
}
678+
window.history.replaceState({}, document.title, '/');
679+
}}
680+
>
681+
<App />
682+
</Auth0Provider>
683+
```
684+
685+
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.

__mocks__/@auth0/auth0-spa-js.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const actual = jest.requireActual('@auth0/auth0-spa-js');
2+
13
const handleRedirectCallback = jest.fn(() => ({ appState: {} }));
24
const buildLogoutUrl = jest.fn();
35
const buildAuthorizeUrl = jest.fn();
@@ -9,6 +11,7 @@ const getIdTokenClaims = jest.fn();
911
const isAuthenticated = jest.fn(() => false);
1012
const loginWithPopup = jest.fn();
1113
const loginWithRedirect = jest.fn();
14+
const connectAccountWithRedirect = jest.fn();
1215
const logout = jest.fn();
1316
const getDpopNonce = jest.fn();
1417
const setDpopNonce = jest.fn();
@@ -28,10 +31,13 @@ export const Auth0Client = jest.fn(() => {
2831
isAuthenticated,
2932
loginWithPopup,
3033
loginWithRedirect,
34+
connectAccountWithRedirect,
3135
logout,
3236
getDpopNonce,
3337
setDpopNonce,
3438
generateDpopProof,
3539
createFetcher,
3640
};
3741
});
42+
43+
export const ResponseType = actual.ResponseType;

__tests__/auth-provider.test.tsx

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
2-
Auth0Client,
2+
Auth0Client, ConnectAccountRedirectResult,
33
GetTokenSilentlyVerboseResponse,
4+
ResponseType
45
} from '@auth0/auth0-spa-js';
56
import '@testing-library/jest-dom';
67
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
@@ -192,6 +193,7 @@ describe('Auth0Provider', () => {
192193
);
193194
clientMock.handleRedirectCallback.mockResolvedValueOnce({
194195
appState: undefined,
196+
response_type: ResponseType.Code
195197
});
196198
const wrapper = createWrapper();
197199
renderHook(() => useContext(Auth0Context), {
@@ -214,6 +216,7 @@ describe('Auth0Provider', () => {
214216
);
215217
clientMock.handleRedirectCallback.mockResolvedValueOnce({
216218
appState: { returnTo: '/foo' },
219+
response_type: ResponseType.Code
217220
});
218221
const wrapper = createWrapper();
219222
renderHook(() => useContext(Auth0Context), {
@@ -257,6 +260,7 @@ describe('Auth0Provider', () => {
257260
clientMock.getUser.mockResolvedValue(user);
258261
clientMock.handleRedirectCallback.mockResolvedValue({
259262
appState: { foo: 'bar' },
263+
response_type: ResponseType.Code
260264
});
261265
const onRedirectCallback = jest.fn();
262266
const wrapper = createWrapper({
@@ -266,7 +270,43 @@ describe('Auth0Provider', () => {
266270
wrapper,
267271
});
268272
await waitFor(() => {
269-
expect(onRedirectCallback).toHaveBeenCalledWith({ foo: 'bar' }, user);
273+
expect(onRedirectCallback).toHaveBeenCalledWith({ foo: 'bar', response_type: ResponseType.Code }, user);
274+
});
275+
});
276+
277+
it('should handle connect account redirect and call a custom handler', async () => {
278+
window.history.pushState(
279+
{},
280+
document.title,
281+
'/?connect_code=__test_code__&state=__test_state__'
282+
);
283+
const user = { name: '__test_user__' };
284+
const connectedAccount = {
285+
id: 'abc123',
286+
connection: 'google-oauth2',
287+
access_type: 'offline' as ConnectAccountRedirectResult['access_type'],
288+
created_at: '2024-01-01T00:00:00.000Z',
289+
expires_at: '2024-01-02T00:00:00.000Z',
290+
}
291+
clientMock.getUser.mockResolvedValue(user);
292+
clientMock.handleRedirectCallback.mockResolvedValue({
293+
appState: { foo: 'bar' },
294+
response_type: ResponseType.ConnectCode,
295+
...connectedAccount,
296+
});
297+
const onRedirectCallback = jest.fn();
298+
const wrapper = createWrapper({
299+
onRedirectCallback,
300+
});
301+
renderHook(() => useContext(Auth0Context), {
302+
wrapper,
303+
});
304+
await waitFor(() => {
305+
expect(onRedirectCallback).toHaveBeenCalledWith({
306+
foo: 'bar',
307+
response_type: ResponseType.ConnectCode,
308+
connectedAccount
309+
}, user);
270310
});
271311
});
272312

@@ -412,6 +452,35 @@ describe('Auth0Provider', () => {
412452
expect(warn).toHaveBeenCalled();
413453
});
414454

455+
it('should provide a connectAccountWithRedirect method', async () => {
456+
const wrapper = createWrapper();
457+
const { result } = renderHook(
458+
() => useContext(Auth0Context),
459+
{ wrapper }
460+
);
461+
await waitFor(() => {
462+
expect(result.current.connectAccountWithRedirect).toBeInstanceOf(Function);
463+
});
464+
await result.current.connectAccountWithRedirect({
465+
connection: 'google-apps'
466+
});
467+
expect(clientMock.connectAccountWithRedirect).toHaveBeenCalledWith({
468+
connection: 'google-apps',
469+
});
470+
});
471+
472+
it('should handle errors from connectAccountWithRedirect', async () => {
473+
const wrapper = createWrapper();
474+
const { result } = renderHook(
475+
() => useContext(Auth0Context),
476+
{ wrapper }
477+
);
478+
clientMock.connectAccountWithRedirect.mockRejectedValue(new Error('__test_error__'));
479+
await act(async () => {
480+
await expect(result.current.connectAccountWithRedirect).rejects.toThrow('__test_error__');
481+
});
482+
});
483+
415484
it('should provide a logout method', async () => {
416485
const user = { name: '__test_user__' };
417486
clientMock.getUser.mockResolvedValue(user);
@@ -814,6 +883,7 @@ describe('Auth0Provider', () => {
814883
it('should provide a handleRedirectCallback method', async () => {
815884
clientMock.handleRedirectCallback.mockResolvedValue({
816885
appState: { redirectUri: '/' },
886+
response_type: ResponseType.Code
817887
});
818888
const wrapper = createWrapper();
819889
const { result } = renderHook(
@@ -827,6 +897,7 @@ describe('Auth0Provider', () => {
827897
appState: {
828898
redirectUri: '/',
829899
},
900+
response_type: ResponseType.Code
830901
});
831902
});
832903
expect(clientMock.handleRedirectCallback).toHaveBeenCalled();
@@ -926,6 +997,7 @@ describe('Auth0Provider', () => {
926997
appState: {
927998
redirectUri: '/',
928999
},
1000+
response_type: ResponseType.Code
9291001
});
9301002
clientMock.getUser.mockResolvedValue(undefined);
9311003
const wrapper = createWrapper();
@@ -938,6 +1010,7 @@ describe('Auth0Provider', () => {
9381010
appState: {
9391011
redirectUri: '/',
9401012
},
1013+
response_type: ResponseType.Code
9411014
});
9421015
});
9431016

@@ -1012,6 +1085,7 @@ describe('Auth0Provider', () => {
10121085
);
10131086
clientMock.handleRedirectCallback.mockResolvedValue({
10141087
appState: undefined,
1088+
response_type: ResponseType.Code
10151089
});
10161090
render(
10171091
<StrictMode>

__tests__/utils.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ describe('utils hasAuthParams', () => {
4242
].forEach((search) => expect(hasAuthParams(search)).toBeTruthy());
4343
});
4444

45+
it('should recognise the connect_code and state param', async () => {
46+
[
47+
'?connect_code=1&state=2',
48+
'?foo=1&state=2&connect_code=3',
49+
'?connect_code=1&foo=2&state=3',
50+
'?state=1&connect_code=2&foo=3',
51+
].forEach((search) => expect(hasAuthParams(search)).toBeTruthy());
52+
});
53+
4554
it('should recognise the error and state param', async () => {
4655
[
4756
'?error=1&state=2',

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ module.exports = {
3535
statements: 100,
3636
},
3737
},
38+
setupFiles: ['./jest.setup.js']
3839
};

jest.setup.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// jest.setup.js
2+
const { TextEncoder, TextDecoder } = require('util');
3+
4+
if (typeof global.TextEncoder === 'undefined') {
5+
global.TextEncoder = TextEncoder;
6+
}
7+
if (typeof global.TextDecoder === 'undefined') {
8+
global.TextDecoder = TextDecoder;
9+
}

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,6 @@
9595
"react-dom": "^16.11.0 || ^17 || ^18 || ^19"
9696
},
9797
"dependencies": {
98-
"@auth0/auth0-spa-js": "^2.5.0"
98+
"@auth0/auth0-spa-js": "^2.6.0"
9999
}
100100
}

scripts/oidc-provider.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ const config = {
4242
webMessageResponseMode: {
4343
enabled: true,
4444
},
45+
dPoP: {
46+
enabled: true,
47+
}
4548
},
4649
rotateRefreshToken: true,
4750
interactions: {

0 commit comments

Comments
 (0)