diff --git a/EXAMPLES.md b/EXAMPLES.md index 1f5d662b9..3446c676c 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2622,15 +2622,23 @@ export const auth0 = new Auth0Client({ login: "/login", logout: "/logout", callback: "/callback", - backChannelLogout: "/backchannel-logout" + backChannelLogout: "/backchannel-logout", + profile: "/api/me", + accessToken: "/api/auth/token" } }); ``` -> [!NOTE] +> [!NOTE] > If you customize the login url you will need to set the environment variable `NEXT_PUBLIC_LOGIN_ROUTE` to this custom value for `withPageAuthRequired` to work correctly. -To configure the profile and access token routes, you must use the `NEXT_PUBLIC_PROFILE_ROUTE` and `NEXT_PUBLIC_ACCESS_TOKEN_ROUTE`, respectively. For example: +#### Configuring routes for client-side usage + +When customizing the `profile` and `accessToken` routes, you need to ensure that client-side functions (`useUser`, `getAccessToken`) and the `Auth0Provider` use the correct routes. There are two approaches: + +**Option 1: Using environment variables (recommended for most cases)** + +Set the environment variables in your `.env.local` file: ``` # .env.local @@ -2640,7 +2648,40 @@ NEXT_PUBLIC_PROFILE_ROUTE=/api/me NEXT_PUBLIC_ACCESS_TOKEN_ROUTE=/api/auth/token ``` -> [!IMPORTANT] +**Option 2: Passing routes programmatically (recommended for multi-tenant applications)** + +For multi-tenant applications where routes may vary by tenant at runtime, you can pass the route directly to the client-side functions: + +```tsx +import { useUser, getAccessToken, Auth0Provider } from "@auth0/nextjs-auth0/client"; + +// In your component +function MyComponent() { + const { user } = useUser({ route: "/tenant-a/auth/profile" }); + + const handleGetToken = async () => { + const token = await getAccessToken({ + route: "/tenant-a/auth/access-token" + }); + }; + + return
{user?.name}
; +} + +// In your layout +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} +``` + +> [!IMPORTANT] +> When using `useUser` with a custom route, ensure the `Auth0Provider` is configured with the same `profileRoute` to properly initialize the SWR cache. + +> [!IMPORTANT] > Updating the route paths will also require updating the **Allowed Callback URLs** and **Allowed Logout URLs** configured in the [Auth0 Dashboard](https://manage.auth0.com) for your client. ## Testing helpers diff --git a/src/client/helpers/get-access-token.test.ts b/src/client/helpers/get-access-token.test.ts index 9ec96e8c1..5adb1d30d 100644 --- a/src/client/helpers/get-access-token.test.ts +++ b/src/client/helpers/get-access-token.test.ts @@ -52,6 +52,9 @@ export const restHandlers = [ } return HttpResponse.json({ token }); + }), + http.get("/custom/token", () => { + return HttpResponse.json({ token: "" }); }) ]; @@ -119,4 +122,34 @@ describe("getAccessToken", () => { }) ).rejects.toThrowError("The request is missing a required parameter."); }); + + it("should use custom route when provided", async () => { + const result = await getAccessToken({ + route: "/custom/token" + }); + + expect(result).toBe(""); + }); + + it("should use custom route with audience and scope", async () => { + server.use( + http.get("/custom/token", ({ request }) => { + const url = new URL(request.url); + const audience = url.searchParams.get("audience"); + const scope = url.searchParams.get("scope"); + + return HttpResponse.json({ + token: `` + }); + }) + ); + + const result = await getAccessToken({ + route: "/custom/token", + audience: "custom_audience", + scope: "read:custom" + }); + + expect(result).toBe(""); + }); }); diff --git a/src/client/helpers/get-access-token.ts b/src/client/helpers/get-access-token.ts index 30e1c5f29..dede90d82 100644 --- a/src/client/helpers/get-access-token.ts +++ b/src/client/helpers/get-access-token.ts @@ -21,6 +21,11 @@ import { normalizeWithBasePath } from "../../utils/pathUtils.js"; * const ordersToken = await getAccessToken({ * audience: 'https://orders-api.example.com' * }); + * + * // Custom route - useful for multi-tenant applications + * const token = await getAccessToken({ + * route: '/tenant-a/auth/access-token' + * }); * ``` */ export type AccessTokenOptions = { @@ -49,6 +54,15 @@ export type AccessTokenOptions = { * @example 'https://orders-api.mycompany.com' */ audience?: string; + + /** + * Custom route for the access token endpoint. + * Useful for multi-tenant applications where different tenants require different route configurations. + * If not specified, falls back to the NEXT_PUBLIC_ACCESS_TOKEN_ROUTE environment variable or "/auth/access-token". + * + * @example '/tenant-a/auth/access-token' + */ + route?: string; }; type AccessTokenResponse = { @@ -81,7 +95,9 @@ export async function getAccessToken( } let url = normalizeWithBasePath( - process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || "/auth/access-token" + options.route || + process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE || + "/auth/access-token" ); // Only append the query string if we have any url parameters to add diff --git a/src/client/helpers/with-page-auth-required.test.tsx b/src/client/helpers/with-page-auth-required.test.tsx index 74529a859..e5cc26f23 100644 --- a/src/client/helpers/with-page-auth-required.test.tsx +++ b/src/client/helpers/with-page-auth-required.test.tsx @@ -250,9 +250,8 @@ describe("with-page-auth-required csr", () => { expect(window.location.assign).toHaveBeenCalled(); }); const url = new URL( - ( - window.location.assign as MockedFunction - ).mock.calls[0][0], + (window.location.assign as MockedFunction) + .mock.calls[0][0], "https://example.com" ); expect(url.searchParams.get("returnTo")).toEqual("/foo?bar=baz&qux=quux"); diff --git a/src/client/hooks/use-user.integration.test.tsx b/src/client/hooks/use-user.integration.test.tsx index c6087f55e..c4e895e2c 100644 --- a/src/client/hooks/use-user.integration.test.tsx +++ b/src/client/hooks/use-user.integration.test.tsx @@ -170,4 +170,79 @@ describe("useUser Integration with SWR Cache", () => { expect(fetchSpy).toHaveBeenCalledOnce(); expect(fetchSpy).toHaveBeenCalledWith("/auth/profile"); }); + + it("should use custom route when provided", async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify(initialUser), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map() }}> + {children} + + ); + + const { result } = renderHook(() => useUser({ route: "/custom/profile" }), { + wrapper + }); + + // Wait for the initial data to load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.user).toEqual(initialUser); + expect(result.current.error).toBe(null); + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(fetchSpy).toHaveBeenCalledWith("/custom/profile"); + }); + + it("should use custom route and invalidate correctly", async () => { + // Mock fetch to return initial data first + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify(initialUser), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map() }}> + {children} + + ); + + const { result } = renderHook( + () => useUser({ route: "/tenant-a/auth/profile" }), + { wrapper } + ); + + // Wait for the initial data to load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.user).toEqual(initialUser); + expect(fetchSpy).toHaveBeenCalledWith("/tenant-a/auth/profile"); + + // Mock fetch to return updated data for the next call + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify(updatedUser), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + + // Call invalidate to trigger re-fetch + await act(async () => { + result.current.invalidate(); + }); + + // Wait for the hook to reflect the updated data + await waitFor(() => expect(result.current.user).toEqual(updatedUser)); + + // Assert both fetch calls used the custom route + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenNthCalledWith(1, "/tenant-a/auth/profile"); + expect(fetchSpy).toHaveBeenNthCalledWith(2, "/tenant-a/auth/profile"); + }); }); diff --git a/src/client/hooks/use-user.ts b/src/client/hooks/use-user.ts index 1358a36a0..ccec6dabd 100644 --- a/src/client/hooks/use-user.ts +++ b/src/client/hooks/use-user.ts @@ -5,10 +5,24 @@ import useSWR from "swr"; import type { User } from "../../types/index.js"; import { normalizeWithBasePath } from "../../utils/pathUtils.js"; -export function useUser() { +/** + * Options for the useUser hook. + */ +export type UseUserOptions = { + /** + * Custom route for the profile endpoint. + * Useful for multi-tenant applications where different tenants require different route configurations. + * If not specified, falls back to the NEXT_PUBLIC_PROFILE_ROUTE environment variable or "/auth/profile". + * + * @example '/tenant-a/auth/profile' + */ + route?: string; +}; + +export function useUser(options: UseUserOptions = {}) { const { data, error, isLoading, mutate } = useSWR( normalizeWithBasePath( - process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile" + options.route || process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile" ), (...args) => fetch(...args).then((res) => { diff --git a/src/client/index.ts b/src/client/index.ts index 5ee646139..e4b6f3624 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,8 +1,14 @@ -export { useUser } from "./hooks/use-user.js"; -export { getAccessToken } from "./helpers/get-access-token.js"; +export { useUser, type UseUserOptions } from "./hooks/use-user.js"; +export { + getAccessToken, + type AccessTokenOptions +} from "./helpers/get-access-token.js"; export { withPageAuthRequired, WithPageAuthRequired, WithPageAuthRequiredOptions } from "./helpers/with-page-auth-required.js"; -export { Auth0Provider } from "./providers/auth0-provider.js"; +export { + Auth0Provider, + type Auth0ProviderProps +} from "./providers/auth0-provider.js"; diff --git a/src/client/providers/auth0-provider.tsx b/src/client/providers/auth0-provider.tsx index ad5a629bd..8d6eb6d8e 100644 --- a/src/client/providers/auth0-provider.tsx +++ b/src/client/providers/auth0-provider.tsx @@ -5,18 +5,41 @@ import { SWRConfig } from "swr"; import { User } from "../../types/index.js"; -export function Auth0Provider({ - user, - children -}: { +/** + * Props for the Auth0Provider component. + */ +export type Auth0ProviderProps = { + /** + * Initial user data to populate the SWR cache. + */ user?: User; + /** + * Child components to render within the provider. + */ children: React.ReactNode; -}) { + /** + * Custom route for the profile endpoint. + * Useful for multi-tenant applications where different tenants require different route configurations. + * If not specified, falls back to the NEXT_PUBLIC_PROFILE_ROUTE environment variable or "/auth/profile". + * + * @example '/tenant-a/auth/profile' + */ + profileRoute?: string; +}; + +export function Auth0Provider({ + user, + children, + profileRoute +}: Auth0ProviderProps) { + const route = + profileRoute || process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile"; + return ( diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index 3caa4b39b..73b8fe7bc 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -956,6 +956,82 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(authClient.handleBackChannelLogout).toHaveBeenCalled(); }); + it("should call the profile handler when custom route is configured via routes option", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: getMockAuthorizationServer(), + + routes: { + ...getDefaultRoutes(), + profile: "/custom-profile" + } + }); + const request = new NextRequest( + new URL("/custom-profile", DEFAULT.appBaseUrl), + { + method: "GET" + } + ); + + authClient.handleProfile = vi.fn(); + await authClient.handler(request); + expect(authClient.handleProfile).toHaveBeenCalled(); + }); + + it("should call the accessToken handler when custom route is configured via routes option", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: getMockAuthorizationServer(), + + routes: { + ...getDefaultRoutes(), + accessToken: "/custom-access-token" + } + }); + const request = new NextRequest( + new URL("/custom-access-token", DEFAULT.appBaseUrl), + { + method: "GET" + } + ); + + authClient.handleAccessToken = vi.fn(); + await authClient.handler(request); + expect(authClient.handleAccessToken).toHaveBeenCalled(); + }); + it("should call the profile handler when the configured route is called", async () => { process.env.NEXT_PUBLIC_PROFILE_ROUTE = "/custom-profile"; diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index ccb56e782..0182e3b46 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -161,12 +161,7 @@ export interface Routes { backChannelLogout: string; connectAccount: string; } -export type RoutesOptions = Partial< - Pick< - Routes, - "login" | "callback" | "logout" | "backChannelLogout" | "connectAccount" - > ->; +export type RoutesOptions = Partial; export interface AuthClientOptions { transactionStore: TransactionStore;