Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,4 @@ test-results

cypress/screenshots
cypress/videos
.npmrc
.npmrc
88 changes: 87 additions & 1 deletion EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
<Auth0Provider
domain="YOUR_AUTH0_DOMAIN"
clientId="YOUR_AUTH0_CLIENT_ID"
authorizationParams={{
redirect_uri: window.location.origin,
audience: '<AUTH0 API IDENTIFIER>' // The API that will use the tokens from the Token Vault
}}
useRefreshTokens={true}
useMrrt={true}
useDpop={true}
>
<App />
</Auth0Provider>
```

### 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 <button onClick={() => loginWithRedirect({
authorizationParams: {
audience: '<AUTH0 API IDENTIFIER>', // The API that will use the tokens from the Token Vault
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
}
})}>Login</button>;
};
```

### 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 <button onClick={() => connectAccountWithRedirect({
connection: '<CONNECTION eg, google-apps-connection>',
access_type: 'offline', // You must also request a refresh token from the third party Identity Provider for it to be stored in Token Vault.
authorization_params: {
scope: '<SCOPE eg https://www.googleapis.com/auth/calendar.acls.readonly>'
}
})}>Connect Google Calendar</button>;
};
```

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
<Auth0Provider
// ...
onRedirectCallback={(appState) => {
if (appState.connectedAccount) {
console.log(`You've connected to ${appState.connectedAccount.connection}`);
}
window.history.replaceState({}, document.title, '/');
}}
>
<App />
</Auth0Provider>
```

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.
6 changes: 6 additions & 0 deletions __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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();
Expand All @@ -28,10 +31,13 @@ export const Auth0Client = jest.fn(() => {
isAuthenticated,
loginWithPopup,
loginWithRedirect,
connectAccountWithRedirect,
logout,
getDpopNonce,
setDpopNonce,
generateDpopProof,
createFetcher,
};
});

export const ResponseType = actual.ResponseType;
78 changes: 76 additions & 2 deletions __tests__/auth-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -192,6 +193,7 @@ describe('Auth0Provider', () => {
);
clientMock.handleRedirectCallback.mockResolvedValueOnce({
appState: undefined,
response_type: ResponseType.Code
});
const wrapper = createWrapper();
renderHook(() => useContext(Auth0Context), {
Expand All @@ -214,6 +216,7 @@ describe('Auth0Provider', () => {
);
clientMock.handleRedirectCallback.mockResolvedValueOnce({
appState: { returnTo: '/foo' },
response_type: ResponseType.Code
});
const wrapper = createWrapper();
renderHook(() => useContext(Auth0Context), {
Expand Down Expand Up @@ -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({
Expand All @@ -266,7 +270,43 @@ describe('Auth0Provider', () => {
wrapper,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent test expectations: This test now expects response_type to be merged into the appState object, but this changes the API surface. Existing consumers of onRedirectCallback will receive a different object shape than before (with an additional response_type property).

While this might be intentional for the new feature, it's a breaking change that should be documented in the migration guide or release notes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a new field to the return object of an api is not a breaking change, customers will only need to change their code if they're trying to implement a function that behaves the same as handleRedirectCallback - as we are doing here with the mock. (they may have to change their mocks if they're doing the same, but I don't believe this qualifies as a breaking change)

});
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({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage: There's no test case for when response_type is ResponseType.ConnectCode but the result object is malformed or missing expected properties. This edge case should be tested to ensure the code handles invalid responses gracefully.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not putting in any additional logic to handle this case (see #912 (comment)) - so there's nothing to test

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);
});
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -827,6 +897,7 @@ describe('Auth0Provider', () => {
appState: {
redirectUri: '/',
},
response_type: ResponseType.Code
});
});
expect(clientMock.handleRedirectCallback).toHaveBeenCalled();
Expand Down Expand Up @@ -926,6 +997,7 @@ describe('Auth0Provider', () => {
appState: {
redirectUri: '/',
},
response_type: ResponseType.Code
});
clientMock.getUser.mockResolvedValue(undefined);
const wrapper = createWrapper();
Expand All @@ -938,6 +1010,7 @@ describe('Auth0Provider', () => {
appState: {
redirectUri: '/',
},
response_type: ResponseType.Code
});
});

Expand Down Expand Up @@ -1012,6 +1085,7 @@ describe('Auth0Provider', () => {
);
clientMock.handleRedirectCallback.mockResolvedValue({
appState: undefined,
response_type: ResponseType.Code
});
render(
<StrictMode>
Expand Down
9 changes: 9 additions & 0 deletions __tests__/utils.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ module.exports = {
statements: 100,
},
},
setupFiles: ['./jest.setup.js']
};
9 changes: 9 additions & 0 deletions jest.setup.js
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm importing the actual Auth0Client into the tests (to get ResponseType) so we need the same as https://github.com/auth0/auth0-spa-js/blob/main/jest.environment.js#L17-L18

Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 3 additions & 0 deletions scripts/oidc-provider.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ const config = {
webMessageResponseMode: {
enabled: true,
},
dPoP: {
enabled: true,
}
},
rotateRefreshToken: true,
interactions: {
Expand Down
Loading
Loading