Skip to content
Open
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
49 changes: 45 additions & 4 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <div>{user?.name}</div>;
}

// In your layout
export default function RootLayout({ children }) {
return (
<Auth0Provider profileRoute="/tenant-a/auth/profile">
{children}
</Auth0Provider>
);
}
```

> [!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
Expand Down
33 changes: 33 additions & 0 deletions src/client/helpers/get-access-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export const restHandlers = [
}

return HttpResponse.json({ token });
}),
http.get("/custom/token", () => {
return HttpResponse.json({ token: "<access_token_from_custom_route>" });
})
];

Expand Down Expand Up @@ -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("<access_token_from_custom_route>");
});

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: `<custom_token_${audience}_${scope}>`
});
})
);

const result = await getAccessToken({
route: "/custom/token",
audience: "custom_audience",
scope: "read:custom"
});

expect(result).toBe("<custom_token_custom_audience_read:custom>");
});
});
18 changes: 17 additions & 1 deletion src/client/helpers/get-access-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions src/client/helpers/with-page-auth-required.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,8 @@ describe("with-page-auth-required csr", () => {
expect(window.location.assign).toHaveBeenCalled();
});
const url = new URL(
(
window.location.assign as MockedFunction<typeof window.location.assign>
).mock.calls[0][0],
(window.location.assign as MockedFunction<typeof window.location.assign>)
.mock.calls[0][0],
"https://example.com"
);
expect(url.searchParams.get("returnTo")).toEqual("/foo?bar=baz&qux=quux");
Expand Down
75 changes: 75 additions & 0 deletions src/client/hooks/use-user.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<swrModule.SWRConfig value={{ provider: () => new Map() }}>
{children}
</swrModule.SWRConfig>
);

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 }) => (
<swrModule.SWRConfig value={{ provider: () => new Map() }}>
{children}
</swrModule.SWRConfig>
);

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");
});
});
18 changes: 16 additions & 2 deletions src/client/hooks/use-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User, Error, string>(
normalizeWithBasePath(
process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile"
options.route || process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile"
),
(...args) =>
fetch(...args).then((res) => {
Expand Down
12 changes: 9 additions & 3 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -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";
35 changes: 29 additions & 6 deletions src/client/providers/auth0-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<SWRConfig
value={{
fallback: {
[process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile"]: user
[route]: user
}
}}
>
Expand Down
Loading