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
31 changes: 31 additions & 0 deletions .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,37 @@ jobs:
- name: Build example-fastify-web
run: npm run build -w example-fastify-web

example-fastify-web-mcd:
name: Multiple Custom Domains
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Setup Node.js with npm caching
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
package-manager-cache: false

- name: Update npm
run: npm install -g npm@11.10.0

- name: Install dependencies
run: npm install

# The example imports @auth0/auth0-fastify from its built dist, so the
# SDK must be built before the example can build or test.
- name: Build auth0-fastify
run: npm run build -w @auth0/auth0-fastify

- name: Build example-fastify-web-mcd
run: npm run build -w example-fastify-web-mcd

- name: Test example-fastify-web-mcd
run: npm run test:ci -w example-fastify-web-mcd

example-fastify-api:
name: API
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Jump straight to the capability you need.
The following examples can be found in the examples directory:

- [Fastify Web App Example](./examples/example-fastify-web/README.md)
- [Fastify Web App Multiple Custom Domains (MCD) Example](./examples/example-fastify-web-mcd/README.md)
- [Fastify API Example](./examples/example-fastify-api/README.md)

Before running the examples, you need to install the dependencies for the monorepo and build all the packages.
Expand Down
11 changes: 11 additions & 0 deletions examples/example-fastify-web-mcd/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
AUTH0_SESSION_SECRET=

# Default Auth0 custom domain, used when the request host is not in the map below.
AUTH0_DOMAIN=

# Multiple Custom Domains: one Auth0 custom domain per served host.
# All must be custom domains of the SAME Auth0 tenant.
AUTH0_CUSTOM_DOMAIN_1=
AUTH0_CUSTOM_DOMAIN_2=
80 changes: 80 additions & 0 deletions examples/example-fastify-web-mcd/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Fastify Multiple Custom Domains (MCD) Example

This example demonstrates Multiple Custom Domains (MCD) support in `@auth0/auth0-fastify`.

A single Fastify app serves two distinct hostnames — `brand-a.localhost` and `brand-b.localhost` — on port 3000 using one plugin registration. Instead of a static `domain` string, the SDK is configured with a **domain resolver function** that maps each request's host to a different Auth0 custom domain of the **same Auth0 tenant**.

> MCD is intended for the custom domains of a single Auth0 tenant. It is not a supported way to connect multiple Auth0 tenants to one application.

## Install dependencies

```bash
npm install
```

## Add `/etc/hosts` entries

So that both hostnames resolve to your machine, add the following to `/etc/hosts`:

```text
127.0.0.1 brand-a.localhost
127.0.0.1 brand-b.localhost
```

## Configuration

Rename `.env.example` to `.env` and fill in your Auth0 credentials:

```ts
AUTH0_CLIENT_ID=YOUR_AUTH0_CLIENT_ID
AUTH0_CLIENT_SECRET=YOUR_AUTH0_CLIENT_SECRET
AUTH0_SESSION_SECRET=YOUR_AUTH0_SESSION_SECRET
AUTH0_DOMAIN=YOUR_DEFAULT_AUTH0_CUSTOM_DOMAIN
AUTH0_CUSTOM_DOMAIN_1=YOUR_FIRST_AUTH0_CUSTOM_DOMAIN
AUTH0_CUSTOM_DOMAIN_2=YOUR_SECOND_AUTH0_CUSTOM_DOMAIN
```

`AUTH0_CUSTOM_DOMAIN_1` and `AUTH0_CUSTOM_DOMAIN_2` are two [custom domains](https://auth0.com/docs/customize/custom-domains) configured on the same Auth0 tenant. `brand-a.localhost` resolves to the first, `brand-b.localhost` to the second, and any other host falls back to `AUTH0_DOMAIN`.

The `AUTH0_SESSION_SECRET` is the key used to encrypt the session cookie. You can generate a secret using `openssl`:

```shell
openssl rand -hex 64
```

> **No `APP_BASE_URL` is configured.** In resolver mode (`domain` is a function), `@auth0/auth0-fastify` infers the application base URL from each request's `Host` / `X-Forwarded-Host` and protocol headers, so callbacks, redirects, and logout use the correct origin per host. See [`src/index.ts`](./src/index.ts).

## Configure your Auth0 tenant

Because the app serves two origins, both must be registered in your Auth0 application settings:

- **Allowed Callback URLs:** `http://brand-a.localhost:3000/auth/callback, http://brand-b.localhost:3000/auth/callback`
- **Allowed Logout URLs:** `http://brand-a.localhost:3000, http://brand-b.localhost:3000`
- **Allowed Web Origins:** `http://brand-a.localhost:3000, http://brand-b.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 domain resolver

Open each origin in your browser and walk through login/logout on each:

- http://brand-a.localhost:3000
- http://brand-b.localhost:3000

When you log in from `brand-a.localhost`, the resolver maps that host to `AUTH0_CUSTOM_DOMAIN_1`, so the SDK authenticates against the first custom domain. The same flow on `brand-b.localhost` uses `AUTH0_CUSTOM_DOMAIN_2` — same configuration, correct Auth0 domain per request. The current host and the resolved Auth0 domain are displayed at the top of each page so you can confirm which is in use.

The SDK caches OIDC discovery metadata and JWKS **per resolved domain**, so serving many domains from one process stays efficient. See the `discoveryCache` option in `src/index.ts`.

## Security

You are responsible for ensuring every domain the resolver returns is a trusted custom domain of your Auth0 tenant. A resolver that returns an attacker-controlled value is a critical risk that can lead to authentication bypass or SSRF. When the resolver derives the domain from request headers (such as `Host`), deploy behind a trusted reverse proxy that sanitizes and overwrites `Host` / `X-Forwarded-Host` before they reach the app.
27 changes: 27 additions & 0 deletions examples/example-fastify-web-mcd/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "example-fastify-web-mcd",
"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",
"tsx": "^4.19.2",
"typescript": "~5.8.2",
"vitest": "^3.0.5"
},
"dependencies": {
"@auth0/auth0-fastify": "*",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"@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.
93 changes: 93 additions & 0 deletions examples/example-fastify-web-mcd/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import type { FastifyInstance } from 'fastify';

// Auth0 custom domains the resolver maps each host to. These must be set BEFORE
// importing the app, which reads process.env at import time to build its
// host -> domain map and to register the plugin.
const DEFAULT_DOMAIN = 'default.auth0.local';
const CUSTOM_DOMAIN_1 = 'brand-a.auth0.local';
const CUSTOM_DOMAIN_2 = 'brand-b.auth0.local';

process.env.AUTH0_DOMAIN = DEFAULT_DOMAIN;
process.env.AUTH0_CUSTOM_DOMAIN_1 = CUSTOM_DOMAIN_1;
process.env.AUTH0_CUSTOM_DOMAIN_2 = CUSTOM_DOMAIN_2;
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>';

// 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(
...[DEFAULT_DOMAIN, CUSTOM_DOMAIN_1, CUSTOM_DOMAIN_2].map((domain) =>
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 host map 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();
});

describe('example-fastify-web-mcd: per-host domain resolution', () => {
test('login from brand-a host authorizes against the first custom domain', async () => {
const res = await fastify.inject({
method: 'GET',
url: '/auth/login',
headers: { host: 'brand-a.localhost:3000' },
});
const location = new URL(res.headers['location']?.toString() ?? '');

expect(res.statusCode).toBe(302);
expect(location.host).toBe(CUSTOM_DOMAIN_1);
expect(location.pathname).toBe('/authorize');
expect(new URL(location.searchParams.get('redirect_uri') ?? '').origin).toBe('http://brand-a.localhost:3000');
});

test('login from brand-b host authorizes against the second custom domain', async () => {
const res = await fastify.inject({
method: 'GET',
url: '/auth/login',
headers: { host: 'brand-b.localhost:3000' },
});
const location = new URL(res.headers['location']?.toString() ?? '');

expect(res.statusCode).toBe(302);
expect(location.host).toBe(CUSTOM_DOMAIN_2);
expect(location.pathname).toBe('/authorize');
expect(new URL(location.searchParams.get('redirect_uri') ?? '').origin).toBe('http://brand-b.localhost:3000');
});

test('login from an unmapped host falls back to the default domain', async () => {
const res = await fastify.inject({
method: 'GET',
url: '/auth/login',
headers: { host: 'unknown.localhost:3000' },
});
const location = new URL(res.headers['location']?.toString() ?? '');

expect(res.statusCode).toBe(302);
expect(location.host).toBe(DEFAULT_DOMAIN);
expect(location.pathname).toBe('/authorize');
expect(new URL(location.searchParams.get('redirect_uri') ?? '').origin).toBe('http://unknown.localhost:3000');
});
});
Loading
Loading