diff --git a/EXAMPLES.md b/EXAMPLES.md index 9c0fa276..25c49466 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1984,4 +1984,88 @@ If initialization fails and the user subsequently signs in by some other means for example a `loginWithPopup` triggered from outside the boundary — the SDK re-checks the session once. If that check succeeds, retrying your Error Boundary renders the subtree normally; if it fails again, the boundary keeps showing the -error. \ No newline at end of file +error. + +## Enterprise Connect + +> Enterprise Connect is an Early Access feature. Confirm the tenant-side +> requirements with your Auth0 contact. + +Enterprise Connect layers enterprise SSO on top of your own auth server. The +`useEnterpriseConnect` hook exposes `isFederatedDomain` (WebFinger domain +discovery against your configured Auth0 domain) and `loginWithSSO` (a +`loginWithRedirect` that sets `login_hint`). + +Login form: discover the domain, then route to SSO or your own login. + +```jsx +import { useEnterpriseConnect } from '@auth0/auth0-react'; + +export function LoginForm() { + const { isFederatedDomain, loginWithSSO } = useEnterpriseConnect(); + + const handleSubmit = async (event) => { + event.preventDefault(); + const email = event.target.email.value; + const emailDomain = email.split('@')[1]; + + if (await isFederatedDomain(emailDomain)) { + await loginWithSSO(email, { + appState: { returnTo: window.location.pathname }, + }); + } else { + // your existing login flow + showPasswordForm(email); + } + }; + + return ( +
+ + +
+ ); +} +``` + +Callback route: complete the login, validate the organization, then read the +enriched claims. + +```jsx +import { useAuth0 } from '@auth0/auth0-react'; +import { useEffect, useRef } from 'react'; + +const ALLOWED_ORGS = ['org_123']; + +export function Callback() { + const { handleRedirectCallback, getIdTokenClaims, logout } = useAuth0(); + const handled = useRef(false); + + useEffect(() => { + if (handled.current) return; + handled.current = true; + + (async () => { + await handleRedirectCallback(); + const claims = await getIdTokenClaims(); + + if (!claims?.org_id || !ALLOWED_ORGS.includes(claims.org_id)) { + await logout({ logoutParams: { returnTo: window.location.origin } }); + return; + } + + console.log('Logged in as', claims.email, 'in org', claims.org_id); + })(); + }, [handleRedirectCallback, getIdTokenClaims, logout]); + + return

Completing login...

; +} +``` + +Logout must be federated to end the enterprise IdP session: + +```jsx +await logout({ + logoutParams: { federated: true, returnTo: window.location.origin }, +}); +``` \ No newline at end of file diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index 45f4174c..b1743577 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -103,3 +103,5 @@ export const PasskeyRegisterError = actual.PasskeyRegisterError; export const PasskeyChallengeError = actual.PasskeyChallengeError; export const PasskeyGetTokenError = actual.PasskeyGetTokenError; export const MyAccountApiError = actual.MyAccountApiError; + +export const isFederatedDomain = jest.fn(); diff --git a/__tests__/use-enterprise-connect.test.tsx b/__tests__/use-enterprise-connect.test.tsx new file mode 100644 index 00000000..33f9d834 --- /dev/null +++ b/__tests__/use-enterprise-connect.test.tsx @@ -0,0 +1,86 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { + isFederatedDomain as spaIsFederatedDomain, + Auth0Client, +} from '@auth0/auth0-spa-js'; +import useEnterpriseConnect from '../src/use-enterprise-connect'; +import { createWrapper } from './helpers'; + +jest.mock('@auth0/auth0-spa-js'); + +const clientMock = jest.mocked(new Auth0Client({ clientId: '', domain: '' })); +const federatedMock = jest.mocked(spaIsFederatedDomain); + +describe('useEnterpriseConnect', () => { + beforeEach(() => { + jest.clearAllMocks(); + clientMock.getConfiguration.mockReturnValue({ + domain: '__test_domain__', + clientId: '__test_client_id__', + }); + }); + + it('calls isFederatedDomain with the configured domain and email domain', async () => { + federatedMock.mockResolvedValueOnce(true); + const wrapper = createWrapper(); + const { result } = renderHook(() => useEnterpriseConnect(), { wrapper }); + + const federated = await result.current.isFederatedDomain('acme.com'); + + expect(federatedMock).toHaveBeenCalledWith( + '__test_domain__', + 'acme.com', + undefined + ); + expect(federated).toBe(true); + }); + + it('forwards options to isFederatedDomain', async () => { + federatedMock.mockResolvedValueOnce(false); + const customFetch = jest.fn(); + const wrapper = createWrapper(); + const { result } = renderHook(() => useEnterpriseConnect(), { wrapper }); + + await result.current.isFederatedDomain('acme.com', { customFetch }); + + expect(federatedMock).toHaveBeenCalledWith('__test_domain__', 'acme.com', { + customFetch, + }); + }); + + it('loginWithSSO calls loginWithRedirect with login_hint set from the email', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useEnterpriseConnect(), { wrapper }); + await waitFor(() => + expect(clientMock.loginWithRedirect).not.toBeNull() + ); + + await result.current.loginWithSSO('jane@acme.com'); + + expect(clientMock.loginWithRedirect).toHaveBeenCalledWith({ + authorizationParams: { login_hint: 'jane@acme.com' }, + }); + }); + + it('loginWithSSO preserves caller authorizationParams and other options', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useEnterpriseConnect(), { wrapper }); + + await result.current.loginWithSSO('jane@acme.com', { + authorizationParams: { + connection: 'okta', + organization: 'org_123', + }, + appState: { returnTo: '/dashboard' }, + }); + + expect(clientMock.loginWithRedirect).toHaveBeenCalledWith({ + appState: { returnTo: '/dashboard' }, + authorizationParams: { + connection: 'okta', + organization: 'org_123', + login_hint: 'jane@acme.com', + }, + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 5c167feb..9b283aa1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,6 +11,10 @@ export { default as useAuth0Suspense, Auth0SuspenseContextInterface, } from './use-auth0-suspense'; +export { + default as useEnterpriseConnect, + UseEnterpriseConnect, +} from './use-enterprise-connect'; export { default as withAuth0, WithAuth0Props } from './with-auth0'; export { default as withAuthenticationRequired, diff --git a/src/use-enterprise-connect.tsx b/src/use-enterprise-connect.tsx new file mode 100644 index 00000000..0a4f273a --- /dev/null +++ b/src/use-enterprise-connect.tsx @@ -0,0 +1,74 @@ +import { useCallback, useContext } from 'react'; +import { + isFederatedDomain as spaIsFederatedDomain, + IsFederatedDomainOptions, +} from '@auth0/auth0-spa-js'; +import Auth0Context, { + Auth0ContextInterface, + RedirectLoginOptions, +} from './auth0-context'; + +/** + * The shape returned by the `useEnterpriseConnect` hook. + */ +export interface UseEnterpriseConnect { + /** + * Runs WebFinger domain discovery for the given email domain against the + * Auth0 domain configured on the `Auth0Provider`. Returns `true` only if + * the domain is managed by Auth0 for enterprise SSO. A routing hint, not a + * security control: it returns `false` on any failure. + */ + isFederatedDomain: ( + emailDomain: string, + options?: IsFederatedDomainOptions + ) => Promise; + /** + * Starts the enterprise SSO redirect, passing the email as `login_hint` + * so Home Realm Discovery can resolve the connection and organization. Any + * `authorizationParams` supplied by the caller are preserved. + */ + loginWithSSO: ( + email: string, + options?: RedirectLoginOptions + ) => Promise; +} + +/** + * ```js + * const { isFederatedDomain, loginWithSSO } = useEnterpriseConnect(); + * ``` + * + * Convenience hook for the Enterprise Connect flow. `isFederatedDomain` reads + * the Auth0 domain from the `Auth0Provider` configuration, so callers pass + * only the email domain. `loginWithSSO` is sugar over `loginWithRedirect` + * that sets `login_hint` to the provided email. + */ +const useEnterpriseConnect = ( + context = Auth0Context +): UseEnterpriseConnect => { + const { getConfiguration, loginWithRedirect } = useContext( + context + ) as Auth0ContextInterface; + + const isFederatedDomain = useCallback( + (emailDomain: string, options?: IsFederatedDomainOptions) => + spaIsFederatedDomain(getConfiguration().domain, emailDomain, options), + [getConfiguration] + ); + + const loginWithSSO = useCallback( + (email: string, options?: RedirectLoginOptions) => + loginWithRedirect({ + ...options, + authorizationParams: { + ...options?.authorizationParams, + login_hint: email, + }, + }), + [loginWithRedirect] + ); + + return { isFederatedDomain, loginWithSSO }; +}; + +export default useEnterpriseConnect;