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
+ +