diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml new file mode 100644 index 0000000..b3d98a2 --- /dev/null +++ b/.github/workflows/examples.yml @@ -0,0 +1,48 @@ +name: Build and Test Examples + +on: + merge_group: + workflow_dispatch: + pull_request: + branches: [main, early-access] + push: + branches: [main, early-access] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + example-fastify-web-dynamic: + name: Dynamic Application Base URL + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Node.js with npm caching + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + package-manager-cache: false + + - name: Update npm + run: npm install -g npm@11.10.0 + + - name: Install dependencies + run: npm install + + - name: Build auth0-fastify + run: npm run build -w @auth0/auth0-fastify + + - name: Build example-fastify-web-dynamic + run: npm run build -w example-fastify-web-dynamic + + - name: Test example-fastify-web-dynamic + run: npm run test:ci -w example-fastify-web-dynamic diff --git a/examples/example-fastify-web-dynamic/.env.example b/examples/example-fastify-web-dynamic/.env.example new file mode 100644 index 0000000..eb62901 --- /dev/null +++ b/examples/example-fastify-web-dynamic/.env.example @@ -0,0 +1,11 @@ +AUTH0_DOMAIN= +AUTH0_CLIENT_ID= +AUTH0_CLIENT_SECRET= +AUTH0_SESSION_SECRET= + +# APP_BASE_URL selects the mode this example runs in: +# - Leave it unset/empty for DYNAMIC mode (base URL inferred from every request). +# - A single URL for STATIC mode, e.g. APP_BASE_URL=http://localhost:3000 +# - Comma-separated URLs for ALLOW-LIST mode, e.g. +# APP_BASE_URL=https://brand-1.localtest.me:3000,https://brand-2.localtest.me:3000 +APP_BASE_URL= diff --git a/examples/example-fastify-web-dynamic/README.md b/examples/example-fastify-web-dynamic/README.md new file mode 100644 index 0000000..56d62be --- /dev/null +++ b/examples/example-fastify-web-dynamic/README.md @@ -0,0 +1,98 @@ +# Fastify Dynamic Application Base URL Example + +This example demonstrates how to use `auth0-fastify` with a **dynamic application +base URL** — where the URL used to build `redirect_uri` and +`post_logout_redirect_uri` is resolved per request instead of being hard-coded. + +This is useful when a single application serves multiple hosts (for example +`brand-1.my-app.com` and `brand-2.my-app.com`) behind a proxy. + +For a classic single-host setup, see the +[`example-fastify-web`](../example-fastify-web) example instead. + +## How it works + +Two pieces make this work: + +1. The Fastify server is created with `trustProxy: true`, so Fastify derives + `request.host` / `request.protocol` from the `X-Forwarded-Host` / + `X-Forwarded-Proto` headers your proxy sets. +2. `appBaseUrl` is **not** a single hard-coded string. The SDK infers the base + URL from those request accessors. + +> [!IMPORTANT] +> When inferring the base URL, your proxy **must** sanitize and overwrite the +> `Host` and `X-Forwarded-Host` headers before they reach the app. Without a +> trusted proxy validating these headers, an attacker can influence the inferred +> base URL and cause malicious redirects. + +## Install dependencies + +```bash +npm install +``` + +## Configuration + +Rename `.env.example` to `.env` and configure your Auth0 application: + +```ts +AUTH0_DOMAIN=YOUR_AUTH0_DOMAIN +AUTH0_CLIENT_ID=YOUR_AUTH0_CLIENT_ID +AUTH0_CLIENT_SECRET=YOUR_AUTH0_CLIENT_SECRET +AUTH0_SESSION_SECRET=YOUR_AUTH0_SESSION_SECRET +APP_BASE_URL= +``` + +Generate a session secret with `openssl`: + +```shell +openssl rand -hex 64 +``` + +`APP_BASE_URL` selects which mode the example runs in: + +| `APP_BASE_URL` value | Mode | Behavior | +|----------------------|------|----------| +| unset / empty | **Dynamic** | Base URL inferred from every request, with no restriction. | +| a single URL | **Static** | A fixed base URL (same as the classic example). | +| comma-separated URLs | **Allow-list** | Base URL inferred per request, but the origin must match one of the listed values, otherwise the request is rejected with HTTP 500. | + +Whichever inferred origins you use, register each one in Auth0 as an +**Allowed Callback URL** and **Allowed Logout URL**. + +## Run + +```bash +npm run start +``` + +The application has 3 routes: + +- `/`: The home route. It shows the **resolved request origin** so you can see + what the SDK infers for the current request. +- `/public`: A public route that can be accessed without authentication. +- `/private`: A private route that can only be accessed by authenticated users. + +## Trying out dynamic inference locally + +Because inference depends on the host/proto of each request, you need to send +different forwarded headers to see it change. A quick way is `curl` (note that +the SDK honors these headers only because the server enables `trustProxy`): + +```bash +# Inferred origin = https://brand-1.example.com +curl -s -H "X-Forwarded-Host: brand-1.example.com" \ + -H "X-Forwarded-Proto: https" \ + http://localhost:3000/ + +# Inferred origin = https://brand-2.example.com +curl -s -H "X-Forwarded-Host: brand-2.example.com" \ + -H "X-Forwarded-Proto: https" \ + http://localhost:3000/ +``` + +For a full login round-trip across hosts, put a real reverse proxy (nginx, +Caddy, etc.) in front of the app and have it set `X-Forwarded-Host` / +`X-Forwarded-Proto` per virtual host. Hostnames such as `*.localtest.me` +(which resolve to `127.0.0.1`) are handy for local multi-host testing. diff --git a/examples/example-fastify-web-dynamic/package.json b/examples/example-fastify-web-dynamic/package.json new file mode 100644 index 0000000..3ceffe8 --- /dev/null +++ b/examples/example-fastify-web-dynamic/package.json @@ -0,0 +1,28 @@ +{ + "name": "example-fastify-web-dynamic", + "version": "1.0.0", + "description": "", + "type": "module", + "scripts": { + "start": "tsx src/index.ts --project tsconfig.json", + "build": "tsc --project tsconfig.json", + "test": "vitest run", + "test:ci": "vitest run" + }, + "devDependencies": { + "@types/ejs": "^3.1.5", + "msw": "^2.11.3", + "ts-node": "^10.9.2", + "tsx": "^4.19.2", + "typescript": "~5.8.2", + "vitest": "^3.0.5" + }, + "dependencies": { + "@auth0/auth0-fastify": "*", + "@fastify/static": "^8.1.1", + "@fastify/view": "^10.0.2", + "dotenv": "^16.4.7", + "ejs": "^3.1.10", + "fastify": "^5.2.1" + } +} diff --git a/examples/example-fastify-web-dynamic/public/img/auth0.png b/examples/example-fastify-web-dynamic/public/img/auth0.png new file mode 100644 index 0000000..c5c260f Binary files /dev/null and b/examples/example-fastify-web-dynamic/public/img/auth0.png differ diff --git a/examples/example-fastify-web-dynamic/src/index.spec.ts b/examples/example-fastify-web-dynamic/src/index.spec.ts new file mode 100644 index 0000000..3eb1f0b --- /dev/null +++ b/examples/example-fastify-web-dynamic/src/index.spec.ts @@ -0,0 +1,110 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { setupServer } from 'msw/node'; +import { http, HttpResponse } from 'msw'; +import type { FastifyInstance } from 'fastify'; + +// The app reads process.env at import time to build its appBaseUrl config and to +// register the plugin, so these must be set BEFORE importing it. We run the +// example in ALLOW-LIST mode: the base URL is inferred per request, but the +// inferred origin must be one of the listed values. +const DOMAIN = 'example.auth0.local'; + +// Under `fastify.inject` with `trustProxy` enabled, `request.host` comes from the +// `host` header and `request.protocol` defaults to `http`, so the inferred +// origin is `http://`. The allow-list entries match that form. +const BRAND_1_ORIGIN = 'http://brand-1.localhost:3000'; +const BRAND_2_ORIGIN = 'http://brand-2.localhost:3000'; + +// Capture the original values so we can restore them in teardown and keep this +// suite from leaking env state into other tests. +const ENV_KEYS = [ + 'AUTH0_DOMAIN', + 'AUTH0_CLIENT_ID', + 'AUTH0_CLIENT_SECRET', + 'AUTH0_SESSION_SECRET', + 'APP_BASE_URL', +] as const; +const originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + +process.env.AUTH0_DOMAIN = DOMAIN; +process.env.AUTH0_CLIENT_ID = ''; +process.env.AUTH0_CLIENT_SECRET = ''; +process.env.AUTH0_SESSION_SECRET = ''; +process.env.APP_BASE_URL = `${BRAND_1_ORIGIN},${BRAND_2_ORIGIN}`; + +// A minimal OIDC discovery document. /auth/login only needs the +// authorization_endpoint to build the 302, so that is all we mock. +const discoveryDocument = (domain: string) => ({ + issuer: `https://${domain}/`, + authorization_endpoint: `https://${domain}/authorize`, + token_endpoint: `https://${domain}/oauth/token`, + end_session_endpoint: `https://${domain}/logout`, +}); + +const server = setupServer( + http.get(`https://${DOMAIN}/.well-known/openid-configuration`, () => HttpResponse.json(discoveryDocument(DOMAIN))) +); + +let fastify: FastifyInstance; + +beforeAll(async () => { + server.listen({ onUnhandledRequest: 'bypass' }); + // Import after env is set so the module builds its appBaseUrl config correctly. + // The start() call is guarded, so importing does not bind a port. + ({ fastify } = await import('./index.js')); + await fastify.ready(); +}); + +afterEach(() => server.resetHandlers()); +afterAll(async () => { + server.close(); + await fastify.close(); + // Restore the env vars we mutated so this suite does not affect others. + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } +}); + +describe('example-fastify-web-dynamic: per-host base URL resolution', () => { + test('login from an allow-listed host infers that origin for the redirect_uri', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/auth/login', + headers: { host: 'brand-1.localhost:3000' }, + }); + const location = new URL(res.headers['location']?.toString() ?? ''); + + expect(res.statusCode).toBe(302); + expect(location.host).toBe(DOMAIN); + expect(location.pathname).toBe('/authorize'); + expect(location.searchParams.get('redirect_uri')).toBe(`${BRAND_1_ORIGIN}/auth/callback`); + }); + + test('login from a second allow-listed host infers that origin for the redirect_uri', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/auth/login', + headers: { host: 'brand-2.localhost:3000' }, + }); + const location = new URL(res.headers['location']?.toString() ?? ''); + + expect(res.statusCode).toBe(302); + expect(location.host).toBe(DOMAIN); + expect(location.pathname).toBe('/authorize'); + expect(location.searchParams.get('redirect_uri')).toBe(`${BRAND_2_ORIGIN}/auth/callback`); + }); + + test('login from a host not in the allow-list is rejected', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/auth/login', + headers: { host: 'evil.localhost:3000' }, + }); + + expect(res.statusCode).toBe(500); + }); +}); diff --git a/examples/example-fastify-web-dynamic/src/index.ts b/examples/example-fastify-web-dynamic/src/index.ts new file mode 100644 index 0000000..7507235 --- /dev/null +++ b/examples/example-fastify-web-dynamic/src/index.ts @@ -0,0 +1,132 @@ +import Fastify, { FastifyReply, FastifyRequest } from 'fastify'; +import fastifyStatic from '@fastify/static'; +import fastifyView from '@fastify/view'; +import fastifyAuth0 from '@auth0/auth0-fastify'; +import ejs from 'ejs'; +import 'dotenv/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// `trustProxy: true` makes Fastify derive `request.host` / `request.protocol` +// from the `X-Forwarded-Host` / `X-Forwarded-Proto` headers your proxy sets. +// The SDK relies on those accessors to infer the application base URL per +// request, so this is required for dynamic and allow-list modes behind a proxy. +const fastify = Fastify({ + logger: true, + trustProxy: true, +}); + +// Fix to use __dirname in ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +fastify.register(fastifyStatic, { + root: path.join(__dirname, '../public'), +}); + +fastify.register(fastifyView, { + engine: { + ejs: ejs, + }, + root: path.join(__dirname, '../views'), + layout: 'layout.ejs', +}); + +// `APP_BASE_URL` drives which mode this example runs in: +// - unset/empty -> dynamic: the base URL is inferred from every request. +// - single URL -> static: a fixed base URL (classic single-host setup). +// - comma-separated URLs -> allow-list: inferred per request, but the origin must +// be one of the listed values or the request is rejected. +const parseAppBaseUrl = (value: string | undefined): string | string[] | undefined => { + if (!value) { + return undefined; + } + + const entries = value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + if (entries.length === 0) { + return undefined; + } + + return entries.length === 1 ? entries[0] : entries; +}; + +const appBaseUrl = parseAppBaseUrl(process.env.APP_BASE_URL); + +fastify.log.info( + { appBaseUrl: appBaseUrl ?? '(inferred per request)' }, + 'Configured appBaseUrl mode' +); + +fastify.register(fastifyAuth0, { + domain: process.env.AUTH0_DOMAIN as string, + clientId: process.env.AUTH0_CLIENT_ID as string, + clientSecret: process.env.AUTH0_CLIENT_SECRET as string, + appBaseUrl, + sessionSecret: process.env.AUTH0_SESSION_SECRET as string, +}); + +fastify.get('/', async (request, reply) => { + const user = await fastify.auth0Client!.getUser({ request, reply }); + + return reply.viewAsync('index.ejs', { + isLoggedIn: !!user, + user: user, + origin: `${request.protocol}://${request.host}`, + }); +}); + +async function hasSessionPreHandler(request: FastifyRequest, reply: FastifyReply) { + const session = await fastify.auth0Client!.getSession({ request, reply }); + + if (!session) { + reply.redirect(`/auth/login?returnTo=${request.url}`); + } +} + +fastify.get('/public', async (request, reply) => { + const user = await fastify.auth0Client!.getUser({ request, reply }); + + return reply.viewAsync('public.ejs', { + isLoggedIn: !!user, + user, + origin: `${request.protocol}://${request.host}`, + }); +}); + +fastify.get( + '/private', + { + preHandler: hasSessionPreHandler, + }, + async (request, reply) => { + const user = await fastify.auth0Client!.getUser({ request, reply }); + + return reply.viewAsync('private.ejs', { + isLoggedIn: !!user, + user, + origin: `${request.protocol}://${request.host}`, + }); + } +); + +const start = async () => { + try { + await fastify.listen({ port: 3000 }); + } catch (err) { + fastify.log.error(err); + process.exit(1); + } +}; + +// Start the server only when this file is run directly (e.g. `npm start`), not +// when it is imported (e.g. by the test suite, which drives `fastify` via +// `fastify.inject`). +if (process.argv[1] === __filename) { + start(); +} + +export { fastify }; diff --git a/examples/example-fastify-web-dynamic/tsconfig.json b/examples/example-fastify-web-dynamic/tsconfig.json new file mode 100644 index 0000000..7173a2d --- /dev/null +++ b/examples/example-fastify-web-dynamic/tsconfig.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "incremental": false, + "isolatedModules": true, + "lib": [ + "es2022", + "DOM", + "DOM.Iterable" + ], + "module": "NodeNext", + "moduleDetection": "force", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "outDir": "dist", + "rootDir": "src" + }, + "ts-node": { + "esm": true, + "compilerOptions": { + "module": "nodenext", + } + } +} diff --git a/examples/example-fastify-web-dynamic/views/index.ejs b/examples/example-fastify-web-dynamic/views/index.ejs new file mode 100644 index 0000000..9ab9ec8 --- /dev/null +++ b/examples/example-fastify-web-dynamic/views/index.ejs @@ -0,0 +1,15 @@ +<% if(isLoggedIn){ %> <% if(locals.user){ %> +

Hello, <%= locals.user.name %>!

+<% } %> +<% } else{ %> +

You are not logged in.

+<% } %> + +

+ Resolved request origin: <%= locals.origin %> +

+

+ This is the value the SDK infers as the application base URL for this request + (from request.protocol and request.host). Change the + forwarded host/proto and it changes with the request. +

diff --git a/examples/example-fastify-web-dynamic/views/layout.ejs b/examples/example-fastify-web-dynamic/views/layout.ejs new file mode 100644 index 0000000..dd09dda --- /dev/null +++ b/examples/example-fastify-web-dynamic/views/layout.ejs @@ -0,0 +1,63 @@ + + + + + + Auth0-Fastify Web Application demo + + + + +
<%- body %>
+ + + diff --git a/examples/example-fastify-web-dynamic/views/private.ejs b/examples/example-fastify-web-dynamic/views/private.ejs new file mode 100644 index 0000000..b0e138a --- /dev/null +++ b/examples/example-fastify-web-dynamic/views/private.ejs @@ -0,0 +1,5 @@ +This is a private page. + +

+ Resolved request origin: <%= locals.origin %> +

diff --git a/examples/example-fastify-web-dynamic/views/public.ejs b/examples/example-fastify-web-dynamic/views/public.ejs new file mode 100644 index 0000000..a044eb4 --- /dev/null +++ b/examples/example-fastify-web-dynamic/views/public.ejs @@ -0,0 +1,5 @@ +This is a public page. + +

+ Resolved request origin: <%= locals.origin %> +

diff --git a/packages/auth0-fastify/EXAMPLES.md b/packages/auth0-fastify/EXAMPLES.md index ade2803..f1ce20f 100644 --- a/packages/auth0-fastify/EXAMPLES.md +++ b/packages/auth0-fastify/EXAMPLES.md @@ -11,6 +11,7 @@ - [Performing a delegation exchange without a session](#performing-a-delegation-exchange-without-a-session) - [Using actor tokens for delegation](#using-actor-tokens-for-delegation) - [Authenticating within an organization](#authenticating-within-an-organization) +- [Dynamic Application Base URL](#dynamic-application-base-url) - [Multiple Custom Domains (MCD)](#multiple-custom-domains-mcd) ## Configuration @@ -273,6 +274,66 @@ fastify.post('/custom-token-exchange', async (request, reply) => { }); ``` +## Dynamic Application Base URL + +`appBaseUrl` controls the origin used to build `redirect_uri` and +`post_logout_redirect_uri`. It accepts three forms: + +- **Static string** — a single fixed base URL (the common case): + + ```ts + fastify.register(fastifyAuth0, { + domain: '', + clientId: '', + clientSecret: '', + sessionSecret: '', + appBaseUrl: 'https://my-app.com', + }); + ``` + +- **Allow-list (array)** — the base URL is inferred per request from + `request.host`/`request.protocol`, but the inferred origin must match one of + the listed origins, otherwise the request is rejected with HTTP 500: + + ```ts + fastify.register(fastifyAuth0, { + domain: '', + clientId: '', + clientSecret: '', + sessionSecret: '', + appBaseUrl: ['https://brand-1.my-app.com', 'https://brand-2.my-app.com'], + }); + ``` + +- **Omitted** — the base URL is inferred per request with no restriction. Make + sure every inferred origin is registered in Auth0 as an `Allowed Callback URL` + and `Allowed Logout URL`. + +Dynamic and allow-list modes work with both a static string `domain` and a +`DomainResolver` (see [Multiple Custom Domains](#multiple-custom-domains-mcd)). + +### Trusting forwarded headers + +When `appBaseUrl` is omitted or an array, the base URL is derived from Fastify's +`request.host` and `request.protocol`. Behind a proxy, enable Fastify's +[`trustProxy`](https://fastify.dev/docs/latest/Reference/Server/#trustproxy) so +these reflect the `X-Forwarded-Host`/`X-Forwarded-Proto` headers your proxy +sets: + +```ts +const fastify = Fastify({ trustProxy: true }); +``` + +Your proxy must sanitize and overwrite `Host` and `X-Forwarded-Host` before they +reach the app. Without a trusted proxy validating these headers, an attacker can +influence the inferred base URL and cause malicious redirects. + +### Secure session cookies + +In dynamic and allow-list modes, session cookies default to `secure: true`. +Setting `secure: false` while `NODE_ENV === 'production'` throws +`InvalidConfigurationError` to prevent an accidental downgrade. + ## Multiple Custom Domains (MCD) `Multiple Custom Domains` (MCD) lets you resolve the Auth0 domain per request while using a single Fastify plugin instance. This is useful when one application serves multiple customer domains (for example, `brand-1.my-app.com` and `brand-2.my-app.com`), each mapped to a different `Auth0` custom domain. @@ -335,10 +396,9 @@ const domainResolver: DomainResolver = (storeOptions) => { Resolver mode means `domain` is configured as a resolver function. The plugin then passes per-request `storeOptions` into the underlying `ServerClient` so it can choose the correct `Auth0` domain for the current request. - When you use the mounted routes, `{ request, reply }` is passed automatically. - If you call `fastify.auth0Client` directly from your own routes, continue to pass `{ request, reply }` to those methods. -- If `appBaseUrl` is provided, that static value is used for callback and logout URLs. -- If `appBaseUrl` is omitted, the SDK infers the base URL from request headers. - -If you omit `appBaseUrl`, make sure every inferred origin is registered in Auth0 as an `Allowed Callback URL` and `Allowed Logout URL`. +- `appBaseUrl` behaves the same as documented in + [Dynamic Application Base URL](#dynamic-application-base-url): provide a static + string, an allow-list array, or omit it to infer per request. diff --git a/packages/auth0-fastify/src/app-base-url.spec.ts b/packages/auth0-fastify/src/app-base-url.spec.ts new file mode 100644 index 0000000..c0f2e20 --- /dev/null +++ b/packages/auth0-fastify/src/app-base-url.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'vitest'; +import { InvalidConfigurationError } from './errors/index.js'; +import { normalizeAppBaseUrl, resolveAppBaseUrl, resolveSecureCookie } from './app-base-url.js'; + +// Minimal stand-in for the parts of FastifyRequest the module reads. +function fakeRequest(host: string | undefined, protocol: string) { + return { host, protocol } as unknown as Parameters[1]; +} + +describe('normalizeAppBaseUrl', () => { + test('string returns static mode', () => { + expect(normalizeAppBaseUrl('https://app.example.com')).toEqual({ + mode: 'static', + value: 'https://app.example.com', + }); + }); + + test('undefined returns dynamic mode', () => { + expect(normalizeAppBaseUrl(undefined)).toEqual({ mode: 'dynamic' }); + }); + + test('non-absolute string throws', () => { + expect(() => normalizeAppBaseUrl('not-a-url')).toThrow(InvalidConfigurationError); + }); +}); + +describe('resolveAppBaseUrl (static + dynamic)', () => { + test('static returns configured value regardless of request', () => { + const config = normalizeAppBaseUrl('https://app.example.com'); + expect(resolveAppBaseUrl(config, fakeRequest('other.example.com', 'http'))).toBe( + 'https://app.example.com' + ); + }); + + test('dynamic infers from request host and protocol', () => { + const config = normalizeAppBaseUrl(undefined); + expect(resolveAppBaseUrl(config, fakeRequest('app.example.com', 'https'))).toBe( + 'https://app.example.com' + ); + }); + + test('dynamic throws when host is missing', () => { + const config = normalizeAppBaseUrl(undefined); + expect(() => resolveAppBaseUrl(config, fakeRequest(undefined, 'https'))).toThrow( + InvalidConfigurationError + ); + }); +}); + +describe('normalizeAppBaseUrl (allow-list)', () => { + test('array returns allowlist mode with normalized origins', () => { + const config = normalizeAppBaseUrl(['https://a.example.com', 'https://b.example.com/ignored-path']); + expect(config.mode).toBe('allowlist'); + if (config.mode !== 'allowlist') throw new Error('expected allowlist'); + expect([...config.origins].sort()).toEqual(['https://a.example.com', 'https://b.example.com']); + }); + + test('empty array throws', () => { + expect(() => normalizeAppBaseUrl([])).toThrow(InvalidConfigurationError); + }); + + test('array with a non-absolute entry throws', () => { + expect(() => normalizeAppBaseUrl(['https://a.example.com', 'nope'])).toThrow( + InvalidConfigurationError + ); + }); +}); + +describe('resolveAppBaseUrl (allow-list)', () => { + test('returns inferred origin when it matches the allow-list', () => { + const config = normalizeAppBaseUrl(['https://a.example.com', 'https://b.example.com']); + expect(resolveAppBaseUrl(config, fakeRequest('b.example.com', 'https'))).toBe( + 'https://b.example.com' + ); + }); + + test('throws when inferred origin is not in the allow-list', () => { + const config = normalizeAppBaseUrl(['https://a.example.com']); + expect(() => resolveAppBaseUrl(config, fakeRequest('evil.example.com', 'https'))).toThrow( + InvalidConfigurationError + ); + }); +}); + +describe('resolveSecureCookie', () => { + const dynamic = { mode: 'dynamic' } as const; + const stat = { mode: 'static', value: 'https://app.example.com' } as const; + + test('dynamic + unset defaults to true', () => { + expect(resolveSecureCookie(dynamic, undefined, false)).toBe(true); + }); + + test('dynamic + explicit false in production throws', () => { + expect(() => resolveSecureCookie(dynamic, false, true)).toThrow(InvalidConfigurationError); + }); + + test('dynamic + explicit false outside production is honored', () => { + expect(resolveSecureCookie(dynamic, false, false)).toBe(false); + }); + + test('dynamic + explicit true is honored', () => { + expect(resolveSecureCookie(dynamic, true, true)).toBe(true); + }); + + test('static returns the configured value unchanged (undefined)', () => { + expect(resolveSecureCookie(stat, undefined, true)).toBeUndefined(); + }); + + test('static returns the configured value unchanged (false in production)', () => { + expect(resolveSecureCookie(stat, false, true)).toBe(false); + }); +}); diff --git a/packages/auth0-fastify/src/app-base-url.ts b/packages/auth0-fastify/src/app-base-url.ts new file mode 100644 index 0000000..a125b95 --- /dev/null +++ b/packages/auth0-fastify/src/app-base-url.ts @@ -0,0 +1,125 @@ +import type { + FastifyRequest, + RouteGenericInterface, + RawServerBase, + RawServerDefault, + RawRequestDefaultExpression, +} from 'fastify'; +import { InvalidConfigurationError } from './errors/index.js'; + +/** + * The normalized form of the `appBaseUrl` option. + * - `static`: a single fixed base URL. + * - `allowlist`: infer per request, but the inferred origin must be in `origins`. + * - `dynamic`: infer per request with no restriction. + */ +export type AppBaseUrlConfig = + | { mode: 'static'; value: string } + | { mode: 'allowlist'; origins: Set } + | { mode: 'dynamic' }; + +const assertAbsoluteUrl = (value: string): string => { + try { + new URL(value); + } catch { + throw new InvalidConfigurationError('appBaseUrl must be an absolute URL.'); + } + return value; +}; + +/** + * Validate the `appBaseUrl` option once at plugin init and reduce it to an + * `AppBaseUrlConfig` the per-request resolver can act on cheaply. + */ +export function normalizeAppBaseUrl(appBaseUrl: string | string[] | undefined): AppBaseUrlConfig { + if (typeof appBaseUrl === 'string') { + return { mode: 'static', value: assertAbsoluteUrl(appBaseUrl) }; + } + + if (Array.isArray(appBaseUrl)) { + if (appBaseUrl.length === 0) { + throw new InvalidConfigurationError('appBaseUrl must not be an empty array.'); + } + const origins = new Set(appBaseUrl.map((entry) => new URL(assertAbsoluteUrl(entry)).origin)); + return { mode: 'allowlist', origins }; + } + + return { mode: 'dynamic' }; +} + +const inferFromRequest = < + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression +>( + request: FastifyRequest +): string => { + const host = request.host; + if (!host) { + throw new InvalidConfigurationError('Unable to infer appBaseUrl: missing host.'); + } + return assertAbsoluteUrl(`${request.protocol}://${host}`); +}; + +/** + * Resolve the base URL for the current request based on the normalized config. + * Throws `InvalidConfigurationError` when inference fails or an inferred origin + * is not in the allow-list. + */ +export function resolveAppBaseUrl< + RawServer extends RawServerBase = RawServerDefault, + RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression +>( + config: AppBaseUrlConfig, + request: FastifyRequest +): string { + if (config.mode === 'static') { + return config.value; + } + + const inferred = inferFromRequest(request); + + if (config.mode === 'allowlist') { + const origin = new URL(inferred).origin; + if (!config.origins.has(origin)) { + throw new InvalidConfigurationError( + `The inferred origin "${origin}" is not in the configured appBaseUrl allow-list.` + ); + } + } + + return inferred; +} + +/** + * Resolve the effective `secure` flag for the session cookie. + * + * When the base URL is dynamic or allow-listed there is no single static + * protocol to derive `secure` from, so we default it to `true` and forbid an + * explicit downgrade in production (anti-downgrade). When the base URL is a + * static string, the configured value passes through unchanged so the caller + * keeps the existing protocol-based default behavior. + * + * @param config The normalized appBaseUrl config. + * @param configuredSecure The `secure` value the app set, or `undefined` if unset. + * @param isProduction Whether the process is running in production. + */ +export function resolveSecureCookie( + config: AppBaseUrlConfig, + configuredSecure: boolean | undefined, + isProduction: boolean +): boolean | undefined { + if (config.mode === 'static') { + return configuredSecure; + } + + if (configuredSecure === false) { + if (isProduction) { + throw new InvalidConfigurationError( + 'Insecure session cookies (secure: false) are not allowed in production when appBaseUrl is dynamic or an allow-list.' + ); + } + return false; + } + + return true; +} diff --git a/packages/auth0-fastify/src/errors/index.ts b/packages/auth0-fastify/src/errors/index.ts index 03d49c1..a9acc42 100644 --- a/packages/auth0-fastify/src/errors/index.ts +++ b/packages/auth0-fastify/src/errors/index.ts @@ -6,3 +6,12 @@ export class MissingStoreOptionsError extends Error { this.name = 'MissingStoreOptionsError'; } } + +export class InvalidConfigurationError extends Error { + public code: string = 'invalid_configuration_error'; + + constructor(message?: string) { + super(message ?? 'The provided configuration is invalid.'); + this.name = 'InvalidConfigurationError'; + } +} diff --git a/packages/auth0-fastify/src/index.spec.ts b/packages/auth0-fastify/src/index.spec.ts index 9092058..e1e0fea 100644 --- a/packages/auth0-fastify/src/index.spec.ts +++ b/packages/auth0-fastify/src/index.spec.ts @@ -3,7 +3,7 @@ import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; import { generateToken } from './test-utils/tokens.js'; import Fastify from 'fastify'; -import plugin from './index.js'; +import plugin, { InvalidConfigurationError } from './index.js'; import { StateData } from '@auth0/auth0-server-js'; import { decrypt, encrypt } from './test-utils/encryption.js'; @@ -118,7 +118,7 @@ test('auth/login redirects to authorize when not using a root appBaseUrl', async }); test('auth/login infers appBaseUrl from request when using a domain resolver', async () => { - const fastify = Fastify(); + const fastify = Fastify({ trustProxy: true }); fastify.register(plugin, { domain: async () => domain, clientId: '', @@ -142,7 +142,7 @@ test('auth/login infers appBaseUrl from request when using a domain resolver', a }); test('auth/login prefers forwarded host/proto when inferring appBaseUrl', async () => { - const fastify = Fastify(); + const fastify = Fastify({ trustProxy: true }); fastify.register(plugin, { domain: async () => domain, clientId: '', @@ -165,8 +165,8 @@ test('auth/login prefers forwarded host/proto when inferring appBaseUrl', async expect(url.searchParams.get('redirect_uri')).toBe('https://public.example.com/auth/callback'); }); -test('auth/login fails when appBaseUrl cannot be inferred', async () => { - const fastify = Fastify(); +test('auth/logout infers appBaseUrl from request when using a domain resolver', async () => { + const fastify = Fastify({ trustProxy: true }); fastify.register(plugin, { domain: async () => domain, clientId: '', @@ -176,20 +176,24 @@ test('auth/login fails when appBaseUrl cannot be inferred', async () => { const res = await fastify.inject({ method: 'GET', - url: '/auth/login', + url: '/auth/logout', headers: { - host: '', - 'x-forwarded-proto': '', + host: 'app.example.com', + 'x-forwarded-proto': 'https', }, }); + const url = new URL(res.headers['location']?.toString() ?? ''); - expect(res.statusCode).toBe(500); + expect(res.statusCode).toBe(302); + const returnTo = + url.searchParams.get('returnTo') ?? url.searchParams.get('post_logout_redirect_uri'); + expect(returnTo).toBe('https://app.example.com'); }); -test('auth/logout infers appBaseUrl from request when using a domain resolver', async () => { - const fastify = Fastify(); +test('infers appBaseUrl for a static domain when appBaseUrl is omitted', async () => { + const fastify = Fastify({ trustProxy: true }); fastify.register(plugin, { - domain: async () => domain, + domain: domain, clientId: '', clientSecret: '', sessionSecret: '', @@ -197,7 +201,7 @@ test('auth/logout infers appBaseUrl from request when using a domain resolver', const res = await fastify.inject({ method: 'GET', - url: '/auth/logout', + url: '/auth/login', headers: { host: 'app.example.com', 'x-forwarded-proto': 'https', @@ -206,22 +210,53 @@ test('auth/logout infers appBaseUrl from request when using a domain resolver', const url = new URL(res.headers['location']?.toString() ?? ''); expect(res.statusCode).toBe(302); - const returnTo = - url.searchParams.get('returnTo') ?? url.searchParams.get('post_logout_redirect_uri'); - expect(returnTo).toBe('https://app.example.com'); + expect(url.searchParams.get('redirect_uri')).toBe('https://app.example.com/auth/callback'); }); -test('requires appBaseUrl when using a static domain', async () => { - const fastify = Fastify(); +test('auth/login infers appBaseUrl from an allow-list when the origin matches', async () => { + const fastify = Fastify({ trustProxy: true }); fastify.register(plugin, { - // @ts-expect-error appBaseUrl required for static domain domain: domain, clientId: '', clientSecret: '', + appBaseUrl: ['https://app.example.com', 'https://other.example.com'], sessionSecret: '', }); - await expect(fastify.ready()).rejects.toThrowError('appBaseUrl is required when domain is a string.'); + const res = await fastify.inject({ + method: 'GET', + url: '/auth/login', + headers: { + host: 'other.example.com', + 'x-forwarded-proto': 'https', + }, + }); + const url = new URL(res.headers['location']?.toString() ?? ''); + + expect(res.statusCode).toBe(302); + expect(url.searchParams.get('redirect_uri')).toBe('https://other.example.com/auth/callback'); +}); + +test('auth/login returns 500 when the inferred origin is not in the allow-list', async () => { + const fastify = Fastify({ trustProxy: true }); + fastify.register(plugin, { + domain: domain, + clientId: '', + clientSecret: '', + appBaseUrl: ['https://app.example.com'], + sessionSecret: '', + }); + + const res = await fastify.inject({ + method: 'GET', + url: '/auth/login', + headers: { + host: 'evil.example.com', + 'x-forwarded-proto': 'https', + }, + }); + + expect(res.statusCode).toBe(500); }); test('auth/login should put the appState in the transaction store', async () => { @@ -1465,3 +1500,22 @@ test('customTokenExchange forwards the organization to the token endpoint', asyn expect(res.statusCode).toBe(200); expect(capturedOrganization).toBe('org_123'); }); + +test('rejects insecure session cookies in production when appBaseUrl is dynamic', async () => { + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + const fastify = Fastify({ trustProxy: true }); + fastify.register(plugin, { + domain: domain, + clientId: '', + clientSecret: '', + sessionSecret: '', + sessionConfiguration: { cookie: { secure: false } }, + }); + + await expect(fastify.ready()).rejects.toThrowError(InvalidConfigurationError); + } finally { + process.env.NODE_ENV = previous; + } +}); diff --git a/packages/auth0-fastify/src/index.ts b/packages/auth0-fastify/src/index.ts index 8d3b64a..f3a7767 100644 --- a/packages/auth0-fastify/src/index.ts +++ b/packages/auth0-fastify/src/index.ts @@ -13,6 +13,8 @@ import type { DomainResolver } from '@auth0/auth0-server-js'; import type { DiscoveryCacheOptions, SessionConfiguration, SessionStore, StoreOptions } from './types.js'; import { createRouteUrl, toSafeRedirect } from './utils.js'; import { FastifyCookieHandler } from './store/fastify-cookie-handler.js'; +import { normalizeAppBaseUrl, resolveAppBaseUrl, resolveSecureCookie } from './app-base-url.js'; +import { InvalidConfigurationError } from './errors/index.js'; export * from './types.js'; export type { DomainResolver } from '@auth0/auth0-server-js'; @@ -25,6 +27,7 @@ export type { } from '@auth0/auth0-server-js'; export { CookieTransactionStore } from '@auth0/auth0-server-js'; export { TokenExchangeError, MissingClientAuthError } from '@auth0/auth0-server-js'; +export { InvalidConfigurationError } from './errors/index.js'; declare module 'fastify' { /** @@ -49,38 +52,6 @@ declare module 'fastify' { } } -const assertAppBaseUrl = (value: string): string => { - try { - new URL(value); - } catch { - throw new Error('appBaseUrl must be an absolute URL.'); - } - return value; -}; - -const getHeaderValue = (value: string | string[] | undefined): string | undefined => - Array.isArray(value) ? value[0] : value; - -const inferAppBaseUrlFromRequest = < - RawServer extends RawServerBase = RawServerDefault, - RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression ->( - request: FastifyRequest -): string => { - const hostHeader = getHeaderValue(request.headers['x-forwarded-host']) ?? getHeaderValue(request.headers.host); - if (!hostHeader) { - throw new Error('Unable to infer appBaseUrl: missing host header.'); - } - - const forwardedProto = getHeaderValue(request.headers['x-forwarded-proto']); - const protocol = (forwardedProto ?? request.protocol).toString().split(',')[0]?.trim(); - if (!protocol) { - throw new Error('Unable to infer appBaseUrl: missing protocol.'); - } - - return assertAppBaseUrl(`${protocol}://${hostHeader}`); -}; - type Auth0FastifyCommonOptions< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, @@ -133,23 +104,22 @@ export type Auth0FastifyOptions< RawServer extends RawServerBase = RawServerDefault, RawRequest extends RawRequestDefaultExpression = RawRequestDefaultExpression, RawReply extends RawReplyDefaultExpression = RawReplyDefaultExpression -> = - | (Auth0FastifyCommonOptions & { - domain: string; - /** - * The base URL of the application used for redirects and callbacks. - * Required when using a static domain. - */ - appBaseUrl: string; - }) - | (Auth0FastifyCommonOptions & { - domain: DomainResolver>; - /** - * Optional base URL used for redirects and callbacks. - * When omitted, the base URL is inferred from the request host/proto. - */ - appBaseUrl?: string; - }); +> = Auth0FastifyCommonOptions & { + domain: string | DomainResolver>; + /** + * The base URL(s) of the application, used for redirects and callbacks. + * + * - `string`: a static base URL (existing behavior). + * - `string[]`: an allow-list. The base URL is inferred per request and must + * match one of these origins, otherwise the request is rejected with HTTP 500. + * - omitted: inferred per request from `request.host`/`request.protocol`. + * + * When omitted or an array and running behind a proxy, configure Fastify's + * `trustProxy` so `request.host`/`request.protocol` are derived from the + * forwarded headers your proxy sets. + */ + appBaseUrl?: string | string[]; +}; export default fp(async function auth0Fastify< RawServer extends RawServerBase = RawServerDefault, @@ -160,17 +130,34 @@ export default fp(async function auth0Fastify< options: Auth0FastifyOptions ) { const callbackPath = options.routes?.callback ?? '/auth/callback'; - const staticAppBaseUrl = options.appBaseUrl ? assertAppBaseUrl(options.appBaseUrl) : undefined; - if (typeof options.domain === 'string' && !staticAppBaseUrl) { - throw new Error('appBaseUrl is required when domain is a string.'); - } - const staticRedirectUri = staticAppBaseUrl ? createRouteUrl(callbackPath, staticAppBaseUrl) : undefined; - - const resolveAppBaseUrl = (request: FastifyRequest): string => { - if (staticAppBaseUrl) { - return staticAppBaseUrl; + const appBaseUrlConfig = normalizeAppBaseUrl(options.appBaseUrl); + const staticRedirectUri = + appBaseUrlConfig.mode === 'static' ? createRouteUrl(callbackPath, appBaseUrlConfig.value) : undefined; + + const isProduction = process.env.NODE_ENV === 'production'; + const resolvedSecure = resolveSecureCookie( + appBaseUrlConfig, + options.sessionConfiguration?.cookie?.secure, + isProduction + ); + + const resolveBaseUrl = (request: FastifyRequest): string => + resolveAppBaseUrl(appBaseUrlConfig, request); + + const resolveOr500 = ( + request: FastifyRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reply: any + ): string | undefined => { + try { + return resolveBaseUrl(request); + } catch (e) { + if (e instanceof InvalidConfigurationError) { + reply.code(500).send(e.message); + return undefined; + } + throw e; } - return inferAppBaseUrlFromRequest(request); }; const auth0Client = new ServerClient>({ @@ -192,6 +179,7 @@ export default fp(async function auth0Fastify< ? new StatefulStateStore( { ...options.sessionConfiguration, + cookie: { ...options.sessionConfiguration?.cookie, secure: resolvedSecure }, secret: options.sessionSecret, store: options.sessionStore, }, @@ -200,6 +188,7 @@ export default fp(async function auth0Fastify< : new StatelessStateStore( { ...options.sessionConfiguration, + cookie: { ...options.sessionConfiguration?.cookie, secure: resolvedSecure }, secret: options.sessionSecret, }, new FastifyCookieHandler() @@ -227,7 +216,8 @@ export default fp(async function auth0Fastify< >, reply ) => { - const appBaseUrl = resolveAppBaseUrl(request); + const appBaseUrl = resolveOr500(request, reply); + if (!appBaseUrl) return; const dangerousReturnTo = request.query.returnTo; const sanitizedReturnTo = toSafeRedirect(dangerousReturnTo || '/', appBaseUrl); const redirectUri = createRouteUrl(callbackPath, appBaseUrl); @@ -248,7 +238,8 @@ export default fp(async function auth0Fastify< ); fastify.get(options.routes?.callback ?? '/auth/callback', async (request, reply) => { - const appBaseUrl = resolveAppBaseUrl(request); + const appBaseUrl = resolveOr500(request, reply); + if (!appBaseUrl) return; const { appState } = await auth0Client.completeInteractiveLogin<{ returnTo: string } | undefined>( createRouteUrl(request.url, appBaseUrl), { request, reply } @@ -258,7 +249,8 @@ export default fp(async function auth0Fastify< }); fastify.get(options.routes?.logout ?? '/auth/logout', async (request, reply) => { - const appBaseUrl = resolveAppBaseUrl(request); + const appBaseUrl = resolveOr500(request, reply); + if (!appBaseUrl) return; const logoutUrl = await auth0Client.logout({ returnTo: appBaseUrl.toString() }, { request, reply }); reply.redirect(logoutUrl.href); @@ -318,7 +310,8 @@ export default fp(async function auth0Fastify< }); } - const appBaseUrl = resolveAppBaseUrl(request); + const appBaseUrl = resolveOr500(request, reply); + if (!appBaseUrl) return; const sanitizedReturnTo = toSafeRedirect(dangerousReturnTo || '/', appBaseUrl); const callbackPath = options.routes?.connectCallback ?? '/auth/connect/callback'; const redirectUri = createRouteUrl(callbackPath, appBaseUrl); @@ -341,7 +334,8 @@ export default fp(async function auth0Fastify< ); fastify.get(options.routes?.connectCallback ?? '/auth/connect/callback', async (request, reply) => { - const appBaseUrl = resolveAppBaseUrl(request); + const appBaseUrl = resolveOr500(request, reply); + if (!appBaseUrl) return; const { appState } = await fastify.auth0Client!.completeLinkUser<{ returnTo: string }>( createRouteUrl(request.url, appBaseUrl), { @@ -375,7 +369,8 @@ export default fp(async function auth0Fastify< }); } - const appBaseUrl = resolveAppBaseUrl(request); + const appBaseUrl = resolveOr500(request, reply); + if (!appBaseUrl) return; const sanitizedReturnTo = toSafeRedirect(dangerousReturnTo || '/', appBaseUrl); const callbackPath = options.routes?.unconnectCallback ?? '/auth/unconnect/callback'; const redirectUri = createRouteUrl(callbackPath, appBaseUrl); @@ -397,7 +392,8 @@ export default fp(async function auth0Fastify< ); fastify.get(options.routes?.unconnectCallback ?? '/auth/unconnect/callback', async (request, reply) => { - const appBaseUrl = resolveAppBaseUrl(request); + const appBaseUrl = resolveOr500(request, reply); + if (!appBaseUrl) return; const { appState } = await fastify.auth0Client!.completeUnlinkUser<{ returnTo: string }>( createRouteUrl(request.url, appBaseUrl), {