-
Notifications
You must be signed in to change notification settings - Fork 5
feat(examples): add Multiple Custom Domains (MCD) web example #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
frederikprijck
wants to merge
1
commit into
main
Choose a base branch
from
worktree-examples
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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= |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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": "*", | ||
| "@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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.