diff --git a/.gitignore b/.gitignore
index 73decfb7..34b2bfa5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -108,4 +108,4 @@ test-results
cypress/screenshots
cypress/videos
-.npmrc
+.npmrc
\ No newline at end of file
diff --git a/EXAMPLES.md b/EXAMPLES.md
index 7242d22b..0bc56771 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -9,7 +9,8 @@
- [Use with Auth0 organizations](#use-with-auth0-organizations)
- [Protecting a route with a claims check](#protecting-a-route-with-a-claims-check)
- [Device-bound tokens with DPoP](#device-bound-tokens-with-dpop)
-- [Using Multi Resource Refresh Tokens]()
+- [Using Multi Resource Refresh Tokens](#using-multi-resource-refresh-tokens)
+- [Connect Accounts for using Token Vault](#connect-accounts-for-using-token-vault)
## Use with a Class Component
@@ -597,3 +598,88 @@ MRRT is disabled by default. To enable it, set the `useMrrt` option to `true` wh
> [!IMPORTANT]
> In order MRRT to work, it needs a previous configuration setting the refresh token policies.
> Visit [configure and implement MRRT.](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token/configure-and-implement-multi-resource-refresh-token)
+
+## 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.
+
+```jsx
+' // The API that will use the tokens from the Token Vault
+ }}
+ useRefreshTokens={true}
+ useMrrt={true}
+ useDpop={true}
+>
+
+
+```
+
+### Login to the application
+
+Use the login methods to authenticate to the application and get a refresh and access token for the API.
+
+```jsx
+const Login = () => {
+ const { loginWithRedirect } = useAuth0();
+ return ;
+};
+```
+
+### 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.
+
+```jsx
+const ConnectAccount = () => {
+ const { connectAccountWithRedirect } = useAuth0();
+ return ;
+};
+```
+
+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.
+
+```jsx
+ {
+ if (appState.connectedAccount) {
+ console.log(`You've connected to ${appState.connectedAccount.connection}`);
+ }
+ window.history.replaceState({}, document.title, '/');
+ }}
+>
+
+
+```
+
+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/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx
index 713e5d6d..9cc9da1b 100644
--- a/__mocks__/@auth0/auth0-spa-js.tsx
+++ b/__mocks__/@auth0/auth0-spa-js.tsx
@@ -1,3 +1,5 @@
+const actual = jest.requireActual('@auth0/auth0-spa-js');
+
const handleRedirectCallback = jest.fn(() => ({ appState: {} }));
const buildLogoutUrl = jest.fn();
const buildAuthorizeUrl = jest.fn();
@@ -9,6 +11,7 @@ const getIdTokenClaims = jest.fn();
const isAuthenticated = jest.fn(() => false);
const loginWithPopup = jest.fn();
const loginWithRedirect = jest.fn();
+const connectAccountWithRedirect = jest.fn();
const logout = jest.fn();
const getDpopNonce = jest.fn();
const setDpopNonce = jest.fn();
@@ -28,6 +31,7 @@ export const Auth0Client = jest.fn(() => {
isAuthenticated,
loginWithPopup,
loginWithRedirect,
+ connectAccountWithRedirect,
logout,
getDpopNonce,
setDpopNonce,
@@ -35,3 +39,5 @@ export const Auth0Client = jest.fn(() => {
createFetcher,
};
});
+
+export const ResponseType = actual.ResponseType;
\ No newline at end of file
diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx
index 143d6422..80a99214 100644
--- a/__tests__/auth-provider.test.tsx
+++ b/__tests__/auth-provider.test.tsx
@@ -1,6 +1,7 @@
import {
- Auth0Client,
+ Auth0Client, ConnectAccountRedirectResult,
GetTokenSilentlyVerboseResponse,
+ ResponseType
} from '@auth0/auth0-spa-js';
import '@testing-library/jest-dom';
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
@@ -192,6 +193,7 @@ describe('Auth0Provider', () => {
);
clientMock.handleRedirectCallback.mockResolvedValueOnce({
appState: undefined,
+ response_type: ResponseType.Code
});
const wrapper = createWrapper();
renderHook(() => useContext(Auth0Context), {
@@ -214,6 +216,7 @@ describe('Auth0Provider', () => {
);
clientMock.handleRedirectCallback.mockResolvedValueOnce({
appState: { returnTo: '/foo' },
+ response_type: ResponseType.Code
});
const wrapper = createWrapper();
renderHook(() => useContext(Auth0Context), {
@@ -257,6 +260,7 @@ describe('Auth0Provider', () => {
clientMock.getUser.mockResolvedValue(user);
clientMock.handleRedirectCallback.mockResolvedValue({
appState: { foo: 'bar' },
+ response_type: ResponseType.Code
});
const onRedirectCallback = jest.fn();
const wrapper = createWrapper({
@@ -266,7 +270,43 @@ describe('Auth0Provider', () => {
wrapper,
});
await waitFor(() => {
- expect(onRedirectCallback).toHaveBeenCalledWith({ foo: 'bar' }, user);
+ expect(onRedirectCallback).toHaveBeenCalledWith({ foo: 'bar', response_type: ResponseType.Code }, user);
+ });
+ });
+
+ it('should handle connect account redirect and call a custom handler', async () => {
+ window.history.pushState(
+ {},
+ document.title,
+ '/?connect_code=__test_code__&state=__test_state__'
+ );
+ const user = { name: '__test_user__' };
+ 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',
+ }
+ clientMock.getUser.mockResolvedValue(user);
+ clientMock.handleRedirectCallback.mockResolvedValue({
+ appState: { foo: 'bar' },
+ response_type: ResponseType.ConnectCode,
+ ...connectedAccount,
+ });
+ const onRedirectCallback = jest.fn();
+ const wrapper = createWrapper({
+ onRedirectCallback,
+ });
+ renderHook(() => useContext(Auth0Context), {
+ wrapper,
+ });
+ await waitFor(() => {
+ expect(onRedirectCallback).toHaveBeenCalledWith({
+ foo: 'bar',
+ response_type: ResponseType.ConnectCode,
+ connectedAccount
+ }, user);
});
});
@@ -412,6 +452,35 @@ describe('Auth0Provider', () => {
expect(warn).toHaveBeenCalled();
});
+ it('should provide a connectAccountWithRedirect method', async () => {
+ const wrapper = createWrapper();
+ const { result } = renderHook(
+ () => useContext(Auth0Context),
+ { wrapper }
+ );
+ await waitFor(() => {
+ expect(result.current.connectAccountWithRedirect).toBeInstanceOf(Function);
+ });
+ await result.current.connectAccountWithRedirect({
+ connection: 'google-apps'
+ });
+ expect(clientMock.connectAccountWithRedirect).toHaveBeenCalledWith({
+ connection: 'google-apps',
+ });
+ });
+
+ it('should handle errors from connectAccountWithRedirect', async () => {
+ const wrapper = createWrapper();
+ const { result } = renderHook(
+ () => useContext(Auth0Context),
+ { wrapper }
+ );
+ clientMock.connectAccountWithRedirect.mockRejectedValue(new Error('__test_error__'));
+ await act(async () => {
+ await expect(result.current.connectAccountWithRedirect).rejects.toThrow('__test_error__');
+ });
+ });
+
it('should provide a logout method', async () => {
const user = { name: '__test_user__' };
clientMock.getUser.mockResolvedValue(user);
@@ -814,6 +883,7 @@ describe('Auth0Provider', () => {
it('should provide a handleRedirectCallback method', async () => {
clientMock.handleRedirectCallback.mockResolvedValue({
appState: { redirectUri: '/' },
+ response_type: ResponseType.Code
});
const wrapper = createWrapper();
const { result } = renderHook(
@@ -827,6 +897,7 @@ describe('Auth0Provider', () => {
appState: {
redirectUri: '/',
},
+ response_type: ResponseType.Code
});
});
expect(clientMock.handleRedirectCallback).toHaveBeenCalled();
@@ -926,6 +997,7 @@ describe('Auth0Provider', () => {
appState: {
redirectUri: '/',
},
+ response_type: ResponseType.Code
});
clientMock.getUser.mockResolvedValue(undefined);
const wrapper = createWrapper();
@@ -938,6 +1010,7 @@ describe('Auth0Provider', () => {
appState: {
redirectUri: '/',
},
+ response_type: ResponseType.Code
});
});
@@ -1012,6 +1085,7 @@ describe('Auth0Provider', () => {
);
clientMock.handleRedirectCallback.mockResolvedValue({
appState: undefined,
+ response_type: ResponseType.Code
});
render(
diff --git a/__tests__/utils.test.tsx b/__tests__/utils.test.tsx
index f1f128ee..1cf027d7 100644
--- a/__tests__/utils.test.tsx
+++ b/__tests__/utils.test.tsx
@@ -42,6 +42,15 @@ describe('utils hasAuthParams', () => {
].forEach((search) => expect(hasAuthParams(search)).toBeTruthy());
});
+ it('should recognise the connect_code and state param', async () => {
+ [
+ '?connect_code=1&state=2',
+ '?foo=1&state=2&connect_code=3',
+ '?connect_code=1&foo=2&state=3',
+ '?state=1&connect_code=2&foo=3',
+ ].forEach((search) => expect(hasAuthParams(search)).toBeTruthy());
+ });
+
it('should recognise the error and state param', async () => {
[
'?error=1&state=2',
diff --git a/jest.config.js b/jest.config.js
index c6975af9..b1928654 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -35,4 +35,5 @@ module.exports = {
statements: 100,
},
},
+ setupFiles: ['./jest.setup.js']
};
diff --git a/jest.setup.js b/jest.setup.js
new file mode 100644
index 00000000..dbf0f91e
--- /dev/null
+++ b/jest.setup.js
@@ -0,0 +1,9 @@
+// jest.setup.js
+const { TextEncoder, TextDecoder } = require('util');
+
+if (typeof global.TextEncoder === 'undefined') {
+ global.TextEncoder = TextEncoder;
+}
+if (typeof global.TextDecoder === 'undefined') {
+ global.TextDecoder = TextDecoder;
+}
diff --git a/package-lock.json b/package-lock.json
index 5bbeb1b8..cff235fb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "2.6.0",
"license": "MIT",
"dependencies": {
- "@auth0/auth0-spa-js": "^2.5.0"
+ "@auth0/auth0-spa-js": "^2.6.0"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -74,9 +74,9 @@
}
},
"node_modules/@auth0/auth0-spa-js": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.5.0.tgz",
- "integrity": "sha512-4Xvx2E4N2Airr1Rp+KOejdDbpj5LnfLtNZ/TaOdo7VbRGmpWAYkyGFcF9BBQSQ10rGhFn/GaBHIZECyskruAfg==",
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.6.0.tgz",
+ "integrity": "sha512-zxqgmXObIv4V8kt6Dz2MU4lWTH72b3RL7Po5C+HDU8zCg5rm5VL/aYXIo407B0uVUIFH44ph0tusMLBoILg7kw==",
"license": "MIT",
"dependencies": {
"browser-tabs-lock": "^1.2.15",
diff --git a/package.json b/package.json
index c9a5d32b..c36bf276 100644
--- a/package.json
+++ b/package.json
@@ -95,6 +95,6 @@
"react-dom": "^16.11.0 || ^17 || ^18 || ^19"
},
"dependencies": {
- "@auth0/auth0-spa-js": "^2.5.0"
+ "@auth0/auth0-spa-js": "^2.6.0"
}
}
diff --git a/scripts/oidc-provider.mjs b/scripts/oidc-provider.mjs
index 86616757..f7094d5f 100644
--- a/scripts/oidc-provider.mjs
+++ b/scripts/oidc-provider.mjs
@@ -42,6 +42,9 @@ const config = {
webMessageResponseMode: {
enabled: true,
},
+ dPoP: {
+ enabled: true,
+ }
},
rotateRefreshToken: true,
interactions: {
diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx
index 8ddd046e..d7343a9c 100644
--- a/src/auth0-context.tsx
+++ b/src/auth0-context.tsx
@@ -10,6 +10,8 @@ import {
GetTokenSilentlyVerboseResponse,
RedirectLoginOptions as SPARedirectLoginOptions,
type Auth0Client,
+ RedirectConnectAccountOptions,
+ ConnectAccountRedirectResult
} from '@auth0/auth0-spa-js';
import { createContext } from 'react';
import { AuthState, initialAuthState } from './auth-state';
@@ -120,6 +122,28 @@ export interface Auth0ContextInterface
config?: PopupConfigOptions
) => Promise;
+ /**
+ * ```js
+ * await connectAccountWithRedirect({
+ * connection: 'google-oauth2',
+ * authorizationParams: {
+ * access_type: 'offline',
+ * scope: 'openid profile email https://www.googleapis.com/auth/drive.readonly',
+ * }
+ * });
+ * ```
+ *
+ * Redirects to the `/connect` URL using the parameters
+ * provided as arguments. This then redirects to the connection's login page
+ * where the user can authenticate and authorize the account to be connected.
+ *
+ * If connecting the account is successful `onRedirectCallback` will be called
+ * with the details of the connected account.
+ */
+ connectAccountWithRedirect: (
+ options: RedirectConnectAccountOptions
+ ) => Promise;
+
/**
* ```js
* auth0.logout({ logoutParams: { returnTo: window.location.origin } });
@@ -140,7 +164,7 @@ export interface Auth0ContextInterface
*
* @param url The URL to that should be used to retrieve the `state` and `code` values. Defaults to `window.location.href` if not given.
*/
- handleRedirectCallback: (url?: string) => Promise;
+ handleRedirectCallback: (url?: string) => Promise;
/**
* Returns the current DPoP nonce used for making requests to Auth0.
@@ -207,6 +231,7 @@ export const initialContext = {
getIdTokenClaims: stub,
loginWithRedirect: stub,
loginWithPopup: stub,
+ connectAccountWithRedirect: stub,
logout: stub,
handleRedirectCallback: stub,
getDpopNonce: stub,
diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx
index c6bcd5aa..d3dc27cb 100644
--- a/src/auth0-provider.tsx
+++ b/src/auth0-provider.tsx
@@ -15,6 +15,9 @@ import {
RedirectLoginResult,
GetTokenSilentlyOptions,
User,
+ RedirectConnectAccountOptions,
+ ConnectAccountRedirectResult,
+ ResponseType
} from '@auth0/auth0-spa-js';
import Auth0Context, {
Auth0ContextInterface,
@@ -31,10 +34,18 @@ import { reducer } from './reducer';
import { initialAuthState, type AuthState } from './auth-state';
/**
- * The state of the application before the user was redirected to the login page.
+ * The account that has been connected during the connect flow.
+ */
+export type ConnectedAccount = Omit;
+
+/**
+ * The state of the application before the user was redirected to the login page
+ * and any account that the user may have connected to.
*/
export type AppState = {
returnTo?: string;
+ connectedAccount?: ConnectedAccount;
+ response_type?: ResponseType;
[key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
};
@@ -116,7 +127,7 @@ const defaultOnRedirectCallback = (appState?: AppState): void => {
window.history.replaceState(
{},
document.title,
- appState?.returnTo ?? window.location.pathname
+ appState!.returnTo ?? window.location.pathname
);
};
@@ -160,8 +171,12 @@ const Auth0Provider = (opts: Auth0ProviderOptions(opts: Auth0ProviderOptions
+ client.connectAccountWithRedirect(options),
+ [client]
+ );
+
const getIdTokenClaims = useCallback(
() => client.getIdTokenClaims(),
[client]
);
const handleRedirectCallback = useCallback(
- async (url?: string): Promise => {
+ async (
+ url?: string
+ ): Promise => {
try {
return await client.handleRedirectCallback(url);
} catch (error) {
@@ -300,6 +323,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions(opts: Auth0ProviderOptions
+
+