Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/examples.yml
Original file line number Diff line number Diff line change
@@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 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
11 changes: 11 additions & 0 deletions examples/example-fastify-web-dynamic/.env.example
Original file line number Diff line number Diff line change
@@ -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=
98 changes: 98 additions & 0 deletions examples/example-fastify-web-dynamic/README.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions examples/example-fastify-web-dynamic/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
110 changes: 110 additions & 0 deletions examples/example-fastify-web-dynamic/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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://<host>`. 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 = '<client_id>';
process.env.AUTH0_CLIENT_SECRET = '<client_secret>';
process.env.AUTH0_SESSION_SECRET = '<a-session-secret-of-at-least-32-chars>';
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);
});
});
Loading
Loading