diff --git a/EXAMPLES.md b/EXAMPLES.md index 462ba23b..20992283 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -23,6 +23,7 @@ - [MyAccount API](#myaccount-api) - [Session Expiry from Upstream IdP (IPSIE)](#session-expiry-from-upstream-idp-ipsie) - [Use Suspense for loading state (React 19+)](#use-suspense-for-loading-state-react-19) +- [Enterprise Connect](#enterprise-connect) ## Use with a Class Component @@ -1984,4 +1985,131 @@ 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`). + +`isFederatedDomain` makes a browser-direct cross-origin WebFinger request to +your Auth0 tenant domain. Configure `domain` to the tenant domain +(`YOUR_TENANT.auth0.com` or a custom domain with the WebFinger route enabled), +not a custom domain that omits it: requests to the wrong host will fail and +`isFederatedDomain` will return `false`. + +Set `enterpriseConnect={true}` to put the SDK into this mode. + +Enterprise Connect issues no refresh token, so the access token expires at the +configured token lifetime with no silent renewal. Plan to re-authenticate the +user through the login flow when the token expires; `getAccessTokenSilently` +will not refresh it. + +Treat Enterprise Connect as identity only: extract the ID token claims after +login and issue your own application session or API tokens from them. Do not +rely on the Auth0 access token for long-lived API authorization. + +```jsx + + + +``` + +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 { + // Not a federated domain: hand off to your existing login flow + // (e.g. render your password form). Replace with your own routing. + } + }; + + return ( +
+ + +
+ ); +} +``` + +After the SSO redirect, `Auth0Provider` completes the token exchange +automatically. Read the settled auth state in your `App` component to validate +the session and navigate. + +Validating the `org_id` claim is an **optional** application-level +authorization step, not an SDK requirement. Add it only if your app restricts +access to specific organizations. A federated user on a connection that +predates `org_id` claims will not have one, so treat a missing `org_id` as "not +org-scoped" rather than an automatic failure. + +```jsx +import { useAuth0 } from '@auth0/auth0-react'; +import { useEffect } from 'react'; + +// Optional: only if your app restricts access to specific organizations. +const ALLOWED_ORGS = ['org_123']; + +export function App() { + const { isLoading, isAuthenticated, getIdTokenClaims, logout } = useAuth0(); + + useEffect(() => { + if (isLoading || !isAuthenticated) return; + + (async () => { + const claims = await getIdTokenClaims(); + + // Optional org check. Remove this block if you do not gate on org. + if (claims?.org_id && !ALLOWED_ORGS.includes(claims.org_id)) { + // Federated logout ends the enterprise IdP session too, so the next + // login runs email discovery again instead of silently re-using SSO. + await logout({ + logoutParams: { federated: true, returnTo: window.location.origin }, + }); + return; + } + + // Session valid. Render your app or navigate to the post-login destination. + })(); + }, [isLoading, isAuthenticated, getIdTokenClaims, logout]); + + if (isLoading) return

Loading...

; + + return
{/* your app */}
; +} +``` + +Logout must be federated to end the enterprise IdP session: + +```jsx +await logout({ + logoutParams: { federated: true, returnTo: window.location.origin }, +}); +``` + +The `returnTo` URL must be registered in the application's **Allowed Logout +URLs** in the Auth0 Dashboard, or the logout redirect will be rejected. \ No newline at end of file diff --git a/README.md b/README.md index 2b13abe9..09b3a7c5 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Explore public API's available in auth0-react. - [useAuth0Suspense](https://auth0.github.io/auth0-react/functions/useAuth0Suspense.html) - [withAuth0](https://auth0.github.io/auth0-react/functions/withAuth0.html) - [withAuthenticationRequired](https://auth0.github.io/auth0-react/functions/withAuthenticationRequired.html) +- [useEnterpriseConnect](https://auth0.github.io/auth0-react/functions/useEnterpriseConnect.html) ## Feedback 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__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx index 8edc5ce7..d6775f63 100644 --- a/__tests__/auth-provider.test.tsx +++ b/__tests__/auth-provider.test.tsx @@ -89,6 +89,27 @@ describe('Auth0Provider', () => { }); }); + it('should forward the enterpriseConnect flag to Auth0Client', async () => { + const opts = { + clientId: 'foo', + domain: 'bar', + enterpriseConnect: true, + }; + const wrapper = createWrapper(opts); + renderHook(() => useContext(Auth0Context), { + wrapper, + }); + await waitFor(() => { + expect(Auth0Client).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: 'foo', + domain: 'bar', + enterpriseConnect: true, + }) + ); + }); + }); + it('should support redirectUri', async () => { const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); const opts = { 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/package-lock.json b/package-lock.json index f0d16caf..c4e8d63f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.25.0", "license": "MIT", "dependencies": { - "@auth0/auth0-spa-js": "^2.25.0" + "@auth0/auth0-spa-js": "^2.26.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.0.1", @@ -80,20 +80,18 @@ "license": "ISC" }, "node_modules/@auth0/auth0-auth-js": { - "version": "1.10.0", - "integrity": "sha512-FDzc0lbaG1E++4sjx3zW7BB9gVK2ODLmuPStGxYPeclKwLMFKkmVyWR8bCyQVNXvTS1KCHeX0dC9HMTJ0WBCJg==", - "license": "MIT", + "version": "1.15.0", + "integrity": "sha512-obn/Qhxx3eNs2wV6/lLsIDIcEiSJvBEEy15hppB5Me1j2No2toa7KqQcXHBCyGX4mBBdVnxZJBjEX6E4WanYwg==", "dependencies": { "jose": "^6.0.8", "openid-client": "^6.8.0" } }, "node_modules/@auth0/auth0-spa-js": { - "version": "2.25.0", - "integrity": "sha512-ISqLDRQcxFfF678AgtYDCdXESHX9W8II339xj/F6ujIptjK/ZkcTwq4HJs3RG7F24xRq1Xx1Vsc2uSCpyCMcUA==", - "license": "MIT", + "version": "2.26.0", + "integrity": "sha512-pv2Si9bJioQqTgk/doh0nIKgnpF1TEW/CvPmC1eYUgwPk/cUIwIzNSPShFBM4G2MnR+pbeD2HhFH6ChJAzIeqg==", "dependencies": { - "@auth0/auth0-auth-js": "^1.10.0", + "@auth0/auth0-auth-js": "^1.15.0", "browser-tabs-lock": "^1.3.0", "dpop": "^2.1.1", "es-cookie": "~1.3.2" @@ -7812,9 +7810,8 @@ } }, "node_modules/jose": { - "version": "6.2.3", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", + "version": "6.2.12", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -9089,9 +9086,8 @@ "license": "MIT" }, "node_modules/oauth4webapi": { - "version": "3.8.6", - "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", - "license": "MIT", + "version": "3.8.8", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -9290,12 +9286,11 @@ "dev": true }, "node_modules/openid-client": { - "version": "6.8.4", - "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", - "license": "MIT", + "version": "6.8.8", + "integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==", "dependencies": { - "jose": "^6.2.2", - "oauth4webapi": "^3.8.5" + "jose": "^6.2.12", + "oauth4webapi": "^3.8.8" }, "funding": { "url": "https://github.com/sponsors/panva" diff --git a/package.json b/package.json index 738e3e0d..fc22a05b 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,6 @@ "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" }, "dependencies": { - "@auth0/auth0-spa-js": "^2.25.0" + "@auth0/auth0-spa-js": "^2.26.0" } } diff --git a/src/index.tsx b/src/index.tsx index 5c167feb..ab92f3ac 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, @@ -76,6 +80,7 @@ export type { RevokeRefreshTokenOptions, FetcherConfig, InteractiveErrorHandler, + IsFederatedDomainOptions, // MFA Types MfaApiClient, Authenticator, diff --git a/src/use-enterprise-connect.tsx b/src/use-enterprise-connect.tsx new file mode 100644 index 00000000..4b0e2496 --- /dev/null +++ b/src/use-enterprise-connect.tsx @@ -0,0 +1,75 @@ +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( + // domain is read at call time, not when the callback is memoized + (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;