-
Notifications
You must be signed in to change notification settings - Fork 6
feat(auth0-fastify): dynamic application base URL support #81
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
12
commits into
main
Choose a base branch
from
worktree-dynamic-app-base-url
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
Show all changes
12 commits
Select commit
Hold shift + click to select a range
db9c4af
feat(auth0-fastify): add InvalidConfigurationError
frederikprijck 9f7cfe3
feat(auth0-fastify): add app-base-url module with static and dynamic …
frederikprijck 69e69c3
refactor(auth0-fastify): avoid double URL parse in allow-list check
frederikprijck 1d9c834
test(auth0-fastify): cover app-base-url allow-list resolution
frederikprijck 7dc14dd
feat(auth0-fastify): enforce secure session cookies for dynamic appBa…
frederikprijck a78c6cc
feat(auth0-fastify): support dynamic and allow-list appBaseUrl in the…
frederikprijck af4af92
test(auth0-fastify): cover dynamic, allow-list and secure-cookie appB…
frederikprijck 14e3854
docs(auth0-fastify): document dynamic and allow-list appBaseUrl
frederikprijck 6e723b5
docs(examples): add dynamic application base URL example app
frederikprijck da30d2b
test(examples): add integration test for dynamic appBaseUrl example
frederikprijck e877ebf
ci: build and test examples in a dedicated workflow
frederikprijck 15d04e5
chore(examples): address PR review feedback
frederikprijck 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
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
| 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 | ||
|
|
||
| - 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 | ||
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_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= |
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,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. |
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,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.
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,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); | ||
| }); | ||
| }); |
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.