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 @@ + + + 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 @@ + + + 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 @@ + 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 @@ + 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.