From 29df5846ffe50820c49c273f1829374cd9975197 Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 09:59:35 +0200 Subject: [PATCH 01/14] docs: add dynamic app base url design spec --- .../2026-06-19-dynamic-app-base-url-design.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-19-dynamic-app-base-url-design.md diff --git a/docs/superpowers/specs/2026-06-19-dynamic-app-base-url-design.md b/docs/superpowers/specs/2026-06-19-dynamic-app-base-url-design.md new file mode 100644 index 0000000..7e2be92 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-dynamic-app-base-url-design.md @@ -0,0 +1,180 @@ +# Dynamic Application Base URL — Design + +**Date:** 2026-06-19 +**Package:** `@auth0/auth0-nuxt` +**Reference:** [auth0-express PR #4](https://github.com/auth0/auth0-express/pull/4) + +## Problem + +Today `appBaseUrl` is a single static string, baked into the `ServerClient`'s +`redirect_uri` at construction. This forces a separate deployment per origin and +prevents serving multiple hostnames (multi-tenant, preview deployments) from one +Auth0 application. + +This feature lets a single Nuxt app serve multiple origins by resolving the +application base URL **per request** instead of once at startup. + +## Modes + +`appBaseUrl` accepts three shapes, selecting a mode: + +| `appBaseUrl` value | Mode | Behavior | +|---|---|---| +| `"https://app.example.com"` | **Static** | Used as-is. Current behavior, unchanged. | +| `["https://a.com", "https://b.com"]` or `"https://a.com, https://b.com"` | **Allow-list** | Request origin matched against the list; the matching configured entry is used. Recommended for production. | +| omitted / empty | **Dynamic** | Base URL inferred from the request host per request. | + +Environment override remains `NUXT_AUTH0_APP_BASE_URL` (Nuxt runtime-config +convention). A comma-separated value is parsed into an allow-list array. + +## Architecture + +The Nuxt SDK differs from Express in three relevant ways, which shape the design: + +1. **No `getConfig()`** — config comes from `runtimeConfig.auth0` and is validated + at startup in the `auth.server.ts` nitro plugin. Parsing, validation, and + secure-cookie enforcement live there (Nuxt's equivalent of Express's `getConfig`). +2. **`ServerClient` is created per-event** in `use-auth0.ts`, with `redirect_uri` + baked from `appBaseUrl`. In dynamic/allow-list mode there is no static value to + bake, so the login handler supplies `redirect_uri` per request. +3. **No Express `trust proxy fn`** — Nitro has no built-in trust policy. We honor + `x-forwarded-host` / `x-forwarded-proto` by default via h3's request getters. + Security rests on Auth0 Allowed Callback URLs and the allow-list mode, exactly + as the Express PR documents. + +### Components + +#### `src/runtime/server/utils/app-base-url.ts` (new) + +```ts +export function isUrl(value: string): boolean +// new URL(value); protocol ∈ {http:, https:} + +export function inferBaseUrlFromRequest(event: H3Event): string | null +// host = getRequestHost(event, { xForwardedHost: true }) +// proto = getRequestProtocol(event, { xForwardedProto: true }) +// const candidate = `${proto}://${host}` +// return isUrl(candidate) ? candidate : null + +export function resolveAppBaseUrl( + appBaseUrl: string | string[] | undefined, + event?: H3Event, +): string +``` + +`resolveAppBaseUrl` semantics (identical to the Express reference): + +- `string` → returned as-is. +- no `event` → throw `InvalidConfigurationError`. +- infer origin from request; `null` → throw `InvalidConfigurationError`. +- `undefined` (pure dynamic) → return inferred origin. +- `string[]` (allow-list) → match inferred origin against each entry's + `new URL(entry).origin`; return the **matching configured entry**, else throw + `InvalidConfigurationError`. + +h3's `getRequestHost`/`getRequestProtocol` fall back to the raw `Host` header when +the forwarded header is absent, so the no-proxy case works without configuration. + +This lives alongside the existing `url.ts` (one clear purpose per file) rather than +being merged into it. + +#### `src/runtime/server/plugins/auth.server.ts` (modified) + +Startup validation gains three helpers (ported from Express `config.ts`): + +- `parseAppBaseUrl(value)` — split comma-separated strings, trim, drop empties; + collapse single-element results back to a string. +- `validateAppBaseUrl(appBaseUrl)` — allow `undefined`; reject empty arrays; + reject any non-http(s) URL (naming the offending entry). +- `enforceSecureCookies(options)` — when `NODE_ENV === 'production'` **and** mode is + dynamic/allow-list (`typeof appBaseUrl !== 'string'`): force + `sessionConfiguration.cookie.secure = true`; throw `InvalidConfigurationError` + if it was explicitly set to `false`. + +The current hard check `if (!options.appBaseUrl) throw ...` is **removed** (dynamic +mode legitimately omits it). The other required-field checks (domain, clientId, +clientSecret, sessionSecret) stay. + +#### `src/runtime/server/composables/use-auth0.ts` (modified) + +`createServerClientInstance` bakes `redirect_uri` into the `ServerClient` **only when +`appBaseUrl` is a static string**. In dynamic/allow-list mode the baked `redirect_uri` +is omitted; the login handler always supplies it per request, and the callback builds +its URL from the resolved base. `getUser()` (global middleware, every page) and +backchannel logout construct the client too but never need `redirect_uri`, so omitting +it in dynamic mode is safe. + +### Handlers + +All three resolve the base URL from their own event. + +**`login.get.ts`** +```ts +const appBaseUrl = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event); +const callbackPath = runtimeConfig.public.auth0.routes?.callback ?? '/auth/callback'; +const redirectUri = createRouteUrl(callbackPath, appBaseUrl); +const dangerousReturnTo = query.returnTo ?? appBaseUrl; +const sanitizedReturnTo = toSafeRedirect(dangerousReturnTo as string, appBaseUrl); +await auth0Client.startInteractiveLogin({ + appState: { returnTo: sanitizedReturnTo }, + authorizationParams: { redirect_uri: redirectUri.toString() }, +}); +``` + +**`callback.get.ts`** — resolve `appBaseUrl` from the event; use it for both +`completeInteractiveLogin(new URL(event.node.req.url, appBaseUrl))` and the fallback +redirect target. + +**`logout.get.ts`** — `returnTo = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event)`. + +### Types + +`Auth0ClientOptions.appBaseUrl: string` → `string | string[]` (optional). Updated +JSDoc describing the three modes. `InvalidConfigurationError` re-exported from the +module entry (`module.ts`), sourced from `@auth0/auth0-server-js`. + +## Error handling + +`InvalidConfigurationError` (from `@auth0/auth0-server-js`) is thrown when: + +- a static `appBaseUrl` is not a valid http(s) URL (startup); +- an allow-list array is empty or contains an invalid URL (startup); +- `sessionConfiguration.cookie.secure` is explicitly `false` in production + dynamic/allow-list mode (startup); +- at request time, the base URL cannot be resolved (no event, indeterminable + origin, or no allow-list match). + +## Testing + +Vitest, following existing `.spec.ts` patterns. + +- **`app-base-url.spec.ts`** (new) — `isUrl`; `inferBaseUrlFromRequest` with and + without `x-forwarded-*`; `resolveAppBaseUrl` across static / dynamic / allow-list + including allow-list miss → throw and missing-event → throw. +- **Plugin config tests** — comma-separated parsing, single-element collapse, + trailing-comma collapse, empty-array reject, invalid-URL reject (names entry), + omitted → dynamic, secure-cookie enforcement (force `true` in prod dynamic and + allow-list; throw on explicit `false`; untouched for static and non-prod). +- **Handler spec updates** — `login.get.spec.ts` asserts `redirect_uri` passed in + `authorizationParams`; `callback.get.spec.ts` / `logout.get.spec.ts` assert + dynamic resolution; existing static-mode assertions updated to include the new + `redirect_uri` argument. + +## Example app + +New `examples/example-nuxt-web-dynamic-app-base-url`, based on `example-nuxt-web`, +configured with an allow-list serving two hosts (`app1.localhost:3000` / +`app2.localhost:3000`) against one Auth0 application. README documents the +`/etc/hosts` setup and demonstrates that each request's origin resolves its own +callback and logout URLs. + +## Docs + +`README.md` and `EXAMPLES.md`: the three modes, the `NUXT_AUTH0_APP_BASE_URL` +comma-separated env form, the production secure-cookie requirement, and the +forwarded-header behavior. + +## Out of scope + +- A configurable `trustProxy` flag (forwarded headers are honored by default). +- Per-tenant client secrets / multiple Auth0 applications (one app, many origins). From ea4341d5950e0d7bb574969ea4034e5dc743e624 Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:29:55 +0200 Subject: [PATCH 02/14] docs: add dynamic app base url implementation plan --- .../plans/2026-06-19-dynamic-app-base-url.md | 1177 +++++++++++++++++ 1 file changed, 1177 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-19-dynamic-app-base-url.md diff --git a/docs/superpowers/plans/2026-06-19-dynamic-app-base-url.md b/docs/superpowers/plans/2026-06-19-dynamic-app-base-url.md new file mode 100644 index 0000000..d183c19 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-dynamic-app-base-url.md @@ -0,0 +1,1177 @@ +# Dynamic Application Base URL Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a single `@auth0/auth0-nuxt` app serve multiple origins by resolving the application base URL per request (static / allow-list / dynamic modes), mirroring auth0-express PR #4. + +**Architecture:** A new request-time resolver (`app-base-url.ts`) infers the origin from the h3 event (honoring `x-forwarded-*`). Startup parsing/validation/secure-cookie enforcement live in the existing nitro plugin (`auth.server.ts`). Login supplies `redirect_uri` per request; callback/logout resolve from their own event; `use-auth0.ts` only bakes a static `redirect_uri`. + +**Tech Stack:** TypeScript, Nuxt module + Nitro, h3, `@auth0/auth0-server-js`, Vitest. + +**Working directory:** `packages/auth0-nuxt`. Run all `npm`/`vitest` commands from there. Branch: `feat/dynamic-app-base-url` (already created; the design spec is already committed). + +--- + +## File Structure + +- **Create** `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts` — `isUrl`, `inferBaseUrlFromRequest`, `resolveAppBaseUrl`. +- **Create** `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts` — unit tests for the resolver. +- **Modify** `packages/auth0-nuxt/src/types.ts` — `appBaseUrl: string | string[]` (optional) + JSDoc. +- **Modify** `packages/auth0-nuxt/src/module.ts` — re-export `InvalidConfigurationError`. +- **Modify** `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts` — parse/validate appBaseUrl, enforce secure cookies, drop the hard required-check. +- **Create** `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.spec.ts` — config parsing/validation/secure-cookie tests. +- **Modify** `packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts` — only bake `redirect_uri` in static mode. +- **Modify** `packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts` (+ `.spec.ts`) — resolve base URL, pass `redirect_uri`. +- **Modify** `packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts` (+ `.spec.ts`) — resolve base URL per request. +- **Modify** `packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts` (+ `.spec.ts`) — resolve base URL per request. +- **Create** `examples/example-nuxt-web-dynamic-app-base-url/*` — multi-host example app. +- **Modify** `packages/auth0-nuxt/README.md` and `packages/auth0-nuxt/EXAMPLES.md` — document modes. + +> **Note on `InvalidConfigurationError`:** it is imported from `@auth0/auth0-server-js`. Before Task 1, verify it is exported there: +> Run: `node -e "import('@auth0/auth0-server-js').then(m => console.log(typeof m.InvalidConfigurationError))"` from `packages/auth0-nuxt`. +> Expected: `function`. If it prints `undefined`, stop and report — the express PR depends on this export; the Nuxt port needs the same `@auth0/auth0-server-js` version. (Express used `^1.2.0`+; bump the dependency if needed.) + +--- + +## Task 1: Base URL resolver utility + +**Files:** +- Create: `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts` +- Test: `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts` + +- [ ] **Step 1: Write the failing test** + +Create `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts`: + +```ts +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('returns null when no host can be determined', () => { + const event = makeEvent({}); + 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(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/runtime/server/utils/app-base-url.spec.ts` +Expected: FAIL — cannot resolve `./app-base-url`. + +- [ ] **Step 3: Write the implementation** + +Create `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts`: + +```ts +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. + */ +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. + */ +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. + * + * @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.' + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/runtime/server/utils/app-base-url.spec.ts` +Expected: PASS (all cases). + +- [ ] **Step 5: Commit** + +```bash +git add packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts +git commit -m "feat: add per-request app base url resolver" +``` + +--- + +## Task 2: Update types + +**Files:** +- Modify: `packages/auth0-nuxt/src/types.ts:80-86` + +- [ ] **Step 1: Change the `appBaseUrl` type and JSDoc** + +In `packages/auth0-nuxt/src/types.ts`, replace the existing `appBaseUrl` block: + +```ts + /** + * 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. + * @example 'http://localhost:3000' + */ + appBaseUrl: string; +``` + +with: + +```ts + /** + * 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 | string[]; +``` + +- [ ] **Step 2: Verify it type-checks** + +Run: `npx vitest run src/runtime/server/utils/app-base-url.spec.ts` +Expected: PASS (no type regressions in the resolver, which already expects `string | string[] | undefined`). + +- [ ] **Step 3: Commit** + +```bash +git add packages/auth0-nuxt/src/types.ts +git commit -m "feat: allow appBaseUrl to be an array or omitted" +``` + +--- + +## Task 3: Re-export InvalidConfigurationError + +**Files:** +- Modify: `packages/auth0-nuxt/src/module.ts:13-14` + +- [ ] **Step 1: Add the re-export** + +In `packages/auth0-nuxt/src/module.ts`, after the existing `export type { ... }` line (currently line 14), add: + +```ts +export { InvalidConfigurationError } from '@auth0/auth0-server-js'; +``` + +- [ ] **Step 2: Verify the module spec still passes** + +Run: `npx vitest run src/module.spec.ts` +Expected: PASS (unchanged behavior). + +- [ ] **Step 3: Commit** + +```bash +git add packages/auth0-nuxt/src/module.ts +git commit -m "feat: re-export InvalidConfigurationError" +``` + +--- + +## Task 4: Config parsing, validation & secure-cookie enforcement (plugin) + +**Files:** +- Modify: `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts` +- Test: `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.spec.ts` + +This task extracts the parse/validate/enforce logic into exported pure functions so they can be unit-tested without a running Nitro app, then wires them into the plugin. + +- [ ] **Step 1: Write the failing test** + +Create `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.spec.ts`: + +```ts +import { describe, it, expect, afterEach } from 'vitest'; +import { InvalidConfigurationError } from '@auth0/auth0-server-js'; +import { parseAppBaseUrl, validateAppBaseUrl, enforceSecureCookies } from './auth.server'; + +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 options: any = { appBaseUrl: undefined }; + enforceSecureCookies(options, true); + expect(options.sessionConfiguration.cookie.secure).toBe(true); + }); + + it('forces secure=true in production allow-list mode', () => { + const options: any = { appBaseUrl: ['https://a.com'] }; + enforceSecureCookies(options, true); + expect(options.sessionConfiguration.cookie.secure).toBe(true); + }); + + it('throws when secure is explicitly false in production dynamic mode', () => { + const options: any = { + appBaseUrl: undefined, + sessionConfiguration: { cookie: { secure: false } }, + }; + expect(() => enforceSecureCookies(options, true)).toThrow(InvalidConfigurationError); + }); + + it('does not touch secure for a static appBaseUrl in production', () => { + const options: any = { appBaseUrl: 'https://app.example.com' }; + enforceSecureCookies(options, true); + expect(options.sessionConfiguration?.cookie?.secure).toBeUndefined(); + }); + + it('does not touch secure outside production', () => { + const options: any = { appBaseUrl: undefined }; + enforceSecureCookies(options, false); + expect(options.sessionConfiguration?.cookie?.secure).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/runtime/server/plugins/auth.server.spec.ts` +Expected: FAIL — `parseAppBaseUrl`/`validateAppBaseUrl`/`enforceSecureCookies` not exported. + +- [ ] **Step 3: Write the implementation** + +Replace the contents of `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts` with: + +```ts +import { useRuntimeConfig } from '#imports'; +import { ServerClient, InvalidConfigurationError, type SessionStore } from '@auth0/auth0-server-js'; +import { defineNitroPlugin } from 'nitropack/dist/runtime/plugin'; +import type { Auth0ClientOptions, StoreOptions } from '~/src/types'; +import { isUrl } from '../utils/app-base-url'; + +declare module 'h3' { + interface H3EventContext { + auth0Client: ServerClient<{ event: H3Event }>; + } +} + +/** + * 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. + */ +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). + * @throws {InvalidConfigurationError} + */ +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. + * Forces `sessionConfiguration.cookie.secure = true`, or throws if it was + * explicitly set to `false`. + * @throws {InvalidConfigurationError} + */ +export function enforceSecureCookies(options: Auth0ClientOptions, isProduction: boolean): void { + const isDynamic = typeof options.appBaseUrl !== 'string'; + + if (!isProduction || !isDynamic) { + return; + } + + if (options.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.' + ); + } + + options.sessionConfiguration = { + ...options.sessionConfiguration, + cookie: { + ...options.sessionConfiguration?.cookie, + secure: true, + }, + }; +} + +async function tryLoadSessionStore(): Promise | undefined> { + try { + const factoryModule = await import('#auth0-session-store'); + return factoryModule.default(); + } catch { + return undefined; + } +} + +export default defineNitroPlugin(async (nitroApp) => { + const config = useRuntimeConfig(); + const options = 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.sessionSecret) throw new Error('Auth0 configuration error: Session Secret is required'); + + // Normalize a comma-separated appBaseUrl (from env or config) into an allow-list, + // then validate and (in production dynamic mode) enforce secure cookies. + if (typeof options.appBaseUrl === 'string') { + options.appBaseUrl = parseAppBaseUrl(options.appBaseUrl); + } + validateAppBaseUrl(options.appBaseUrl); + enforceSecureCookies(options, process.env.NODE_ENV === 'production'); + + const sessionStoreInstance = await tryLoadSessionStore(); + + nitroApp.hooks.hook('request', async (event) => { + event.context.auth0ClientOptions = options; + event.context.auth0SessionStore = sessionStoreInstance; + }); +}); +``` + +> Note: the previous `if (!options.appBaseUrl) throw ...` check is intentionally removed — dynamic mode legitimately omits `appBaseUrl`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/runtime/server/plugins/auth.server.spec.ts` +Expected: PASS (all cases). + +- [ ] **Step 5: Commit** + +```bash +git add packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts packages/auth0-nuxt/src/runtime/server/plugins/auth.server.spec.ts +git commit -m "feat: parse, validate and enforce secure cookies for appBaseUrl" +``` + +--- + +## Task 5: Only bake static redirect_uri in use-auth0 + +**Files:** +- Modify: `packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts:82-123` + +- [ ] **Step 1: Update `createServerClientInstance`** + +In `packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts`, replace the redirect-uri computation and the `authorizationParams` block. Replace: + +```ts + const callbackPath = publicConfig.routes?.callback ?? '/auth/callback'; + const redirectUri = createRouteUrl(callbackPath, options.appBaseUrl); + + return new ServerClient({ + domain: options.domain, + clientId: options.clientId, + clientSecret: options.clientSecret, + authorizationParams: { + audience: options.audience, + redirect_uri: redirectUri.toString(), + }, +``` + +with: + +```ts + // 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 = + typeof options.appBaseUrl === 'string' + ? createRouteUrl(callbackPath, options.appBaseUrl).toString() + : undefined; + + return new ServerClient({ + domain: options.domain, + clientId: options.clientId, + clientSecret: options.clientSecret, + authorizationParams: { + audience: options.audience, + redirect_uri: redirectUri, + }, +``` + +- [ ] **Step 2: Verify existing specs still pass** + +Run: `npx vitest run src/runtime/server/composables/use-auth0.server.spec.ts` +Expected: PASS (static-mode behavior unchanged; `redirect_uri` still set for string `appBaseUrl`). + +- [ ] **Step 3: Commit** + +```bash +git add packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts +git commit -m "feat: only bake static redirect_uri into the server client" +``` + +--- + +## Task 6: Login handler resolves base URL and passes redirect_uri + +**Files:** +- Modify: `packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts` +- Test: `packages/auth0-nuxt/src/runtime/server/api/auth/login.get.spec.ts` + +- [ ] **Step 1: Update the login spec to expect redirect_uri** + +Replace the body of `packages/auth0-nuxt/src/runtime/server/api/auth/login.get.spec.ts` with: + +```ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import loginHandler from './login.get'; +import type { H3Event } from 'h3'; + +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()), + sendRedirect: sendRedirectMock, + getQuery: getQueryMock, +})); + +vi.mock('#imports', () => ({ useRuntimeConfig: useRuntimeConfigMock })); + +const mockAuth0Client = { + startInteractiveLogin: vi.fn().mockResolvedValue({}), +}; + +vi.mock('../../composables/use-auth0', () => ({ + useAuth0: vi.fn(() => mockAuth0Client), +})); + +describe('login.get handler', () => { + const mockEvent = { + context: { + auth0ClientOptions: { appBaseUrl: 'http://localhost:3000' }, + }, + node: { req: { headers: { host: 'localhost:3000' }, socket: {} }, res: { setHeader: vi.fn() } }, + } as unknown as H3Event; + + beforeEach(() => { + vi.clearAllMocks(); + mockAuth0Client.startInteractiveLogin.mockResolvedValue(new URL('http://redirectTo')); + }); + + 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('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('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'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/runtime/server/api/auth/login.get.spec.ts` +Expected: FAIL — handler does not yet pass `authorizationParams.redirect_uri`. + +- [ ] **Step 3: Update the login handler** + +Replace the contents of `packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts` with: + +```ts +import { useAuth0 } from '../../composables/use-auth0'; +import { defineEventHandler, getQuery, sendRedirect } from 'h3'; +import { useRuntimeConfig } from '#imports'; +import { createRouteUrl, toSafeRedirect } from './../../utils/url'; +import { resolveAppBaseUrl } from './../../utils/app-base-url'; + +interface LoginParams { + returnTo?: string; +} + +export default defineEventHandler(async (event) => { + const auth0Client = useAuth0(event); + const auth0ClientOptions = event.context.auth0ClientOptions; + const runtimeConfig = useRuntimeConfig(); + + const appBaseUrl = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event); + const callbackPath = runtimeConfig.public.auth0?.routes?.callback ?? '/auth/callback'; + const redirectUri = createRouteUrl(callbackPath, 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); +}); +``` + +> If `runtimeConfig.public.auth0` is not typed with `routes`, cast as needed: the existing `Auth0PublicConfig` interface in `use-auth0.ts` already models `routes?: RouteConfig`. Reuse that type if a cast is required. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/runtime/server/api/auth/login.get.spec.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts packages/auth0-nuxt/src/runtime/server/api/auth/login.get.spec.ts +git commit -m "feat: resolve app base url and pass redirect_uri on login" +``` + +--- + +## Task 7: Callback handler resolves base URL per request + +**Files:** +- Modify: `packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts` +- Test: `packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.spec.ts` + +- [ ] **Step 1: Add a dynamic-mode test** + +In `packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.spec.ts`, update `mockEvent` so the request has a host header (needed for dynamic resolution to be exercisable), and add a dynamic test. Change the `mockEvent` `node.req` to: + +```ts + node: { + req: { + url: 'foo', + headers: { host: 'localhost:3000' }, + socket: {}, + }, + }, +``` + +Then append this test inside the `describe('callback.get handler', ...)` block: + +```ts + 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'); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/runtime/server/api/auth/callback.get.spec.ts` +Expected: FAIL — the new dynamic test fails because the handler still reads `appBaseUrl` directly (it is `undefined`, producing an invalid URL). + +- [ ] **Step 3: Update the callback handler** + +Replace the contents of `packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts` with: + +```ts +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, appBaseUrl) + ); + + const safeReturnTo = appState?.returnTo ? toSafeRedirect(appState.returnTo, appBaseUrl) : appBaseUrl; + + sendRedirect(event, safeReturnTo ?? appBaseUrl); +}); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/runtime/server/api/auth/callback.get.spec.ts` +Expected: PASS (existing static tests + new dynamic test). + +- [ ] **Step 5: Commit** + +```bash +git add packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.spec.ts +git commit -m "feat: resolve app base url per request on callback" +``` + +--- + +## Task 8: Logout handler resolves base URL per request + +**Files:** +- Modify: `packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts` +- Test: `packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.spec.ts` + +- [ ] **Step 1: Add a dynamic-mode test** + +In `packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.spec.ts`, update `mockEvent`'s context to also carry request headers, and add a dynamic test. Change `mockEvent` to: + +```ts + const mockEvent = { + context: { + auth0ClientOptions: { appBaseUrl: 'http://localhost:3000' }, + }, + node: { req: { headers: { host: 'localhost:3000' }, socket: {} } }, + } as unknown as H3Event; +``` + +Then append inside the `describe`: + +```ts + 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' }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/runtime/server/api/auth/logout.get.spec.ts` +Expected: FAIL — handler still passes `undefined` as `returnTo` in dynamic mode. + +- [ ] **Step 3: Update the logout handler** + +Replace the contents of `packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts` with: + +```ts +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 returnTo = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event); + const logoutUrl = await auth0Client.logout({ returnTo }); + + sendRedirect(event, logoutUrl.href); +}); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/runtime/server/api/auth/logout.get.spec.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.spec.ts +git commit -m "feat: resolve app base url per request on logout" +``` + +--- + +## Task 9: Full unit suite green + +**Files:** none (verification). + +- [ ] **Step 1: Run the full unit suite** + +Run: `npm run test:unit` +Expected: PASS — all `src/**` specs green, including the new and updated ones. + +- [ ] **Step 2: Lint** + +Run: `npm run lint` +Expected: no errors. Fix any lint issues introduced (e.g. import ordering) and re-run. + +- [ ] **Step 3: Commit any fixups** + +```bash +git add -A +git commit -m "chore: lint fixups for dynamic app base url" || echo "nothing to commit" +``` + +--- + +## Task 10: Multi-host example app + +**Files:** +- Create: `examples/example-nuxt-web-dynamic-app-base-url/` (based on `examples/example-nuxt-web`) + +This example demonstrates allow-list mode serving two hosts from one Auth0 app. + +- [ ] **Step 1: Scaffold from the existing example** + +Run (from repo root): + +```bash +cp -R examples/example-nuxt-web examples/example-nuxt-web-dynamic-app-base-url +rm -rf examples/example-nuxt-web-dynamic-app-base-url/node_modules examples/example-nuxt-web-dynamic-app-base-url/.nuxt examples/example-nuxt-web-dynamic-app-base-url/docker-compose.yml examples/example-nuxt-web-dynamic-app-base-url/server/utils/session-store-factory.ts +``` + +- [ ] **Step 2: Rename the package** + +In `examples/example-nuxt-web-dynamic-app-base-url/package.json`, change the `name` field to: + +```json + "name": "example-nuxt-web-dynamic-app-base-url", +``` + +- [ ] **Step 3: Configure allow-list mode** + +Replace `examples/example-nuxt-web-dynamic-app-base-url/nuxt.config.ts` with a minimal config that omits a static `appBaseUrl` and relies on `NUXT_AUTH0_APP_BASE_URL` (set as a comma-separated allow-list via env). Use: + +```ts +// 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: '', // NUXT_AUTH0_DOMAIN + clientId: '', // NUXT_AUTH0_CLIENT_ID + clientSecret: '', // NUXT_AUTH0_CLIENT_SECRET + sessionSecret: '', // NUXT_AUTH0_SESSION_SECRET + // appBaseUrl omitted here on purpose; provided as a comma-separated + // allow-list via NUXT_AUTH0_APP_BASE_URL (see .env.example). + audience: '', // NUXT_AUTH0_AUDIENCE + }, + }, +}); +``` + +> Remove the bootstrap `app.head` / `nitro.storage.redis` blocks copied from the source config if present; they are not needed here. Keep `app.vue`, `pages/`, and `middleware/` as copied. + +- [ ] **Step 4: Set the example env** + +Replace `examples/example-nuxt-web-dynamic-app-base-url/.env.example` with: + +``` +NUXT_AUTH0_DOMAIN= +NUXT_AUTH0_CLIENT_ID= +NUXT_AUTH0_CLIENT_SECRET= +NUXT_AUTH0_SESSION_SECRET= +# Allow-list of origins this single app serves. Comma-separated. +NUXT_AUTH0_APP_BASE_URL=http://app1.localhost:3000,http://app2.localhost:3000 +``` + +- [ ] **Step 5: Write the example README** + +Create `examples/example-nuxt-web-dynamic-app-base-url/README.md`: + +```markdown +# Dynamic App Base URL (Nuxt) Example + +Serves one Auth0 application across multiple origins. `NUXT_AUTH0_APP_BASE_URL` +is a comma-separated allow-list; the SDK resolves the base URL from each +request's origin. + +## Setup + +1. Add both hosts to your `/etc/hosts`: + + ``` + 127.0.0.1 app1.localhost app2.localhost + ``` + + (Many systems already resolve `*.localhost` to `127.0.0.1`.) + +2. In your Auth0 application, add **both** origins to Allowed Callback URLs and + Allowed Logout URLs: + + - `http://app1.localhost:3000/auth/callback`, `http://app2.localhost:3000/auth/callback` + - `http://app1.localhost:3000`, `http://app2.localhost:3000` + +3. Copy `.env.example` to `.env` and fill in your tenant values. + +4. Run the app: + + ```bash + npm run dev + ``` + +5. Visit `http://app1.localhost:3000` and `http://app2.localhost:3000` — logging + in from either origin redirects back to that same origin. +``` + +- [ ] **Step 6: Verify the example builds** + +Run (from repo root): `npm install && npm run build` +Expected: the workspace builds, including the new example (Nuxt prepares it). If the example fails only because real Auth0 env vars are absent at *runtime*, that is acceptable — the build/prepare step should still pass. + +- [ ] **Step 7: Commit** + +```bash +git add examples/example-nuxt-web-dynamic-app-base-url +git commit -m "docs: add dynamic app base url nuxt example" +``` + +--- + +## Task 11: Documentation + +**Files:** +- Modify: `packages/auth0-nuxt/README.md` +- Modify: `packages/auth0-nuxt/EXAMPLES.md` + +- [ ] **Step 1: Document the modes in EXAMPLES.md** + +Add a new section to `packages/auth0-nuxt/EXAMPLES.md` (place it after the configuration/getting-started section; match the surrounding heading style): + +```markdown +## Dynamic Application Base URL + +`appBaseUrl` supports serving multiple origins from a single Auth0 application: + +- **Static** — a single URL string (default): + + ``` + NUXT_AUTH0_APP_BASE_URL=https://app.example.com + ``` + +- **Allow-list** — a comma-separated list of origins. The SDK matches the + incoming request origin against the list and uses the matching entry. + Recommended 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 `x-forwarded-host` / `x-forwarded-proto`). + +Add every origin to your Auth0 application's **Allowed Callback URLs** and +**Allowed Logout URLs** — this remains the primary safeguard. + +> **Production note:** In dynamic and allow-list modes, secure session cookies +> are enforced when `NODE_ENV=production`. 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. +``` + +- [ ] **Step 2: Cross-link from README.md** + +In `packages/auth0-nuxt/README.md`, in the section that documents configuration options (where `appBaseUrl` / `NUXT_AUTH0_APP_BASE_URL` is described), add a sentence: + +```markdown +`appBaseUrl` may be a single URL, a comma-separated allow-list of origins, or +omitted to infer the origin per request. See +[Dynamic Application Base URL](./EXAMPLES.md#dynamic-application-base-url). +``` + +> If the README does not yet describe `appBaseUrl`, add the sentence under the configuration/options heading near the other env vars. + +- [ ] **Step 3: Commit** + +```bash +git add packages/auth0-nuxt/README.md packages/auth0-nuxt/EXAMPLES.md +git commit -m "docs: document dynamic application base url modes" +``` + +--- + +## Task 12: Final verification + +**Files:** none (verification). + +- [ ] **Step 1: Run the full unit suite once more** + +Run (from `packages/auth0-nuxt`): `npm run test:unit` +Expected: PASS. + +- [ ] **Step 2: Lint the package** + +Run: `npm run lint` +Expected: no errors. + +- [ ] **Step 3: Type-check via build** + +Run: `npm run build` +Expected: builds cleanly (the module-builder runs `nuxt-module-build`, which type-checks the runtime). + +- [ ] **Step 4: Review the diff against the spec** + +Manually confirm every spec section is covered: types, resolver, plugin validation/enforcement, handler wiring, example app, docs. Note anything missing. + +- [ ] **Step 5: Final commit if needed** + +```bash +git add -A +git commit -m "chore: finalize dynamic app base url" || echo "nothing to commit" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** types (T2), `InvalidConfigurationError` export (T3), resolver `isUrl`/`inferBaseUrlFromRequest`/`resolveAppBaseUrl` (T1), plugin parse/validate/enforce (T4), static-only baked redirect_uri (T5), login/callback/logout wiring (T6–T8), example app (T10), docs (T11). All spec sections map to a task. +- **Forwarded headers:** honored by default via h3 getters (T1) — matches the approved design. +- **Type consistency:** `resolveAppBaseUrl(appBaseUrl, event?)`, `parseAppBaseUrl`, `validateAppBaseUrl`, `enforceSecureCookies(options, isProduction)` signatures are used identically across tasks and tests. +- **Risk flagged:** the `@auth0/auth0-server-js` `InvalidConfigurationError` export is verified up front (pre-Task-1 note); bump the dependency if missing. +``` From 4e1a9c2ce72ca7ce06e6d3fdd5293cca74e6b9b6 Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:38:46 +0200 Subject: [PATCH 03/14] docs: add dynamic app base url nuxt example --- .../.env.example | 7 ++ .../.gitignore | 24 ++++++ .../README.md | 75 ++++++++++++++++++ .../app.vue | 62 +++++++++++++++ .../nuxt.config.ts | 51 ++++++++++++ .../package.json | 24 ++++++ .../pages/index.vue | 19 +++++ .../pages/private.vue | 1 + .../pages/public.vue | 1 + .../public/favicon.ico | Bin 0 -> 4286 bytes .../public/img/auth0.png | Bin 0 -> 9142 bytes .../public/robots.txt | 1 + .../server/middleware/auth.server.ts | 11 +++ .../tsconfig.json | 4 + 14 files changed, 280 insertions(+) create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/.env.example create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/.gitignore create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/README.md create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/app.vue create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/nuxt.config.ts create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/package.json create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/pages/index.vue create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/pages/private.vue create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/pages/public.vue create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/public/favicon.ico create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/public/img/auth0.png create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/public/robots.txt create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/server/middleware/auth.server.ts create mode 100644 examples/example-nuxt-web-dynamic-app-base-url/tsconfig.json 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 0000000000000000000000000000000000000000..18993ad91cfd43e03b074dd0b5cc3f37ab38e49c GIT binary patch literal 4286 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 literal 0 HcmV?d00001 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" +} From cd7be462e18d58c33e3df136966de83515673ffd Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:50:53 +0200 Subject: [PATCH 04/14] feat: add per-request app base url resolver --- .../runtime/server/utils/app-base-url.spec.ts | 80 ++++++++++++++++ .../src/runtime/server/utils/app-base-url.ts | 96 +++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts create mode 100644 packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts 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.' + ); +} From 1604139140cb43a4ffa9eb3d00071f6bca4863c0 Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:51:16 +0200 Subject: [PATCH 05/14] feat: allow appBaseUrl to be an array or omitted --- packages/auth0-nuxt/src/types.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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. From fd348ca83bb2648b64db2a32ad7ca280af1bf234 Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:51:38 +0200 Subject: [PATCH 06/14] feat: re-export InvalidConfigurationError --- packages/auth0-nuxt/src/module.ts | 1 + 1 file changed, 1 insertion(+) 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. From f080d5df994f7b690083fa138f4acb63754a7111 Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:53:53 +0200 Subject: [PATCH 07/14] feat: parse, validate and enforce secure cookies for appBaseUrl --- .../src/runtime/server/plugins/auth.server.ts | 15 +++- .../src/runtime/server/utils/config.spec.ts | 82 +++++++++++++++++++ .../src/runtime/server/utils/config.ts | 81 ++++++++++++++++++ 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 packages/auth0-nuxt/src/runtime/server/utils/config.spec.ts create mode 100644 packages/auth0-nuxt/src/runtime/server/utils/config.ts 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..601a779 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 { parseAppBaseUrl, validateAppBaseUrl, enforceSecureCookies } from '../utils/config'; declare module 'h3' { interface H3EventContext { @@ -20,14 +21,22 @@ async function tryLoadSessionStore(): Promise | undef export default defineNitroPlugin(async (nitroApp) => { const config = useRuntimeConfig(); - const options = config.auth0; + const options = 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'); + // Normalize a comma-separated appBaseUrl (from env or config) into an allow-list, + // then validate and (in production dynamic mode) enforce secure cookies. + // Omitting appBaseUrl entirely enables dynamic, per-request resolution. + if (typeof options.appBaseUrl === 'string') { + options.appBaseUrl = parseAppBaseUrl(options.appBaseUrl); + } + validateAppBaseUrl(options.appBaseUrl); + enforceSecureCookies(options, process.env.NODE_ENV === 'production'); + const sessionStoreInstance = await tryLoadSessionStore(); nitroApp.hooks.hook('request', async (event) => { 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..f61a402 --- /dev/null +++ b/packages/auth0-nuxt/src/runtime/server/utils/config.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { InvalidConfigurationError } from '@auth0/auth0-server-js'; +import { parseAppBaseUrl, validateAppBaseUrl, enforceSecureCookies } 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 options = { appBaseUrl: undefined } as Auth0ClientOptions; + enforceSecureCookies(options, true); + expect(options.sessionConfiguration?.cookie?.secure).toBe(true); + }); + + it('forces secure=true in production allow-list mode', () => { + const options = { appBaseUrl: ['https://a.com'] } as Auth0ClientOptions; + enforceSecureCookies(options, true); + expect(options.sessionConfiguration?.cookie?.secure).toBe(true); + }); + + it('throws when secure is explicitly false in production dynamic mode', () => { + const options = { + appBaseUrl: undefined, + sessionConfiguration: { cookie: { secure: false } }, + } as Auth0ClientOptions; + expect(() => enforceSecureCookies(options, true)).toThrow(InvalidConfigurationError); + }); + + it('does not touch secure for a static appBaseUrl in production', () => { + const options = { appBaseUrl: 'https://app.example.com' } as Auth0ClientOptions; + enforceSecureCookies(options, true); + expect(options.sessionConfiguration?.cookie?.secure).toBeUndefined(); + }); + + it('does not touch secure outside production', () => { + const options = { appBaseUrl: undefined } as Auth0ClientOptions; + enforceSecureCookies(options, false); + expect(options.sessionConfiguration?.cookie?.secure).toBeUndefined(); + }); +}); 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..098df17 --- /dev/null +++ b/packages/auth0-nuxt/src/runtime/server/utils/config.ts @@ -0,0 +1,81 @@ +import { InvalidConfigurationError } 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. + * Forces `sessionConfiguration.cookie.secure = true`, or throws if it was + * explicitly set to `false`. + * @param options The Auth0 client options, mutated in place. + * @param isProduction Whether the app is running in production. + * @throws {InvalidConfigurationError} When secure cookies are explicitly disabled. + */ +export function enforceSecureCookies(options: Auth0ClientOptions, isProduction: boolean): void { + const isDynamic = typeof options.appBaseUrl !== 'string'; + + if (!isProduction || !isDynamic) { + return; + } + + if (options.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.' + ); + } + + options.sessionConfiguration = { + ...options.sessionConfiguration, + cookie: { + ...options.sessionConfiguration?.cookie, + secure: true, + }, + }; +} From 99c6281cccc85b8fa4efbd7afb51f1977d59716b Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:54:21 +0200 Subject: [PATCH 08/14] feat: only bake static redirect_uri into the server client --- .../src/runtime/server/composables/use-auth0.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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, From 15d142b3eddf0da9f6f1e6314e04a11d40af5706 Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:56:00 +0200 Subject: [PATCH 09/14] feat: resolve app base url and pass redirect_uri on login --- .../runtime/server/api/auth/login.get.spec.ts | 50 ++++++++++++------- .../src/runtime/server/api/auth/login.get.ts | 18 +++++-- 2 files changed, 45 insertions(+), 23 deletions(-) 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); From 7b2526b17cbcb12d8765e94a778a091639c8f68b Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:56:43 +0200 Subject: [PATCH 10/14] feat: resolve app base url per request on callback --- .../server/api/auth/callback.get.spec.ts | 17 +++++++++++++++++ .../src/runtime/server/api/auth/callback.get.ts | 12 +++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) 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); }); From 6ede9dcca168db2453aac1acadc3c02b72ba3380 Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:57:27 +0200 Subject: [PATCH 11/14] feat: resolve app base url per request on logout --- .../src/runtime/server/api/auth/logout.get.spec.ts | 12 ++++++++++++ .../src/runtime/server/api/auth/logout.get.ts | 13 ++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) 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 +}); From 5f54ad195f182fc49d6669a1d963029ee31d311b Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 10:58:28 +0200 Subject: [PATCH 12/14] docs: document dynamic application base url modes --- packages/auth0-nuxt/EXAMPLES.md | 26 ++++++++++++++++++++++++++ packages/auth0-nuxt/README.md | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) 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] From 1ff2e0f422963522e134bf24d6c0d9bd31fec3ea Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 11:31:10 +0200 Subject: [PATCH 13/14] fix: avoid mutating frozen runtime config when resolving options --- .../src/runtime/server/plugins/auth.server.ts | 23 +++-- .../src/runtime/server/utils/config.spec.ts | 88 +++++++++++++++---- .../src/runtime/server/utils/config.ts | 53 ++++++++--- 3 files changed, 122 insertions(+), 42 deletions(-) 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 601a779..3a8b8f5 100644 --- a/packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts +++ b/packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts @@ -2,7 +2,7 @@ import { useRuntimeConfig } from '#imports'; import { ServerClient, type SessionStore } from '@auth0/auth0-server-js'; import { defineNitroPlugin } from 'nitropack/dist/runtime/plugin'; import type { Auth0ClientOptions, StoreOptions } from '~/src/types'; -import { parseAppBaseUrl, validateAppBaseUrl, enforceSecureCookies } from '../utils/config'; +import { resolveAuth0Options } from '../utils/config'; declare module 'h3' { interface H3EventContext { @@ -21,21 +21,18 @@ async function tryLoadSessionStore(): Promise | undef export default defineNitroPlugin(async (nitroApp) => { const config = useRuntimeConfig(); - const options = config.auth0 as Auth0ClientOptions; + 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.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, - // then validate and (in production dynamic mode) enforce secure cookies. - // Omitting appBaseUrl entirely enables dynamic, per-request resolution. - if (typeof options.appBaseUrl === 'string') { - options.appBaseUrl = parseAppBaseUrl(options.appBaseUrl); - } - validateAppBaseUrl(options.appBaseUrl); - enforceSecureCookies(options, process.env.NODE_ENV === 'production'); + // 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/config.spec.ts b/packages/auth0-nuxt/src/runtime/server/utils/config.spec.ts index f61a402..2f83733 100644 --- a/packages/auth0-nuxt/src/runtime/server/utils/config.spec.ts +++ b/packages/auth0-nuxt/src/runtime/server/utils/config.spec.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest'; import { InvalidConfigurationError } from '@auth0/auth0-server-js'; -import { parseAppBaseUrl, validateAppBaseUrl, enforceSecureCookies } from './config'; +import { + parseAppBaseUrl, + validateAppBaseUrl, + enforceSecureCookies, + resolveAuth0Options, +} from './config'; import type { Auth0ClientOptions } from '~/src/types'; describe('parseAppBaseUrl', () => { @@ -49,34 +54,81 @@ describe('validateAppBaseUrl', () => { describe('enforceSecureCookies', () => { it('forces secure=true in production dynamic mode', () => { - const options = { appBaseUrl: undefined } as Auth0ClientOptions; - enforceSecureCookies(options, true); - expect(options.sessionConfiguration?.cookie?.secure).toBe(true); + const result = enforceSecureCookies(undefined, undefined, true); + expect(result?.cookie?.secure).toBe(true); }); it('forces secure=true in production allow-list mode', () => { - const options = { appBaseUrl: ['https://a.com'] } as Auth0ClientOptions; - enforceSecureCookies(options, true); - expect(options.sessionConfiguration?.cookie?.secure).toBe(true); + 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', () => { - const options = { - appBaseUrl: undefined, - sessionConfiguration: { cookie: { secure: false } }, - } as Auth0ClientOptions; - expect(() => enforceSecureCookies(options, true)).toThrow(InvalidConfigurationError); + expect(() => enforceSecureCookies(undefined, { cookie: { secure: false } }, true)).toThrow( + InvalidConfigurationError + ); }); it('does not touch secure for a static appBaseUrl in production', () => { - const options = { appBaseUrl: 'https://app.example.com' } as Auth0ClientOptions; - enforceSecureCookies(options, true); - expect(options.sessionConfiguration?.cookie?.secure).toBeUndefined(); + const result = enforceSecureCookies('https://app.example.com', undefined, true); + expect(result?.cookie?.secure).toBeUndefined(); }); it('does not touch secure outside production', () => { - const options = { appBaseUrl: undefined } as Auth0ClientOptions; - enforceSecureCookies(options, false); - expect(options.sessionConfiguration?.cookie?.secure).toBeUndefined(); + 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 index 098df17..6a47d5d 100644 --- a/packages/auth0-nuxt/src/runtime/server/utils/config.ts +++ b/packages/auth0-nuxt/src/runtime/server/utils/config.ts @@ -1,4 +1,4 @@ -import { InvalidConfigurationError } from '@auth0/auth0-server-js'; +import { InvalidConfigurationError, type SessionConfiguration } from '@auth0/auth0-server-js'; import type { Auth0ClientOptions } from '~/src/types'; import { isUrl } from './app-base-url'; @@ -51,31 +51,62 @@ export function validateAppBaseUrl(appBaseUrl: string | string[] | undefined): v /** * In production dynamic/allow-list mode, secure session cookies are required. - * Forces `sessionConfiguration.cookie.secure = true`, or throws if it was - * explicitly set to `false`. - * @param options The Auth0 client options, mutated in place. + * 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(options: Auth0ClientOptions, isProduction: boolean): void { - const isDynamic = typeof options.appBaseUrl !== 'string'; +export function enforceSecureCookies( + appBaseUrl: string | string[] | undefined, + sessionConfiguration: SessionConfiguration | undefined, + isProduction: boolean +): SessionConfiguration | undefined { + const isDynamic = typeof appBaseUrl !== 'string'; if (!isProduction || !isDynamic) { - return; + return sessionConfiguration; } - if (options.sessionConfiguration?.cookie?.secure === false) { + 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.' ); } - options.sessionConfiguration = { - ...options.sessionConfiguration, + return { + ...sessionConfiguration, cookie: { - ...options.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, + }; +} From 4b2f9c53cca2e7175750ddcf181651cde1724907 Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Fri, 19 Jun 2026 11:35:37 +0200 Subject: [PATCH 14/14] chore: remove superpowers planning docs --- .../plans/2026-06-19-dynamic-app-base-url.md | 1177 ----------------- .../2026-06-19-dynamic-app-base-url-design.md | 180 --- 2 files changed, 1357 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-19-dynamic-app-base-url.md delete mode 100644 docs/superpowers/specs/2026-06-19-dynamic-app-base-url-design.md diff --git a/docs/superpowers/plans/2026-06-19-dynamic-app-base-url.md b/docs/superpowers/plans/2026-06-19-dynamic-app-base-url.md deleted file mode 100644 index d183c19..0000000 --- a/docs/superpowers/plans/2026-06-19-dynamic-app-base-url.md +++ /dev/null @@ -1,1177 +0,0 @@ -# Dynamic Application Base URL Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let a single `@auth0/auth0-nuxt` app serve multiple origins by resolving the application base URL per request (static / allow-list / dynamic modes), mirroring auth0-express PR #4. - -**Architecture:** A new request-time resolver (`app-base-url.ts`) infers the origin from the h3 event (honoring `x-forwarded-*`). Startup parsing/validation/secure-cookie enforcement live in the existing nitro plugin (`auth.server.ts`). Login supplies `redirect_uri` per request; callback/logout resolve from their own event; `use-auth0.ts` only bakes a static `redirect_uri`. - -**Tech Stack:** TypeScript, Nuxt module + Nitro, h3, `@auth0/auth0-server-js`, Vitest. - -**Working directory:** `packages/auth0-nuxt`. Run all `npm`/`vitest` commands from there. Branch: `feat/dynamic-app-base-url` (already created; the design spec is already committed). - ---- - -## File Structure - -- **Create** `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts` — `isUrl`, `inferBaseUrlFromRequest`, `resolveAppBaseUrl`. -- **Create** `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts` — unit tests for the resolver. -- **Modify** `packages/auth0-nuxt/src/types.ts` — `appBaseUrl: string | string[]` (optional) + JSDoc. -- **Modify** `packages/auth0-nuxt/src/module.ts` — re-export `InvalidConfigurationError`. -- **Modify** `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts` — parse/validate appBaseUrl, enforce secure cookies, drop the hard required-check. -- **Create** `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.spec.ts` — config parsing/validation/secure-cookie tests. -- **Modify** `packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts` — only bake `redirect_uri` in static mode. -- **Modify** `packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts` (+ `.spec.ts`) — resolve base URL, pass `redirect_uri`. -- **Modify** `packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts` (+ `.spec.ts`) — resolve base URL per request. -- **Modify** `packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts` (+ `.spec.ts`) — resolve base URL per request. -- **Create** `examples/example-nuxt-web-dynamic-app-base-url/*` — multi-host example app. -- **Modify** `packages/auth0-nuxt/README.md` and `packages/auth0-nuxt/EXAMPLES.md` — document modes. - -> **Note on `InvalidConfigurationError`:** it is imported from `@auth0/auth0-server-js`. Before Task 1, verify it is exported there: -> Run: `node -e "import('@auth0/auth0-server-js').then(m => console.log(typeof m.InvalidConfigurationError))"` from `packages/auth0-nuxt`. -> Expected: `function`. If it prints `undefined`, stop and report — the express PR depends on this export; the Nuxt port needs the same `@auth0/auth0-server-js` version. (Express used `^1.2.0`+; bump the dependency if needed.) - ---- - -## Task 1: Base URL resolver utility - -**Files:** -- Create: `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts` -- Test: `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts` - -- [ ] **Step 1: Write the failing test** - -Create `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts`: - -```ts -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('returns null when no host can be determined', () => { - const event = makeEvent({}); - 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(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run src/runtime/server/utils/app-base-url.spec.ts` -Expected: FAIL — cannot resolve `./app-base-url`. - -- [ ] **Step 3: Write the implementation** - -Create `packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts`: - -```ts -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. - */ -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. - */ -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. - * - * @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.' - ); -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run src/runtime/server/utils/app-base-url.spec.ts` -Expected: PASS (all cases). - -- [ ] **Step 5: Commit** - -```bash -git add packages/auth0-nuxt/src/runtime/server/utils/app-base-url.ts packages/auth0-nuxt/src/runtime/server/utils/app-base-url.spec.ts -git commit -m "feat: add per-request app base url resolver" -``` - ---- - -## Task 2: Update types - -**Files:** -- Modify: `packages/auth0-nuxt/src/types.ts:80-86` - -- [ ] **Step 1: Change the `appBaseUrl` type and JSDoc** - -In `packages/auth0-nuxt/src/types.ts`, replace the existing `appBaseUrl` block: - -```ts - /** - * 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. - * @example 'http://localhost:3000' - */ - appBaseUrl: string; -``` - -with: - -```ts - /** - * 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 | string[]; -``` - -- [ ] **Step 2: Verify it type-checks** - -Run: `npx vitest run src/runtime/server/utils/app-base-url.spec.ts` -Expected: PASS (no type regressions in the resolver, which already expects `string | string[] | undefined`). - -- [ ] **Step 3: Commit** - -```bash -git add packages/auth0-nuxt/src/types.ts -git commit -m "feat: allow appBaseUrl to be an array or omitted" -``` - ---- - -## Task 3: Re-export InvalidConfigurationError - -**Files:** -- Modify: `packages/auth0-nuxt/src/module.ts:13-14` - -- [ ] **Step 1: Add the re-export** - -In `packages/auth0-nuxt/src/module.ts`, after the existing `export type { ... }` line (currently line 14), add: - -```ts -export { InvalidConfigurationError } from '@auth0/auth0-server-js'; -``` - -- [ ] **Step 2: Verify the module spec still passes** - -Run: `npx vitest run src/module.spec.ts` -Expected: PASS (unchanged behavior). - -- [ ] **Step 3: Commit** - -```bash -git add packages/auth0-nuxt/src/module.ts -git commit -m "feat: re-export InvalidConfigurationError" -``` - ---- - -## Task 4: Config parsing, validation & secure-cookie enforcement (plugin) - -**Files:** -- Modify: `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts` -- Test: `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.spec.ts` - -This task extracts the parse/validate/enforce logic into exported pure functions so they can be unit-tested without a running Nitro app, then wires them into the plugin. - -- [ ] **Step 1: Write the failing test** - -Create `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.spec.ts`: - -```ts -import { describe, it, expect, afterEach } from 'vitest'; -import { InvalidConfigurationError } from '@auth0/auth0-server-js'; -import { parseAppBaseUrl, validateAppBaseUrl, enforceSecureCookies } from './auth.server'; - -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 options: any = { appBaseUrl: undefined }; - enforceSecureCookies(options, true); - expect(options.sessionConfiguration.cookie.secure).toBe(true); - }); - - it('forces secure=true in production allow-list mode', () => { - const options: any = { appBaseUrl: ['https://a.com'] }; - enforceSecureCookies(options, true); - expect(options.sessionConfiguration.cookie.secure).toBe(true); - }); - - it('throws when secure is explicitly false in production dynamic mode', () => { - const options: any = { - appBaseUrl: undefined, - sessionConfiguration: { cookie: { secure: false } }, - }; - expect(() => enforceSecureCookies(options, true)).toThrow(InvalidConfigurationError); - }); - - it('does not touch secure for a static appBaseUrl in production', () => { - const options: any = { appBaseUrl: 'https://app.example.com' }; - enforceSecureCookies(options, true); - expect(options.sessionConfiguration?.cookie?.secure).toBeUndefined(); - }); - - it('does not touch secure outside production', () => { - const options: any = { appBaseUrl: undefined }; - enforceSecureCookies(options, false); - expect(options.sessionConfiguration?.cookie?.secure).toBeUndefined(); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run src/runtime/server/plugins/auth.server.spec.ts` -Expected: FAIL — `parseAppBaseUrl`/`validateAppBaseUrl`/`enforceSecureCookies` not exported. - -- [ ] **Step 3: Write the implementation** - -Replace the contents of `packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts` with: - -```ts -import { useRuntimeConfig } from '#imports'; -import { ServerClient, InvalidConfigurationError, type SessionStore } from '@auth0/auth0-server-js'; -import { defineNitroPlugin } from 'nitropack/dist/runtime/plugin'; -import type { Auth0ClientOptions, StoreOptions } from '~/src/types'; -import { isUrl } from '../utils/app-base-url'; - -declare module 'h3' { - interface H3EventContext { - auth0Client: ServerClient<{ event: H3Event }>; - } -} - -/** - * 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. - */ -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). - * @throws {InvalidConfigurationError} - */ -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. - * Forces `sessionConfiguration.cookie.secure = true`, or throws if it was - * explicitly set to `false`. - * @throws {InvalidConfigurationError} - */ -export function enforceSecureCookies(options: Auth0ClientOptions, isProduction: boolean): void { - const isDynamic = typeof options.appBaseUrl !== 'string'; - - if (!isProduction || !isDynamic) { - return; - } - - if (options.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.' - ); - } - - options.sessionConfiguration = { - ...options.sessionConfiguration, - cookie: { - ...options.sessionConfiguration?.cookie, - secure: true, - }, - }; -} - -async function tryLoadSessionStore(): Promise | undefined> { - try { - const factoryModule = await import('#auth0-session-store'); - return factoryModule.default(); - } catch { - return undefined; - } -} - -export default defineNitroPlugin(async (nitroApp) => { - const config = useRuntimeConfig(); - const options = 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.sessionSecret) throw new Error('Auth0 configuration error: Session Secret is required'); - - // Normalize a comma-separated appBaseUrl (from env or config) into an allow-list, - // then validate and (in production dynamic mode) enforce secure cookies. - if (typeof options.appBaseUrl === 'string') { - options.appBaseUrl = parseAppBaseUrl(options.appBaseUrl); - } - validateAppBaseUrl(options.appBaseUrl); - enforceSecureCookies(options, process.env.NODE_ENV === 'production'); - - const sessionStoreInstance = await tryLoadSessionStore(); - - nitroApp.hooks.hook('request', async (event) => { - event.context.auth0ClientOptions = options; - event.context.auth0SessionStore = sessionStoreInstance; - }); -}); -``` - -> Note: the previous `if (!options.appBaseUrl) throw ...` check is intentionally removed — dynamic mode legitimately omits `appBaseUrl`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run src/runtime/server/plugins/auth.server.spec.ts` -Expected: PASS (all cases). - -- [ ] **Step 5: Commit** - -```bash -git add packages/auth0-nuxt/src/runtime/server/plugins/auth.server.ts packages/auth0-nuxt/src/runtime/server/plugins/auth.server.spec.ts -git commit -m "feat: parse, validate and enforce secure cookies for appBaseUrl" -``` - ---- - -## Task 5: Only bake static redirect_uri in use-auth0 - -**Files:** -- Modify: `packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts:82-123` - -- [ ] **Step 1: Update `createServerClientInstance`** - -In `packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts`, replace the redirect-uri computation and the `authorizationParams` block. Replace: - -```ts - const callbackPath = publicConfig.routes?.callback ?? '/auth/callback'; - const redirectUri = createRouteUrl(callbackPath, options.appBaseUrl); - - return new ServerClient({ - domain: options.domain, - clientId: options.clientId, - clientSecret: options.clientSecret, - authorizationParams: { - audience: options.audience, - redirect_uri: redirectUri.toString(), - }, -``` - -with: - -```ts - // 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 = - typeof options.appBaseUrl === 'string' - ? createRouteUrl(callbackPath, options.appBaseUrl).toString() - : undefined; - - return new ServerClient({ - domain: options.domain, - clientId: options.clientId, - clientSecret: options.clientSecret, - authorizationParams: { - audience: options.audience, - redirect_uri: redirectUri, - }, -``` - -- [ ] **Step 2: Verify existing specs still pass** - -Run: `npx vitest run src/runtime/server/composables/use-auth0.server.spec.ts` -Expected: PASS (static-mode behavior unchanged; `redirect_uri` still set for string `appBaseUrl`). - -- [ ] **Step 3: Commit** - -```bash -git add packages/auth0-nuxt/src/runtime/server/composables/use-auth0.ts -git commit -m "feat: only bake static redirect_uri into the server client" -``` - ---- - -## Task 6: Login handler resolves base URL and passes redirect_uri - -**Files:** -- Modify: `packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts` -- Test: `packages/auth0-nuxt/src/runtime/server/api/auth/login.get.spec.ts` - -- [ ] **Step 1: Update the login spec to expect redirect_uri** - -Replace the body of `packages/auth0-nuxt/src/runtime/server/api/auth/login.get.spec.ts` with: - -```ts -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import loginHandler from './login.get'; -import type { H3Event } from 'h3'; - -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()), - sendRedirect: sendRedirectMock, - getQuery: getQueryMock, -})); - -vi.mock('#imports', () => ({ useRuntimeConfig: useRuntimeConfigMock })); - -const mockAuth0Client = { - startInteractiveLogin: vi.fn().mockResolvedValue({}), -}; - -vi.mock('../../composables/use-auth0', () => ({ - useAuth0: vi.fn(() => mockAuth0Client), -})); - -describe('login.get handler', () => { - const mockEvent = { - context: { - auth0ClientOptions: { appBaseUrl: 'http://localhost:3000' }, - }, - node: { req: { headers: { host: 'localhost:3000' }, socket: {} }, res: { setHeader: vi.fn() } }, - } as unknown as H3Event; - - beforeEach(() => { - vi.clearAllMocks(); - mockAuth0Client.startInteractiveLogin.mockResolvedValue(new URL('http://redirectTo')); - }); - - 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('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('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'); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run src/runtime/server/api/auth/login.get.spec.ts` -Expected: FAIL — handler does not yet pass `authorizationParams.redirect_uri`. - -- [ ] **Step 3: Update the login handler** - -Replace the contents of `packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts` with: - -```ts -import { useAuth0 } from '../../composables/use-auth0'; -import { defineEventHandler, getQuery, sendRedirect } from 'h3'; -import { useRuntimeConfig } from '#imports'; -import { createRouteUrl, toSafeRedirect } from './../../utils/url'; -import { resolveAppBaseUrl } from './../../utils/app-base-url'; - -interface LoginParams { - returnTo?: string; -} - -export default defineEventHandler(async (event) => { - const auth0Client = useAuth0(event); - const auth0ClientOptions = event.context.auth0ClientOptions; - const runtimeConfig = useRuntimeConfig(); - - const appBaseUrl = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event); - const callbackPath = runtimeConfig.public.auth0?.routes?.callback ?? '/auth/callback'; - const redirectUri = createRouteUrl(callbackPath, 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); -}); -``` - -> If `runtimeConfig.public.auth0` is not typed with `routes`, cast as needed: the existing `Auth0PublicConfig` interface in `use-auth0.ts` already models `routes?: RouteConfig`. Reuse that type if a cast is required. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run src/runtime/server/api/auth/login.get.spec.ts` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/auth0-nuxt/src/runtime/server/api/auth/login.get.ts packages/auth0-nuxt/src/runtime/server/api/auth/login.get.spec.ts -git commit -m "feat: resolve app base url and pass redirect_uri on login" -``` - ---- - -## Task 7: Callback handler resolves base URL per request - -**Files:** -- Modify: `packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts` -- Test: `packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.spec.ts` - -- [ ] **Step 1: Add a dynamic-mode test** - -In `packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.spec.ts`, update `mockEvent` so the request has a host header (needed for dynamic resolution to be exercisable), and add a dynamic test. Change the `mockEvent` `node.req` to: - -```ts - node: { - req: { - url: 'foo', - headers: { host: 'localhost:3000' }, - socket: {}, - }, - }, -``` - -Then append this test inside the `describe('callback.get handler', ...)` block: - -```ts - 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'); - }); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run src/runtime/server/api/auth/callback.get.spec.ts` -Expected: FAIL — the new dynamic test fails because the handler still reads `appBaseUrl` directly (it is `undefined`, producing an invalid URL). - -- [ ] **Step 3: Update the callback handler** - -Replace the contents of `packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts` with: - -```ts -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, appBaseUrl) - ); - - const safeReturnTo = appState?.returnTo ? toSafeRedirect(appState.returnTo, appBaseUrl) : appBaseUrl; - - sendRedirect(event, safeReturnTo ?? appBaseUrl); -}); -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run src/runtime/server/api/auth/callback.get.spec.ts` -Expected: PASS (existing static tests + new dynamic test). - -- [ ] **Step 5: Commit** - -```bash -git add packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.ts packages/auth0-nuxt/src/runtime/server/api/auth/callback.get.spec.ts -git commit -m "feat: resolve app base url per request on callback" -``` - ---- - -## Task 8: Logout handler resolves base URL per request - -**Files:** -- Modify: `packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts` -- Test: `packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.spec.ts` - -- [ ] **Step 1: Add a dynamic-mode test** - -In `packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.spec.ts`, update `mockEvent`'s context to also carry request headers, and add a dynamic test. Change `mockEvent` to: - -```ts - const mockEvent = { - context: { - auth0ClientOptions: { appBaseUrl: 'http://localhost:3000' }, - }, - node: { req: { headers: { host: 'localhost:3000' }, socket: {} } }, - } as unknown as H3Event; -``` - -Then append inside the `describe`: - -```ts - 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' }); - }); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `npx vitest run src/runtime/server/api/auth/logout.get.spec.ts` -Expected: FAIL — handler still passes `undefined` as `returnTo` in dynamic mode. - -- [ ] **Step 3: Update the logout handler** - -Replace the contents of `packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts` with: - -```ts -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 returnTo = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event); - const logoutUrl = await auth0Client.logout({ returnTo }); - - sendRedirect(event, logoutUrl.href); -}); -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `npx vitest run src/runtime/server/api/auth/logout.get.spec.ts` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.ts packages/auth0-nuxt/src/runtime/server/api/auth/logout.get.spec.ts -git commit -m "feat: resolve app base url per request on logout" -``` - ---- - -## Task 9: Full unit suite green - -**Files:** none (verification). - -- [ ] **Step 1: Run the full unit suite** - -Run: `npm run test:unit` -Expected: PASS — all `src/**` specs green, including the new and updated ones. - -- [ ] **Step 2: Lint** - -Run: `npm run lint` -Expected: no errors. Fix any lint issues introduced (e.g. import ordering) and re-run. - -- [ ] **Step 3: Commit any fixups** - -```bash -git add -A -git commit -m "chore: lint fixups for dynamic app base url" || echo "nothing to commit" -``` - ---- - -## Task 10: Multi-host example app - -**Files:** -- Create: `examples/example-nuxt-web-dynamic-app-base-url/` (based on `examples/example-nuxt-web`) - -This example demonstrates allow-list mode serving two hosts from one Auth0 app. - -- [ ] **Step 1: Scaffold from the existing example** - -Run (from repo root): - -```bash -cp -R examples/example-nuxt-web examples/example-nuxt-web-dynamic-app-base-url -rm -rf examples/example-nuxt-web-dynamic-app-base-url/node_modules examples/example-nuxt-web-dynamic-app-base-url/.nuxt examples/example-nuxt-web-dynamic-app-base-url/docker-compose.yml examples/example-nuxt-web-dynamic-app-base-url/server/utils/session-store-factory.ts -``` - -- [ ] **Step 2: Rename the package** - -In `examples/example-nuxt-web-dynamic-app-base-url/package.json`, change the `name` field to: - -```json - "name": "example-nuxt-web-dynamic-app-base-url", -``` - -- [ ] **Step 3: Configure allow-list mode** - -Replace `examples/example-nuxt-web-dynamic-app-base-url/nuxt.config.ts` with a minimal config that omits a static `appBaseUrl` and relies on `NUXT_AUTH0_APP_BASE_URL` (set as a comma-separated allow-list via env). Use: - -```ts -// 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: '', // NUXT_AUTH0_DOMAIN - clientId: '', // NUXT_AUTH0_CLIENT_ID - clientSecret: '', // NUXT_AUTH0_CLIENT_SECRET - sessionSecret: '', // NUXT_AUTH0_SESSION_SECRET - // appBaseUrl omitted here on purpose; provided as a comma-separated - // allow-list via NUXT_AUTH0_APP_BASE_URL (see .env.example). - audience: '', // NUXT_AUTH0_AUDIENCE - }, - }, -}); -``` - -> Remove the bootstrap `app.head` / `nitro.storage.redis` blocks copied from the source config if present; they are not needed here. Keep `app.vue`, `pages/`, and `middleware/` as copied. - -- [ ] **Step 4: Set the example env** - -Replace `examples/example-nuxt-web-dynamic-app-base-url/.env.example` with: - -``` -NUXT_AUTH0_DOMAIN= -NUXT_AUTH0_CLIENT_ID= -NUXT_AUTH0_CLIENT_SECRET= -NUXT_AUTH0_SESSION_SECRET= -# Allow-list of origins this single app serves. Comma-separated. -NUXT_AUTH0_APP_BASE_URL=http://app1.localhost:3000,http://app2.localhost:3000 -``` - -- [ ] **Step 5: Write the example README** - -Create `examples/example-nuxt-web-dynamic-app-base-url/README.md`: - -```markdown -# Dynamic App Base URL (Nuxt) Example - -Serves one Auth0 application across multiple origins. `NUXT_AUTH0_APP_BASE_URL` -is a comma-separated allow-list; the SDK resolves the base URL from each -request's origin. - -## Setup - -1. Add both hosts to your `/etc/hosts`: - - ``` - 127.0.0.1 app1.localhost app2.localhost - ``` - - (Many systems already resolve `*.localhost` to `127.0.0.1`.) - -2. In your Auth0 application, add **both** origins to Allowed Callback URLs and - Allowed Logout URLs: - - - `http://app1.localhost:3000/auth/callback`, `http://app2.localhost:3000/auth/callback` - - `http://app1.localhost:3000`, `http://app2.localhost:3000` - -3. Copy `.env.example` to `.env` and fill in your tenant values. - -4. Run the app: - - ```bash - npm run dev - ``` - -5. Visit `http://app1.localhost:3000` and `http://app2.localhost:3000` — logging - in from either origin redirects back to that same origin. -``` - -- [ ] **Step 6: Verify the example builds** - -Run (from repo root): `npm install && npm run build` -Expected: the workspace builds, including the new example (Nuxt prepares it). If the example fails only because real Auth0 env vars are absent at *runtime*, that is acceptable — the build/prepare step should still pass. - -- [ ] **Step 7: Commit** - -```bash -git add examples/example-nuxt-web-dynamic-app-base-url -git commit -m "docs: add dynamic app base url nuxt example" -``` - ---- - -## Task 11: Documentation - -**Files:** -- Modify: `packages/auth0-nuxt/README.md` -- Modify: `packages/auth0-nuxt/EXAMPLES.md` - -- [ ] **Step 1: Document the modes in EXAMPLES.md** - -Add a new section to `packages/auth0-nuxt/EXAMPLES.md` (place it after the configuration/getting-started section; match the surrounding heading style): - -```markdown -## Dynamic Application Base URL - -`appBaseUrl` supports serving multiple origins from a single Auth0 application: - -- **Static** — a single URL string (default): - - ``` - NUXT_AUTH0_APP_BASE_URL=https://app.example.com - ``` - -- **Allow-list** — a comma-separated list of origins. The SDK matches the - incoming request origin against the list and uses the matching entry. - Recommended 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 `x-forwarded-host` / `x-forwarded-proto`). - -Add every origin to your Auth0 application's **Allowed Callback URLs** and -**Allowed Logout URLs** — this remains the primary safeguard. - -> **Production note:** In dynamic and allow-list modes, secure session cookies -> are enforced when `NODE_ENV=production`. 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. -``` - -- [ ] **Step 2: Cross-link from README.md** - -In `packages/auth0-nuxt/README.md`, in the section that documents configuration options (where `appBaseUrl` / `NUXT_AUTH0_APP_BASE_URL` is described), add a sentence: - -```markdown -`appBaseUrl` may be a single URL, a comma-separated allow-list of origins, or -omitted to infer the origin per request. See -[Dynamic Application Base URL](./EXAMPLES.md#dynamic-application-base-url). -``` - -> If the README does not yet describe `appBaseUrl`, add the sentence under the configuration/options heading near the other env vars. - -- [ ] **Step 3: Commit** - -```bash -git add packages/auth0-nuxt/README.md packages/auth0-nuxt/EXAMPLES.md -git commit -m "docs: document dynamic application base url modes" -``` - ---- - -## Task 12: Final verification - -**Files:** none (verification). - -- [ ] **Step 1: Run the full unit suite once more** - -Run (from `packages/auth0-nuxt`): `npm run test:unit` -Expected: PASS. - -- [ ] **Step 2: Lint the package** - -Run: `npm run lint` -Expected: no errors. - -- [ ] **Step 3: Type-check via build** - -Run: `npm run build` -Expected: builds cleanly (the module-builder runs `nuxt-module-build`, which type-checks the runtime). - -- [ ] **Step 4: Review the diff against the spec** - -Manually confirm every spec section is covered: types, resolver, plugin validation/enforcement, handler wiring, example app, docs. Note anything missing. - -- [ ] **Step 5: Final commit if needed** - -```bash -git add -A -git commit -m "chore: finalize dynamic app base url" || echo "nothing to commit" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** types (T2), `InvalidConfigurationError` export (T3), resolver `isUrl`/`inferBaseUrlFromRequest`/`resolveAppBaseUrl` (T1), plugin parse/validate/enforce (T4), static-only baked redirect_uri (T5), login/callback/logout wiring (T6–T8), example app (T10), docs (T11). All spec sections map to a task. -- **Forwarded headers:** honored by default via h3 getters (T1) — matches the approved design. -- **Type consistency:** `resolveAppBaseUrl(appBaseUrl, event?)`, `parseAppBaseUrl`, `validateAppBaseUrl`, `enforceSecureCookies(options, isProduction)` signatures are used identically across tasks and tests. -- **Risk flagged:** the `@auth0/auth0-server-js` `InvalidConfigurationError` export is verified up front (pre-Task-1 note); bump the dependency if missing. -``` diff --git a/docs/superpowers/specs/2026-06-19-dynamic-app-base-url-design.md b/docs/superpowers/specs/2026-06-19-dynamic-app-base-url-design.md deleted file mode 100644 index 7e2be92..0000000 --- a/docs/superpowers/specs/2026-06-19-dynamic-app-base-url-design.md +++ /dev/null @@ -1,180 +0,0 @@ -# Dynamic Application Base URL — Design - -**Date:** 2026-06-19 -**Package:** `@auth0/auth0-nuxt` -**Reference:** [auth0-express PR #4](https://github.com/auth0/auth0-express/pull/4) - -## Problem - -Today `appBaseUrl` is a single static string, baked into the `ServerClient`'s -`redirect_uri` at construction. This forces a separate deployment per origin and -prevents serving multiple hostnames (multi-tenant, preview deployments) from one -Auth0 application. - -This feature lets a single Nuxt app serve multiple origins by resolving the -application base URL **per request** instead of once at startup. - -## Modes - -`appBaseUrl` accepts three shapes, selecting a mode: - -| `appBaseUrl` value | Mode | Behavior | -|---|---|---| -| `"https://app.example.com"` | **Static** | Used as-is. Current behavior, unchanged. | -| `["https://a.com", "https://b.com"]` or `"https://a.com, https://b.com"` | **Allow-list** | Request origin matched against the list; the matching configured entry is used. Recommended for production. | -| omitted / empty | **Dynamic** | Base URL inferred from the request host per request. | - -Environment override remains `NUXT_AUTH0_APP_BASE_URL` (Nuxt runtime-config -convention). A comma-separated value is parsed into an allow-list array. - -## Architecture - -The Nuxt SDK differs from Express in three relevant ways, which shape the design: - -1. **No `getConfig()`** — config comes from `runtimeConfig.auth0` and is validated - at startup in the `auth.server.ts` nitro plugin. Parsing, validation, and - secure-cookie enforcement live there (Nuxt's equivalent of Express's `getConfig`). -2. **`ServerClient` is created per-event** in `use-auth0.ts`, with `redirect_uri` - baked from `appBaseUrl`. In dynamic/allow-list mode there is no static value to - bake, so the login handler supplies `redirect_uri` per request. -3. **No Express `trust proxy fn`** — Nitro has no built-in trust policy. We honor - `x-forwarded-host` / `x-forwarded-proto` by default via h3's request getters. - Security rests on Auth0 Allowed Callback URLs and the allow-list mode, exactly - as the Express PR documents. - -### Components - -#### `src/runtime/server/utils/app-base-url.ts` (new) - -```ts -export function isUrl(value: string): boolean -// new URL(value); protocol ∈ {http:, https:} - -export function inferBaseUrlFromRequest(event: H3Event): string | null -// host = getRequestHost(event, { xForwardedHost: true }) -// proto = getRequestProtocol(event, { xForwardedProto: true }) -// const candidate = `${proto}://${host}` -// return isUrl(candidate) ? candidate : null - -export function resolveAppBaseUrl( - appBaseUrl: string | string[] | undefined, - event?: H3Event, -): string -``` - -`resolveAppBaseUrl` semantics (identical to the Express reference): - -- `string` → returned as-is. -- no `event` → throw `InvalidConfigurationError`. -- infer origin from request; `null` → throw `InvalidConfigurationError`. -- `undefined` (pure dynamic) → return inferred origin. -- `string[]` (allow-list) → match inferred origin against each entry's - `new URL(entry).origin`; return the **matching configured entry**, else throw - `InvalidConfigurationError`. - -h3's `getRequestHost`/`getRequestProtocol` fall back to the raw `Host` header when -the forwarded header is absent, so the no-proxy case works without configuration. - -This lives alongside the existing `url.ts` (one clear purpose per file) rather than -being merged into it. - -#### `src/runtime/server/plugins/auth.server.ts` (modified) - -Startup validation gains three helpers (ported from Express `config.ts`): - -- `parseAppBaseUrl(value)` — split comma-separated strings, trim, drop empties; - collapse single-element results back to a string. -- `validateAppBaseUrl(appBaseUrl)` — allow `undefined`; reject empty arrays; - reject any non-http(s) URL (naming the offending entry). -- `enforceSecureCookies(options)` — when `NODE_ENV === 'production'` **and** mode is - dynamic/allow-list (`typeof appBaseUrl !== 'string'`): force - `sessionConfiguration.cookie.secure = true`; throw `InvalidConfigurationError` - if it was explicitly set to `false`. - -The current hard check `if (!options.appBaseUrl) throw ...` is **removed** (dynamic -mode legitimately omits it). The other required-field checks (domain, clientId, -clientSecret, sessionSecret) stay. - -#### `src/runtime/server/composables/use-auth0.ts` (modified) - -`createServerClientInstance` bakes `redirect_uri` into the `ServerClient` **only when -`appBaseUrl` is a static string**. In dynamic/allow-list mode the baked `redirect_uri` -is omitted; the login handler always supplies it per request, and the callback builds -its URL from the resolved base. `getUser()` (global middleware, every page) and -backchannel logout construct the client too but never need `redirect_uri`, so omitting -it in dynamic mode is safe. - -### Handlers - -All three resolve the base URL from their own event. - -**`login.get.ts`** -```ts -const appBaseUrl = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event); -const callbackPath = runtimeConfig.public.auth0.routes?.callback ?? '/auth/callback'; -const redirectUri = createRouteUrl(callbackPath, appBaseUrl); -const dangerousReturnTo = query.returnTo ?? appBaseUrl; -const sanitizedReturnTo = toSafeRedirect(dangerousReturnTo as string, appBaseUrl); -await auth0Client.startInteractiveLogin({ - appState: { returnTo: sanitizedReturnTo }, - authorizationParams: { redirect_uri: redirectUri.toString() }, -}); -``` - -**`callback.get.ts`** — resolve `appBaseUrl` from the event; use it for both -`completeInteractiveLogin(new URL(event.node.req.url, appBaseUrl))` and the fallback -redirect target. - -**`logout.get.ts`** — `returnTo = resolveAppBaseUrl(auth0ClientOptions.appBaseUrl, event)`. - -### Types - -`Auth0ClientOptions.appBaseUrl: string` → `string | string[]` (optional). Updated -JSDoc describing the three modes. `InvalidConfigurationError` re-exported from the -module entry (`module.ts`), sourced from `@auth0/auth0-server-js`. - -## Error handling - -`InvalidConfigurationError` (from `@auth0/auth0-server-js`) is thrown when: - -- a static `appBaseUrl` is not a valid http(s) URL (startup); -- an allow-list array is empty or contains an invalid URL (startup); -- `sessionConfiguration.cookie.secure` is explicitly `false` in production - dynamic/allow-list mode (startup); -- at request time, the base URL cannot be resolved (no event, indeterminable - origin, or no allow-list match). - -## Testing - -Vitest, following existing `.spec.ts` patterns. - -- **`app-base-url.spec.ts`** (new) — `isUrl`; `inferBaseUrlFromRequest` with and - without `x-forwarded-*`; `resolveAppBaseUrl` across static / dynamic / allow-list - including allow-list miss → throw and missing-event → throw. -- **Plugin config tests** — comma-separated parsing, single-element collapse, - trailing-comma collapse, empty-array reject, invalid-URL reject (names entry), - omitted → dynamic, secure-cookie enforcement (force `true` in prod dynamic and - allow-list; throw on explicit `false`; untouched for static and non-prod). -- **Handler spec updates** — `login.get.spec.ts` asserts `redirect_uri` passed in - `authorizationParams`; `callback.get.spec.ts` / `logout.get.spec.ts` assert - dynamic resolution; existing static-mode assertions updated to include the new - `redirect_uri` argument. - -## Example app - -New `examples/example-nuxt-web-dynamic-app-base-url`, based on `example-nuxt-web`, -configured with an allow-list serving two hosts (`app1.localhost:3000` / -`app2.localhost:3000`) against one Auth0 application. README documents the -`/etc/hosts` setup and demonstrates that each request's origin resolves its own -callback and logout URLs. - -## Docs - -`README.md` and `EXAMPLES.md`: the three modes, the `NUXT_AUTH0_APP_BASE_URL` -comma-separated env form, the production secure-cookie requirement, and the -forwarded-header behavior. - -## Out of scope - -- A configurable `trustProxy` flag (forwarded headers are honored by default). -- Per-tenant client secrets / multiple Auth0 applications (one app, many origins).