diff --git a/examples/example-nuxt-web-dynamic-app-base-url/.env.example b/examples/example-nuxt-web-dynamic-app-base-url/.env.example
new file mode 100644
index 0000000..3a57bad
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/.env.example
@@ -0,0 +1,7 @@
+NUXT_AUTH0_DOMAIN=
+NUXT_AUTH0_CLIENT_ID=
+NUXT_AUTH0_CLIENT_SECRET=
+NUXT_AUTH0_SESSION_SECRET=
+# Comma-separated allow-list of the origins this single app serves.
+# The SDK matches each request's origin against this list.
+NUXT_AUTH0_APP_BASE_URL=http://app1.localhost:3000,http://app2.localhost:3000
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/.gitignore b/examples/example-nuxt-web-dynamic-app-base-url/.gitignore
new file mode 100644
index 0000000..4a7f73a
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/.gitignore
@@ -0,0 +1,24 @@
+# Nuxt dev/build outputs
+.output
+.data
+.nuxt
+.nitro
+.cache
+dist
+
+# Node dependencies
+node_modules
+
+# Logs
+logs
+*.log
+
+# Misc
+.DS_Store
+.fleet
+.idea
+
+# Local env files
+.env
+.env.*
+!.env.example
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/README.md b/examples/example-nuxt-web-dynamic-app-base-url/README.md
new file mode 100644
index 0000000..4eee4ba
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/README.md
@@ -0,0 +1,75 @@
+# Nuxt Dynamic App Base URL Example
+
+This example demonstrates the allow-list mode of the dynamic `appBaseUrl` feature in `@auth0/auth0-nuxt`.
+
+A single Nuxt app serves two distinct domains — `app1.localhost` and `app2.localhost` — on port 3000 using the same Auth0 application. Instead of a single static `appBaseUrl`, the SDK is configured with an allow-list of origins. On each request it matches the incoming origin against the allow-list and uses the correct base URL for the callback `redirect_uri`, post-login redirect, and logout.
+
+## Install dependencies
+
+Install the dependencies using npm:
+
+```bash
+npm install
+```
+
+## Add `/etc/hosts` entries
+
+So that both hostnames resolve to your machine, add the following to `/etc/hosts`:
+
+```
+127.0.0.1 app1.localhost
+127.0.0.1 app2.localhost
+```
+
+(Many systems already resolve `*.localhost` to `127.0.0.1`, in which case this step can be skipped.)
+
+## Configuration
+
+Rename `.env.example` to `.env` and fill in your Auth0 credentials:
+
+```ts
+NUXT_AUTH0_DOMAIN=YOUR_AUTH0_DOMAIN
+NUXT_AUTH0_CLIENT_ID=YOUR_AUTH0_CLIENT_ID
+NUXT_AUTH0_CLIENT_SECRET=YOUR_AUTH0_CLIENT_SECRET
+NUXT_AUTH0_SESSION_SECRET=YOUR_AUTH0_SESSION_SECRET
+NUXT_AUTH0_APP_BASE_URL=http://app1.localhost:3000,http://app2.localhost:3000
+```
+
+The `NUXT_AUTH0_SESSION_SECRET` is the key used to encrypt the session cookie. You can generate a secret using `openssl`:
+
+```shell
+openssl rand -hex 64
+```
+
+`NUXT_AUTH0_APP_BASE_URL` is a **comma-separated allow-list** of the origins this app serves. The SDK parses this into an array and validates each request's origin against it.
+
+## Configure your Auth0 tenant
+
+Because the app serves two origins, both must be registered in your Auth0 application settings:
+
+- **Allowed Callback URLs:** `http://app1.localhost:3000/auth/callback, http://app2.localhost:3000/auth/callback`
+- **Allowed Logout URLs:** `http://app1.localhost:3000, http://app2.localhost:3000`
+- **Allowed Web Origins:** `http://app1.localhost:3000, http://app2.localhost:3000`
+
+## Run the app
+
+```bash
+npm run start
+```
+
+The application has 3 routes:
+
+- `/`: The home route, displaying a message depending on the authentication state.
+- `/public`: A public route that can be accessed without authentication.
+- `/private`: A private route that can only be accessed by authenticated users. Navigating here while unauthenticated redirects to Auth0 and back.
+
+## Test the dynamic base URL
+
+Open each origin in your browser and walk through login/logout on each:
+
+- http://app1.localhost:3000
+- http://app2.localhost:3000
+
+When you log in from `app1.localhost`, the SDK infers that origin, matches it against the allow-list, and uses `http://app1.localhost:3000/auth/callback` as the `redirect_uri`. The same flow on `app2.localhost` uses `http://app2.localhost:3000/auth/callback` — same configuration, correct URL per request. The current host is displayed in a banner at the top of each page so you can confirm which origin you are on.
+
+If a request arrives from an origin that is not in the allow-list, the SDK throws an `InvalidConfigurationError`.
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/app.vue b/examples/example-nuxt-web-dynamic-app-base-url/app.vue
new file mode 100644
index 0000000..89a97aa
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/app.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+ Serving from {{ host }}
+
+
+
+
+
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/nuxt.config.ts b/examples/example-nuxt-web-dynamic-app-base-url/nuxt.config.ts
new file mode 100644
index 0000000..4420bbb
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/nuxt.config.ts
@@ -0,0 +1,51 @@
+// https://nuxt.com/docs/api/configuration/nuxt-config
+export default defineNuxtConfig({
+ compatibilityDate: '2024-11-01',
+ modules: [
+ [
+ '@auth0/auth0-nuxt',
+ {
+ mountRoutes: true,
+ },
+ ],
+ ],
+ imports: {
+ autoImport: true,
+ },
+ runtimeConfig: {
+ auth0: {
+ domain: '', // is overridden by NUXT_AUTH0_DOMAIN environment variable
+ clientId: '', // is overridden by NUXT_AUTH0_CLIENT_ID environment variable
+ clientSecret: '', // is overridden by NUXT_AUTH0_CLIENT_SECRET environment variable
+ sessionSecret: '', // is overridden by NUXT_AUTH0_SESSION_SECRET environment variable
+ // `appBaseUrl` is intentionally omitted here. It is provided as a
+ // comma-separated allow-list via the NUXT_AUTH0_APP_BASE_URL environment
+ // variable (see .env.example). The SDK parses it into an array and, on
+ // each request, matches the incoming origin against the allow-list to
+ // resolve the correct base URL for the callback, post-login redirect,
+ // and logout.
+ audience: '', // is overridden by NUXT_AUTH0_AUDIENCE environment variable
+ },
+ },
+ app: {
+ head: {
+ script: [
+ {
+ src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js',
+ integrity:
+ 'sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz',
+ crossorigin: 'anonymous',
+ },
+ ],
+ link: [
+ {
+ rel: 'stylesheet',
+ href: 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css',
+ integrity:
+ 'sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH',
+ crossorigin: 'anonymous',
+ },
+ ],
+ },
+ },
+});
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/package.json b/examples/example-nuxt-web-dynamic-app-base-url/package.json
new file mode 100644
index 0000000..ba83219
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "example-nuxt-web-dynamic-app-base-url",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "nuxt build",
+ "dev": "nuxt dev",
+ "generate": "nuxt generate",
+ "preview": "nuxt preview",
+ "start": "npm run dev"
+ },
+ "dependencies": {
+ "@auth0/auth0-nuxt": "*",
+ "nuxt": "^3.16.2",
+ "vue": "^3.5.13",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@nuxt/devtools": "^3.2.3",
+ "@nuxt/eslint-config": "^1.15.2",
+ "eslint": "^9.20.1",
+ "typescript": "~5.9.3"
+ }
+}
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/pages/index.vue b/examples/example-nuxt-web-dynamic-app-base-url/pages/index.vue
new file mode 100644
index 0000000..26158bb
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/pages/index.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
Welcome to the homepage
+
+ You are logged in as {{ user?.name }} on
+ {{ host }}.
+
+
+ You are not logged in. Logging in from {{ host }} will
+ redirect you back to this same origin.
+
+
+
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/pages/private.vue b/examples/example-nuxt-web-dynamic-app-base-url/pages/private.vue
new file mode 100644
index 0000000..5da439a
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/pages/private.vue
@@ -0,0 +1 @@
+This is a private page.
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/pages/public.vue b/examples/example-nuxt-web-dynamic-app-base-url/pages/public.vue
new file mode 100644
index 0000000..cd68f46
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/pages/public.vue
@@ -0,0 +1 @@
+This is a public page.
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/public/favicon.ico b/examples/example-nuxt-web-dynamic-app-base-url/public/favicon.ico
new file mode 100644
index 0000000..18993ad
Binary files /dev/null and b/examples/example-nuxt-web-dynamic-app-base-url/public/favicon.ico differ
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/public/img/auth0.png b/examples/example-nuxt-web-dynamic-app-base-url/public/img/auth0.png
new file mode 100644
index 0000000..c5c260f
Binary files /dev/null and b/examples/example-nuxt-web-dynamic-app-base-url/public/img/auth0.png differ
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/public/robots.txt b/examples/example-nuxt-web-dynamic-app-base-url/public/robots.txt
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/public/robots.txt
@@ -0,0 +1 @@
+
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/server/middleware/auth.server.ts b/examples/example-nuxt-web-dynamic-app-base-url/server/middleware/auth.server.ts
new file mode 100644
index 0000000..bc18cb5
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/server/middleware/auth.server.ts
@@ -0,0 +1,11 @@
+export default defineEventHandler(async (event) => {
+ const url = getRequestURL(event);
+
+ if (url.pathname === '/private') {
+ const auth0Client = useAuth0(event);
+ const session = await auth0Client.getSession();
+ if (!session) {
+ return sendRedirect(event, '/auth/login?returnTo=' + url.pathname);
+ }
+ }
+})
\ No newline at end of file
diff --git a/examples/example-nuxt-web-dynamic-app-base-url/tsconfig.json b/examples/example-nuxt-web-dynamic-app-base-url/tsconfig.json
new file mode 100644
index 0000000..a746f2a
--- /dev/null
+++ b/examples/example-nuxt-web-dynamic-app-base-url/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ // https://nuxt.com/docs/guide/concepts/typescript
+ "extends": "./.nuxt/tsconfig.json"
+}
diff --git a/packages/auth0-nuxt/EXAMPLES.md b/packages/auth0-nuxt/EXAMPLES.md
index 084a297..8f5aef1 100644
--- a/packages/auth0-nuxt/EXAMPLES.md
+++ b/packages/auth0-nuxt/EXAMPLES.md
@@ -2,6 +2,7 @@
- [Configuration](#configuration)
- [Basic configuration](#basic-configuration)
+ - [Dynamic Application Base URL](#dynamic-application-base-url)
- [Configuring the mountes routes](#configuring-the-mountes-routes)
- [Configuring Stateful Sessions](#configuring-stateful-sessions)
- [Customizing State and Transaction Identifiers](#customizing-state-and-transaction-identifiers)
@@ -63,6 +64,31 @@ NUXT_AUTH0_APP_BASE_URL=http://localhost:3000
NUXT_AUTH0_SESSION_SECRET=
```
+### Dynamic Application Base URL
+
+`appBaseUrl` supports serving multiple origins from a single Auth0 application. It can be configured in three ways:
+
+- **Static** — a single URL string (the default). Use this when your app is served from one origin:
+
+ ```
+ NUXT_AUTH0_APP_BASE_URL=https://app.example.com
+ ```
+
+- **Allow-list** — a comma-separated list of origins. On each request the SDK matches the incoming origin against the list and uses the matching entry for the callback `redirect_uri`, the post-login redirect, and logout. This is the recommended approach for production multi-origin setups:
+
+ ```
+ NUXT_AUTH0_APP_BASE_URL=https://app1.example.com,https://app2.example.com
+ ```
+
+- **Dynamic** — omit `appBaseUrl` entirely. The base URL is inferred from each request's host, honoring the `x-forwarded-host` / `x-forwarded-proto` headers set by proxies and CDNs, and falling back to the `Host` header.
+
+Regardless of the mode, every origin must be registered in your Auth0 application's **Allowed Callback URLs** and **Allowed Logout URLs** — this remains the primary safeguard.
+
+> [!IMPORTANT]
+> In dynamic and allow-list modes, secure session cookies are enforced when `NODE_ENV=production`. Explicitly setting `sessionConfiguration.cookie.secure = false` in that mode throws an `InvalidConfigurationError`.
+
+See the [`example-nuxt-web-dynamic-app-base-url`](../../examples/example-nuxt-web-dynamic-app-base-url) example for a runnable two-host setup.
+
### Configuring the mountes routes
The SDK for Nuxt Web Applications mounts 4 main routes:
diff --git a/packages/auth0-nuxt/README.md b/packages/auth0-nuxt/README.md
index 3d92208..f4cb8d5 100644
--- a/packages/auth0-nuxt/README.md
+++ b/packages/auth0-nuxt/README.md
@@ -51,7 +51,7 @@ The `SESSION_SECRET` is the key used to encrypt the session cookie. You can gene
openssl rand -hex 64
```
-The `APP_BASE_URL` is the URL that your application is running on. When developing locally, this is most commonly http://localhost:3000.
+The `APP_BASE_URL` is the URL that your application is running on. When developing locally, this is most commonly http://localhost:3000. It may also be a comma-separated allow-list of origins, or omitted to infer the origin per request — see [Dynamic Application Base URL](https://github.com/auth0/auth0-nuxt/blob/main/packages/auth0-nuxt/EXAMPLES.md#dynamic-application-base-url) for serving multiple origins from a single Auth0 application.
> [!IMPORTANT]
diff --git a/packages/auth0-nuxt/src/module.ts b/packages/auth0-nuxt/src/module.ts
index d4c947b..f37f82a 100644
--- a/packages/auth0-nuxt/src/module.ts
+++ b/packages/auth0-nuxt/src/module.ts
@@ -12,6 +12,7 @@ import type { RouteConfig } from './types';
export * from './types';
export type { SessionConfiguration, SessionCookieOptions, StateData } from '@auth0/auth0-server-js';
+export { InvalidConfigurationError } from '@auth0/auth0-server-js';
/**
* Module options for the Auth0 Nuxt module.
diff --git a/packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.spec.ts b/packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.spec.ts
index 53143ff..8412790 100644
--- a/packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.spec.ts
+++ b/packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.spec.ts
@@ -29,6 +29,8 @@ describe('callback.get handler', () => {
node: {
req: {
url: 'foo',
+ headers: { host: 'localhost:3000' },
+ socket: {},
},
},
} as unknown as H3Event;
@@ -77,4 +79,19 @@ describe('callback.get handler', () => {
expect(sendRedirectMock).toHaveBeenCalledWith(mockEvent, 'http://localhost:3000');
});
+
+ it('resolves the base url dynamically from the request host', async () => {
+ const dynamicEvent = {
+ context: { auth0ClientOptions: { appBaseUrl: undefined } },
+ node: { req: { url: 'foo', headers: { host: 'app2.localhost:3000' }, socket: {} } },
+ } as unknown as H3Event;
+ mockAuth0Client.completeInteractiveLogin.mockResolvedValue({ appState: undefined });
+
+ await callbackHandler(dynamicEvent);
+
+ expect(mockAuth0Client.completeInteractiveLogin).toHaveBeenCalledWith(
+ new URL('foo', 'http://app2.localhost:3000')
+ );
+ expect(sendRedirectMock).toHaveBeenCalledWith(dynamicEvent, 'http://app2.localhost:3000');
+ });
});
diff --git a/packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts b/packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts
index 1b7358e..8fdb868 100644
--- a/packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts
+++ b/packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts
@@ -1,17 +1,19 @@
import { defineEventHandler, sendRedirect } from 'h3';
import { useAuth0 } from '../../composables/use-auth0';
import { toSafeRedirect } from './../../utils/url';
+import { resolveAppBaseUrl } from './../../utils/app-base-url';
export default defineEventHandler(async (event) => {
const auth0Client = useAuth0(event);
const auth0ClientOptions = event.context.auth0ClientOptions;
+
+ const appBaseUrl = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event);
+
const { appState } = await auth0Client.completeInteractiveLogin<{ returnTo: string } | undefined>(
- new URL(event.node.req.url as string, auth0ClientOptions.appBaseUrl)
+ new URL(event.node.req.url as string, appBaseUrl)
);
- const safeReturnTo = appState?.returnTo
- ? toSafeRedirect(appState.returnTo, auth0ClientOptions.appBaseUrl)
- : auth0ClientOptions.appBaseUrl;
+ const safeReturnTo = appState?.returnTo ? toSafeRedirect(appState.returnTo, appBaseUrl) : appBaseUrl;
- sendRedirect(event, safeReturnTo ?? auth0ClientOptions.appBaseUrl);
+ sendRedirect(event, safeReturnTo ?? appBaseUrl);
});
diff --git a/packages/auth0-nuxt/src/runtime/server/api/auth/login.get.spec.ts b/packages/auth0-nuxt/src/runtime/server/api/auth/login.get.spec.ts
index e22d4ba..3527718 100644
--- a/packages/auth0-nuxt/src/runtime/server/api/auth/login.get.spec.ts
+++ b/packages/auth0-nuxt/src/runtime/server/api/auth/login.get.spec.ts
@@ -1,14 +1,13 @@
+// @vitest-environment node
import { describe, it, expect, beforeEach, vi } from 'vitest';
import loginHandler from './login.get';
import type { H3Event } from 'h3';
-const { sendRedirectMock } = vi.hoisted(() => {
- return { sendRedirectMock: vi.fn() };
-});
-
-const { getQueryMock } = vi.hoisted(() => {
- return { getQueryMock: vi.fn() };
-});
+const { sendRedirectMock } = vi.hoisted(() => ({ sendRedirectMock: vi.fn() }));
+const { getQueryMock } = vi.hoisted(() => ({ getQueryMock: vi.fn() }));
+const { useRuntimeConfigMock } = vi.hoisted(() => ({
+ useRuntimeConfigMock: vi.fn(() => ({ public: { auth0: { routes: { callback: '/auth/callback' } } } })),
+}));
vi.mock('h3', async (importOriginal) => ({
...(await importOriginal()),
@@ -16,6 +15,8 @@ vi.mock('h3', async (importOriginal) => ({
getQuery: getQueryMock,
}));
+vi.mock('#app/nuxt', () => ({ useRuntimeConfig: useRuntimeConfigMock }));
+
const mockAuth0Client = {
startInteractiveLogin: vi.fn().mockResolvedValue({}),
};
@@ -27,15 +28,9 @@ vi.mock('../../composables/use-auth0', () => ({
describe('login.get handler', () => {
const mockEvent = {
context: {
- auth0ClientOptions: {
- appBaseUrl: 'http://localhost:3000',
- },
+ auth0ClientOptions: { appBaseUrl: 'http://localhost:3000' },
},
- node: {
- res: {
- setHeader: vi.fn(),
- }
- }
+ node: { req: { headers: { host: 'localhost:3000' }, socket: {} }, res: { setHeader: vi.fn() } },
} as unknown as H3Event;
beforeEach(() => {
@@ -43,32 +38,49 @@ describe('login.get handler', () => {
mockAuth0Client.startInteractiveLogin.mockResolvedValue(new URL('http://redirectTo'));
});
- it('should call auth0Client startInteractiveLogin with the returnTo in case of valid returnTo', async () => {
+ it('passes the resolved redirect_uri and a valid returnTo', async () => {
getQueryMock.mockReturnValue({ returnTo: 'http://localhost:3000/foo' });
await loginHandler(mockEvent);
expect(mockAuth0Client.startInteractiveLogin).toHaveBeenCalledWith({
appState: { returnTo: 'http://localhost:3000/foo' },
+ authorizationParams: { redirect_uri: 'http://localhost:3000/auth/callback' },
});
});
- it('should call startInteractiveLogin with returnTo as undefined in case of invalid returnTo', async () => {
+ it('drops an unsafe returnTo', async () => {
getQueryMock.mockReturnValue({ returnTo: 'http://foo.bar:3000/foo' });
await loginHandler(mockEvent);
expect(mockAuth0Client.startInteractiveLogin).toHaveBeenCalledWith({
appState: { returnTo: undefined },
+ authorizationParams: { redirect_uri: 'http://localhost:3000/auth/callback' },
+ });
+ });
+
+ it('resolves redirect_uri dynamically from the request host', async () => {
+ getQueryMock.mockReturnValue({});
+ const dynamicEvent = {
+ context: { auth0ClientOptions: { appBaseUrl: undefined } },
+ node: { req: { headers: { host: 'app2.localhost:3000' }, socket: {} }, res: { setHeader: vi.fn() } },
+ } as unknown as H3Event;
+
+ await loginHandler(dynamicEvent);
+
+ expect(mockAuth0Client.startInteractiveLogin).toHaveBeenCalledWith({
+ appState: { returnTo: 'http://app2.localhost:3000/' },
+ authorizationParams: { redirect_uri: 'http://app2.localhost:3000/auth/callback' },
});
});
- it('should call sendRedirect with the returned url', async () => {
+ it('calls sendRedirect with the returned url', async () => {
+ getQueryMock.mockReturnValue({});
mockAuth0Client.startInteractiveLogin.mockResolvedValue(new URL('http://localhost:3000/foo'));
await loginHandler(mockEvent);
expect(sendRedirectMock).toHaveBeenCalledWith(mockEvent, 'http://localhost:3000/foo');
});
-
});
diff --git a/packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts b/packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts
index 7bfbb53..c4ac0da 100644
--- a/packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts
+++ b/packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts
@@ -1,6 +1,9 @@
import { useAuth0 } from '../../composables/use-auth0';
import { defineEventHandler, getQuery, sendRedirect } from 'h3';
-import { toSafeRedirect } from './../../utils/url';
+import { useRuntimeConfig } from '#imports';
+import { createRouteUrl, toSafeRedirect } from './../../utils/url';
+import { resolveAppBaseUrl } from './../../utils/app-base-url';
+import type { Auth0PublicConfig } from '../../composables/use-auth0';
interface LoginParams {
returnTo?: string;
@@ -9,13 +12,20 @@ interface LoginParams {
export default defineEventHandler(async (event) => {
const auth0Client = useAuth0(event);
const auth0ClientOptions = event.context.auth0ClientOptions;
- const query = getQuery(event);
- const dangerousReturnTo = query.returnTo ?? auth0ClientOptions.appBaseUrl;
+ const runtimeConfig = useRuntimeConfig();
+ const publicConfig = runtimeConfig.public.auth0 as Auth0PublicConfig;
+
+ const appBaseUrl = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event);
+ const callbackPath = publicConfig.routes?.callback ?? '/auth/callback';
+ const redirectUri = createRouteUrl(callbackPath, appBaseUrl);
- const sanitizedReturnTo = toSafeRedirect(dangerousReturnTo as string, auth0ClientOptions.appBaseUrl);
+ const query = getQuery(event);
+ const dangerousReturnTo = query.returnTo ?? appBaseUrl;
+ const sanitizedReturnTo = toSafeRedirect(dangerousReturnTo as string, appBaseUrl);
const authorizationUrl = await auth0Client.startInteractiveLogin({
appState: { returnTo: sanitizedReturnTo },
+ authorizationParams: { redirect_uri: redirectUri.toString() },
});
sendRedirect(event, authorizationUrl.href);
diff --git a/packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.spec.ts b/packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.spec.ts
index 4a40473..6d90e94 100644
--- a/packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.spec.ts
+++ b/packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.spec.ts
@@ -26,6 +26,7 @@ describe('logout.get handler', () => {
appBaseUrl: 'http://localhost:3000',
},
},
+ node: { req: { headers: { host: 'localhost:3000' }, socket: {} } },
} as unknown as H3Event;
beforeEach(() => {
@@ -45,4 +46,15 @@ describe('logout.get handler', () => {
expect(sendRedirectMock).toHaveBeenCalledWith(mockEvent, 'http://external/logout');
});
+
+ it('resolves returnTo dynamically from the request host', async () => {
+ const dynamicEvent = {
+ context: { auth0ClientOptions: { appBaseUrl: undefined } },
+ node: { req: { headers: { host: 'app2.localhost:3000' }, socket: {} } },
+ } as unknown as H3Event;
+
+ await logoutHandler(dynamicEvent);
+
+ expect(mockAuth0Client.logout).toHaveBeenCalledWith({ returnTo: 'http://app2.localhost:3000' });
+ });
});
diff --git a/packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts b/packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts
index 6466682..5dd5185 100644
--- a/packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts
+++ b/packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts
@@ -1,14 +1,13 @@
import { useAuth0 } from '../../composables/use-auth0';
import { defineEventHandler, sendRedirect } from 'h3';
+import { resolveAppBaseUrl } from './../../utils/app-base-url';
export default defineEventHandler(async (event) => {
- const auth0Client = useAuth0(event);
- const auth0ClientOptions = event.context.auth0ClientOptions;
+ const auth0Client = useAuth0(event);
+ const auth0ClientOptions = event.context.auth0ClientOptions;
- const returnTo = auth0ClientOptions.appBaseUrl;
- const logoutUrl = await auth0Client.logout(
- { returnTo: returnTo.toString() },
- );
+ const returnTo = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event);
+ const logoutUrl = await auth0Client.logout({ returnTo });
sendRedirect(event, logoutUrl.href);
-});
\ No newline at end of file
+});
diff --git a/packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts b/packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts
index e564357..13a0f99 100644
--- a/packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts
+++ b/packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts
@@ -84,8 +84,14 @@ function createServerClientInstance(
publicConfig: Auth0PublicConfig,
sessionStore?: SessionStore
): ServerClient {
+ // The redirect_uri is only known statically when appBaseUrl is a single
+ // string. In dynamic/allow-list mode the login handler supplies it per
+ // request, and the callback builds its URL from the resolved base.
const callbackPath = publicConfig.routes?.callback ?? '/auth/callback';
- const redirectUri = createRouteUrl(callbackPath, options.appBaseUrl);
+ const redirectUri =
+ typeof options.appBaseUrl === 'string'
+ ? createRouteUrl(callbackPath, options.appBaseUrl).toString()
+ : undefined;
return new ServerClient({
domain: options.domain,
@@ -93,7 +99,7 @@ function createServerClientInstance(
clientSecret: options.clientSecret,
authorizationParams: {
audience: options.audience,
- redirect_uri: redirectUri.toString(),
+ redirect_uri: redirectUri,
},
stateIdentifier: options.stateIdentifier,
transactionIdentifier: options.transactionIdentifier,
diff --git a/packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts b/packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts
index 8538508..3a8b8f5 100644
--- a/packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts
+++ b/packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts
@@ -1,7 +1,8 @@
import { useRuntimeConfig } from '#imports';
import { ServerClient, type SessionStore } from '@auth0/auth0-server-js';
import { defineNitroPlugin } from 'nitropack/dist/runtime/plugin';
-import type { StoreOptions } from '~/src/types';
+import type { Auth0ClientOptions, StoreOptions } from '~/src/types';
+import { resolveAuth0Options } from '../utils/config';
declare module 'h3' {
interface H3EventContext {
@@ -20,13 +21,18 @@ async function tryLoadSessionStore(): Promise | undef
export default defineNitroPlugin(async (nitroApp) => {
const config = useRuntimeConfig();
- const options = config.auth0;
+ const rawOptions = config.auth0 as Auth0ClientOptions;
- if (!options.domain) throw new Error('Auth0 configuration error: Domain is required');
- if (!options.clientId) throw new Error('Auth0 configuration error: Client ID is required');
- if (!options.clientSecret) throw new Error('Auth0 configuration error: Client Secret is required');
- if (!options.appBaseUrl) throw new Error('Auth0 configuration error: App Base URL is required');
- if (!options.sessionSecret) throw new Error('Auth0 configuration error: Session Secret is required');
+ if (!rawOptions.domain) throw new Error('Auth0 configuration error: Domain is required');
+ if (!rawOptions.clientId) throw new Error('Auth0 configuration error: Client ID is required');
+ if (!rawOptions.clientSecret) throw new Error('Auth0 configuration error: Client Secret is required');
+ if (!rawOptions.sessionSecret) throw new Error('Auth0 configuration error: Session Secret is required');
+
+ // Normalize a comma-separated appBaseUrl (from env or config) into an allow-list,
+ // validate it, and (in production dynamic mode) enforce secure cookies. This
+ // returns a new object — the Nuxt runtime config is frozen and must not be
+ // mutated. Omitting appBaseUrl entirely enables dynamic, per-request resolution.
+ const options = resolveAuth0Options(rawOptions, process.env.NODE_ENV === 'production');
const sessionStoreInstance = await tryLoadSessionStore();
diff --git a/packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts b/packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts
new file mode 100644
index 0000000..3ffbea6
--- /dev/null
+++ b/packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect } from 'vitest';
+import type { H3Event } from 'h3';
+import { isUrl, inferBaseUrlFromRequest, resolveAppBaseUrl } from './app-base-url';
+
+// Build a minimal event whose headers drive h3's getRequestHost/getRequestProtocol.
+function makeEvent(headers: Record): H3Event {
+ return {
+ node: {
+ req: { headers, socket: {} },
+ },
+ } as unknown as H3Event;
+}
+
+describe('isUrl', () => {
+ it('accepts http and https URLs', () => {
+ expect(isUrl('http://a.com')).toBe(true);
+ expect(isUrl('https://a.com:3000')).toBe(true);
+ });
+
+ it('rejects non-http(s) and invalid values', () => {
+ expect(isUrl('not-a-url')).toBe(false);
+ expect(isUrl('ftp://a.com')).toBe(false);
+ expect(isUrl('')).toBe(false);
+ });
+});
+
+describe('inferBaseUrlFromRequest', () => {
+ it('infers from host header', () => {
+ const event = makeEvent({ host: 'app1.localhost:3000' });
+ expect(inferBaseUrlFromRequest(event)).toBe('http://app1.localhost:3000');
+ });
+
+ it('prefers x-forwarded-host and x-forwarded-proto', () => {
+ const event = makeEvent({
+ host: 'internal:8080',
+ 'x-forwarded-host': 'app.example.com',
+ 'x-forwarded-proto': 'https',
+ });
+ expect(inferBaseUrlFromRequest(event)).toBe('https://app.example.com');
+ });
+
+ it('falls back to localhost when no host header is present (h3 default)', () => {
+ const event = makeEvent({});
+ expect(inferBaseUrlFromRequest(event)).toBe('http://localhost');
+ });
+
+ it('returns null when the host cannot form a valid origin', () => {
+ const event = makeEvent({ host: 'bad host name' });
+ expect(inferBaseUrlFromRequest(event)).toBeNull();
+ });
+});
+
+describe('resolveAppBaseUrl', () => {
+ it('returns a static string as-is without an event', () => {
+ expect(resolveAppBaseUrl('https://app.example.com')).toBe('https://app.example.com');
+ });
+
+ it('throws when dynamic and no event is provided', () => {
+ expect(() => resolveAppBaseUrl(undefined)).toThrow();
+ });
+
+ it('infers the origin in dynamic mode', () => {
+ const event = makeEvent({ host: 'app1.localhost:3000' });
+ expect(resolveAppBaseUrl(undefined, event)).toBe('http://app1.localhost:3000');
+ });
+
+ it('returns the matching allow-list entry', () => {
+ const event = makeEvent({ host: 'app2.localhost:3000' });
+ const result = resolveAppBaseUrl(
+ ['http://app1.localhost:3000', 'http://app2.localhost:3000'],
+ event
+ );
+ expect(result).toBe('http://app2.localhost:3000');
+ });
+
+ it('throws when the request origin is not in the allow-list', () => {
+ const event = makeEvent({ host: 'evil.localhost:3000' });
+ expect(() => resolveAppBaseUrl(['http://app1.localhost:3000'], event)).toThrow();
+ });
+});
diff --git a/packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts b/packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts
new file mode 100644
index 0000000..4f3ca4f
--- /dev/null
+++ b/packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts
@@ -0,0 +1,96 @@
+import { getRequestHost, getRequestProtocol, type H3Event } from 'h3';
+import { InvalidConfigurationError } from '@auth0/auth0-server-js';
+
+const HTTP_PROTOCOLS = new Set(['http:', 'https:']);
+
+/**
+ * Checks if a string is a valid HTTP or HTTPS URL.
+ * @param value The value to check.
+ * @returns True when the value is a valid http(s) URL.
+ */
+export function isUrl(value: string): boolean {
+ try {
+ const parsed = new URL(value);
+ return HTTP_PROTOCOLS.has(parsed.protocol);
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Infers the application base URL (origin) from the incoming request.
+ *
+ * Honors `x-forwarded-host` / `x-forwarded-proto` (set by proxies/CDNs),
+ * falling back to the raw `Host` header and request protocol. Returns null
+ * when a valid origin cannot be built.
+ * @param event The h3 event for the current request.
+ * @returns The inferred origin (e.g. `https://app.example.com`) or null.
+ */
+export function inferBaseUrlFromRequest(event: H3Event): string | null {
+ const host = getRequestHost(event, { xForwardedHost: true });
+ const proto = getRequestProtocol(event, { xForwardedProto: true });
+
+ if (!host || !proto) {
+ return null;
+ }
+
+ const candidate = `${proto}://${host}`;
+ return isUrl(candidate) ? candidate : null;
+}
+
+/**
+ * Resolves the application base URL for the current request.
+ *
+ * - `string`: used as-is (static configuration).
+ * - `undefined`: inferred from the request host (dynamic mode).
+ * - `string[]`: the request origin is matched against the allow-list; the
+ * matching configured entry is returned, otherwise an error is thrown.
+ *
+ * @param appBaseUrl The configured app base URL (static, allow-list or omitted).
+ * @param event The h3 event for the current request (required unless static).
+ * @returns The resolved application base URL.
+ * @throws {InvalidConfigurationError} When the base URL cannot be resolved.
+ */
+export function resolveAppBaseUrl(
+ appBaseUrl: string | string[] | undefined,
+ event?: H3Event
+): string {
+ if (typeof appBaseUrl === 'string') {
+ return appBaseUrl;
+ }
+
+ if (!event) {
+ throw new InvalidConfigurationError(
+ 'appBaseUrl is not configured as a static string, and a request context is not available.'
+ );
+ }
+
+ const inferred = inferBaseUrlFromRequest(event);
+ if (!inferred) {
+ throw new InvalidConfigurationError(
+ 'appBaseUrl is not configured as a static string, and the request origin could not be determined from the request context.'
+ );
+ }
+
+ if (!appBaseUrl) {
+ // undefined → pure dynamic mode
+ return inferred;
+ }
+
+ const requestOrigin = new URL(inferred).origin;
+ const matchedEntry = appBaseUrl.find((allowedUrl) => {
+ try {
+ return new URL(allowedUrl).origin === requestOrigin;
+ } catch {
+ return false;
+ }
+ });
+
+ if (matchedEntry !== undefined) {
+ return matchedEntry;
+ }
+
+ throw new InvalidConfigurationError(
+ 'appBaseUrl is configured as an allow-list, but it does not contain a match for the current request origin.'
+ );
+}
diff --git a/packages/auth0-nuxt/src/runtime/server/utils/config.spec.ts b/packages/auth0-nuxt/src/runtime/server/utils/config.spec.ts
new file mode 100644
index 0000000..2f83733
--- /dev/null
+++ b/packages/auth0-nuxt/src/runtime/server/utils/config.spec.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect } from 'vitest';
+import { InvalidConfigurationError } from '@auth0/auth0-server-js';
+import {
+ parseAppBaseUrl,
+ validateAppBaseUrl,
+ enforceSecureCookies,
+ resolveAuth0Options,
+} from './config';
+import type { Auth0ClientOptions } from '~/src/types';
+
+describe('parseAppBaseUrl', () => {
+ it('returns undefined for falsy input', () => {
+ expect(parseAppBaseUrl(undefined)).toBeUndefined();
+ expect(parseAppBaseUrl('')).toBeUndefined();
+ });
+
+ it('keeps a single URL as a string', () => {
+ expect(parseAppBaseUrl('https://app.example.com')).toBe('https://app.example.com');
+ });
+
+ it('splits a comma-separated value into an array', () => {
+ expect(parseAppBaseUrl('https://a.com, https://b.com')).toEqual([
+ 'https://a.com',
+ 'https://b.com',
+ ]);
+ });
+
+ it('collapses a trailing-comma value to a string', () => {
+ expect(parseAppBaseUrl('https://a.com,')).toBe('https://a.com');
+ });
+});
+
+describe('validateAppBaseUrl', () => {
+ it('allows undefined (dynamic mode)', () => {
+ expect(() => validateAppBaseUrl(undefined)).not.toThrow();
+ });
+
+ it('throws on an invalid static URL', () => {
+ expect(() => validateAppBaseUrl('not-a-url')).toThrow(InvalidConfigurationError);
+ });
+
+ it('throws on an empty array', () => {
+ expect(() => validateAppBaseUrl([])).toThrow(InvalidConfigurationError);
+ });
+
+ it('throws when an array contains an invalid URL, naming the entry', () => {
+ expect(() => validateAppBaseUrl(['https://a.com', 'nope'])).toThrow(/nope/);
+ });
+
+ it('passes for a valid array', () => {
+ expect(() => validateAppBaseUrl(['https://a.com', 'https://b.com'])).not.toThrow();
+ });
+});
+
+describe('enforceSecureCookies', () => {
+ it('forces secure=true in production dynamic mode', () => {
+ const result = enforceSecureCookies(undefined, undefined, true);
+ expect(result?.cookie?.secure).toBe(true);
+ });
+
+ it('forces secure=true in production allow-list mode', () => {
+ const result = enforceSecureCookies(['https://a.com'], undefined, true);
+ expect(result?.cookie?.secure).toBe(true);
+ });
+
+ it('throws when secure is explicitly false in production dynamic mode', () => {
+ expect(() => enforceSecureCookies(undefined, { cookie: { secure: false } }, true)).toThrow(
+ InvalidConfigurationError
+ );
+ });
+
+ it('does not touch secure for a static appBaseUrl in production', () => {
+ const result = enforceSecureCookies('https://app.example.com', undefined, true);
+ expect(result?.cookie?.secure).toBeUndefined();
+ });
+
+ it('does not touch secure outside production', () => {
+ const result = enforceSecureCookies(undefined, undefined, false);
+ expect(result?.cookie?.secure).toBeUndefined();
+ });
+
+ it('does not mutate the provided session configuration', () => {
+ const sessionConfiguration = { cookie: {} };
+ const result = enforceSecureCookies(undefined, sessionConfiguration, true);
+ expect(result).not.toBe(sessionConfiguration);
+ expect(sessionConfiguration.cookie).toEqual({});
+ });
+});
+
+describe('resolveAuth0Options', () => {
+ it('parses a comma-separated appBaseUrl into an allow-list', () => {
+ const options = {
+ domain: 'd',
+ clientId: 'c',
+ clientSecret: 's',
+ sessionSecret: 'ss',
+ appBaseUrl: 'https://a.com, https://b.com',
+ } as Auth0ClientOptions;
+
+ const result = resolveAuth0Options(options, false);
+
+ expect(result.appBaseUrl).toEqual(['https://a.com', 'https://b.com']);
+ });
+
+ it('returns a new object without mutating a frozen input (runtime config)', () => {
+ const options = Object.freeze({
+ domain: 'd',
+ clientId: 'c',
+ clientSecret: 's',
+ sessionSecret: 'ss',
+ appBaseUrl: 'https://a.com, https://b.com',
+ }) as Auth0ClientOptions;
+
+ // Must not throw on the frozen object.
+ const result = resolveAuth0Options(options, true);
+
+ expect(result).not.toBe(options);
+ expect(options.appBaseUrl).toBe('https://a.com, https://b.com');
+ expect(result.appBaseUrl).toEqual(['https://a.com', 'https://b.com']);
+ expect(result.sessionConfiguration?.cookie?.secure).toBe(true);
+ });
+
+ it('throws when the resolved appBaseUrl is invalid', () => {
+ const options = {
+ domain: 'd',
+ clientId: 'c',
+ clientSecret: 's',
+ sessionSecret: 'ss',
+ appBaseUrl: 'not-a-url',
+ } as Auth0ClientOptions;
+
+ expect(() => resolveAuth0Options(options, false)).toThrow(InvalidConfigurationError);
+ });
+});
diff --git a/packages/auth0-nuxt/src/runtime/server/utils/config.ts b/packages/auth0-nuxt/src/runtime/server/utils/config.ts
new file mode 100644
index 0000000..6a47d5d
--- /dev/null
+++ b/packages/auth0-nuxt/src/runtime/server/utils/config.ts
@@ -0,0 +1,112 @@
+import { InvalidConfigurationError, type SessionConfiguration } from '@auth0/auth0-server-js';
+import type { Auth0ClientOptions } from '~/src/types';
+import { isUrl } from './app-base-url';
+
+/**
+ * Parses an appBaseUrl value coming from config or the environment.
+ * A comma-separated string becomes an allow-list array; a single value
+ * (or trailing-comma value) stays a string; falsy input becomes undefined.
+ * @param value The raw appBaseUrl string (e.g. from NUXT_AUTH0_APP_BASE_URL).
+ * @returns A string, an array of strings, or undefined.
+ */
+export function parseAppBaseUrl(value: string | undefined): string | string[] | undefined {
+ if (!value) {
+ return undefined;
+ }
+ if (value.includes(',')) {
+ const entries = value
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+ return entries.length === 1 ? entries[0] : entries;
+ }
+ return value;
+}
+
+/**
+ * Validates a (possibly parsed) appBaseUrl. `undefined` is valid (dynamic mode).
+ * @param appBaseUrl The configured app base URL.
+ * @throws {InvalidConfigurationError} When the configuration is invalid.
+ */
+export function validateAppBaseUrl(appBaseUrl: string | string[] | undefined): void {
+ if (appBaseUrl === undefined) {
+ return;
+ }
+
+ if (Array.isArray(appBaseUrl)) {
+ if (appBaseUrl.length === 0) {
+ throw new InvalidConfigurationError('appBaseUrl array configuration cannot be empty.');
+ }
+ const invalid = appBaseUrl.filter((url) => !isUrl(url));
+ if (invalid.length > 0) {
+ throw new InvalidConfigurationError(`appBaseUrl array contains invalid URLs: ${invalid.join(', ')}`);
+ }
+ return;
+ }
+
+ if (!isUrl(appBaseUrl)) {
+ throw new InvalidConfigurationError(`appBaseUrl must be a valid http(s) URL: ${appBaseUrl}`);
+ }
+}
+
+/**
+ * In production dynamic/allow-list mode, secure session cookies are required.
+ * Returns a `sessionConfiguration` with `cookie.secure = true` enforced, or the
+ * original configuration unchanged when enforcement does not apply.
+ *
+ * Does not mutate its input — the Nuxt runtime config is frozen.
+ * @param appBaseUrl The resolved app base URL (static, allow-list or omitted).
+ * @param sessionConfiguration The configured session configuration, if any.
+ * @param isProduction Whether the app is running in production.
+ * @returns The (possibly new) session configuration to use.
+ * @throws {InvalidConfigurationError} When secure cookies are explicitly disabled.
+ */
+export function enforceSecureCookies(
+ appBaseUrl: string | string[] | undefined,
+ sessionConfiguration: SessionConfiguration | undefined,
+ isProduction: boolean
+): SessionConfiguration | undefined {
+ const isDynamic = typeof appBaseUrl !== 'string';
+
+ if (!isProduction || !isDynamic) {
+ return sessionConfiguration;
+ }
+
+ if (sessionConfiguration?.cookie?.secure === false) {
+ throw new InvalidConfigurationError(
+ 'Secure cookies are required when relying on dynamic base URLs in production. ' +
+ 'Remove the explicit `sessionConfiguration.cookie.secure = false` or set a static appBaseUrl.'
+ );
+ }
+
+ return {
+ ...sessionConfiguration,
+ cookie: {
+ ...sessionConfiguration?.cookie,
+ secure: true,
+ },
+ };
+}
+
+/**
+ * Resolves and validates the Auth0 client options derived from the (frozen)
+ * Nuxt runtime config. Returns a new options object — the input is not mutated.
+ * @param options The raw Auth0 client options from runtime config.
+ * @param isProduction Whether the app is running in production.
+ * @returns A new, validated options object.
+ * @throws {InvalidConfigurationError} When the configuration is invalid.
+ */
+export function resolveAuth0Options(options: Auth0ClientOptions, isProduction: boolean): Auth0ClientOptions {
+ const appBaseUrl =
+ typeof options.appBaseUrl === 'string' ? parseAppBaseUrl(options.appBaseUrl) : options.appBaseUrl;
+
+ validateAppBaseUrl(appBaseUrl);
+
+ const sessionConfiguration = enforceSecureCookies(appBaseUrl, options.sessionConfiguration, isProduction);
+
+ return {
+ ...options,
+ appBaseUrl,
+ sessionConfiguration,
+ };
+}
diff --git a/packages/auth0-nuxt/src/types.ts b/packages/auth0-nuxt/src/types.ts
index c1cb46a..de8689c 100644
--- a/packages/auth0-nuxt/src/types.ts
+++ b/packages/auth0-nuxt/src/types.ts
@@ -78,12 +78,20 @@ export interface Auth0ClientOptions {
clientSecret: string;
/**
- * The base URL of your application.
- * This is the URL where your application is hosted.
- * It is used to construct redirect URIs for authentication flows.
+ * The base URL of your application, used to construct redirect URIs for
+ * authentication flows. Supports three modes:
+ *
+ * - A single URL string (static): `'https://app.example.com'`.
+ * - An array of URLs (allow-list): the request origin is matched against the
+ * list. Recommended when serving multiple origins from one Auth0 app.
+ * - Omitted (dynamic): the base URL is inferred from the incoming request
+ * host on each request.
+ *
+ * A comma-separated string (e.g. via `NUXT_AUTH0_APP_BASE_URL`) is parsed into
+ * an allow-list array.
* @example 'http://localhost:3000'
*/
- appBaseUrl: string;
+ appBaseUrl?: string | string[];
/**
* The secret used to sign session cookies.