From ec81908fbc29920f27b0c8f29f85b8649407030a Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Tue, 14 Jul 2026 09:57:25 +0200 Subject: [PATCH] feat(examples): add Multiple Custom Domains (MCD) web example Adds example-fastify-web-mcd, demonstrating MCD support in @auth0/auth0-fastify via a per-request domain resolver. A single app serves two hostnames (brand-a.localhost and brand-b.localhost) mapped to different Auth0 custom domains of the same tenant, with the app base URL inferred per request in resolver mode. Includes a vitest spec (msw + fastify.inject) covering per-host domain resolution and default-domain fallback, a Build and Test Examples workflow, and a link from the root README. --- .github/workflows/examples.yml | 31 ++++ README.md | 1 + examples/example-fastify-web-mcd/.env.example | 11 ++ examples/example-fastify-web-mcd/README.md | 80 ++++++++++ examples/example-fastify-web-mcd/package.json | 27 ++++ .../public/img/auth0.png | Bin 0 -> 9142 bytes .../example-fastify-web-mcd/src/index.spec.ts | 93 ++++++++++++ examples/example-fastify-web-mcd/src/index.ts | 142 ++++++++++++++++++ .../example-fastify-web-mcd/tsconfig.json | 22 +++ .../example-fastify-web-mcd/views/index.ejs | 6 + .../example-fastify-web-mcd/views/layout.ejs | 75 +++++++++ .../example-fastify-web-mcd/views/private.ejs | 1 + .../example-fastify-web-mcd/views/public.ejs | 1 + llms.txt | 1 + 14 files changed, 491 insertions(+) create mode 100644 examples/example-fastify-web-mcd/.env.example create mode 100644 examples/example-fastify-web-mcd/README.md create mode 100644 examples/example-fastify-web-mcd/package.json create mode 100644 examples/example-fastify-web-mcd/public/img/auth0.png create mode 100644 examples/example-fastify-web-mcd/src/index.spec.ts create mode 100644 examples/example-fastify-web-mcd/src/index.ts create mode 100644 examples/example-fastify-web-mcd/tsconfig.json create mode 100644 examples/example-fastify-web-mcd/views/index.ejs create mode 100644 examples/example-fastify-web-mcd/views/layout.ejs create mode 100644 examples/example-fastify-web-mcd/views/private.ejs create mode 100644 examples/example-fastify-web-mcd/views/public.ejs diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index fe77c03..573147b 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -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 diff --git a/README.md b/README.md index e8d0314..1183315 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/examples/example-fastify-web-mcd/.env.example b/examples/example-fastify-web-mcd/.env.example new file mode 100644 index 0000000..f74bd89 --- /dev/null +++ b/examples/example-fastify-web-mcd/.env.example @@ -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= diff --git a/examples/example-fastify-web-mcd/README.md b/examples/example-fastify-web-mcd/README.md new file mode 100644 index 0000000..2ac10ed --- /dev/null +++ b/examples/example-fastify-web-mcd/README.md @@ -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. diff --git a/examples/example-fastify-web-mcd/package.json b/examples/example-fastify-web-mcd/package.json new file mode 100644 index 0000000..a6d86b3 --- /dev/null +++ b/examples/example-fastify-web-mcd/package.json @@ -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" + } +} diff --git a/examples/example-fastify-web-mcd/public/img/auth0.png b/examples/example-fastify-web-mcd/public/img/auth0.png new file mode 100644 index 0000000000000000000000000000000000000000..c5c260f3c0e0f4a871b3300399e77eee0625c6b4 GIT binary patch literal 9142 zcmd6L_cxpk)a_^^m_ZO_M(;$=s3CeEU64c>y^T(y_h5+r>LsE?@12O=N%S5;MARq| z5lOhuxPQT2>s#x)7R$EJKKq=rpT`eibhTBX#4utI2n1DEQ_=^4@L|AL;T=4H;mh&& z0lq!ZMH(vK+}z+368=AZN@$~!fcCrI`q~B{xto&$p`MK-_c~qX&?yvowrtY@2*k#s zt|V{hyK>kY1}H(H1^>!v%5kyqfAPcaSbGXSAI42=K};Tzbqn!*|`NhNhd zx?=ImrM!mye{sM6J32I8>xtM!&|L*vJC^gTd?PUzp+@P*mg5}01NubAhI+1Of(UUL<5=%kGn1KQwsaR8*H{LOBbJm_O)!~1H!aD^AU{NcRk zT%<^x7YEuwBmCLgm0InCDbc|;5ja(dN&U^l3~2mdT4ONCHT#HpE33)KRxpWhhpeZ; zlMft_rFTE=LgewZt~+kVm!YIp&3ggMIukFi2F`GjI_en@fAUt(tsFd7?)RqPxu4V0 zAX}Ig{xAOB(Xtt{@{`Xy7tvDH)*(FPWNI10oPEl4L-%cc8QP+vOPoq}bT!#}ULvxJ5gih4wC@s9 z5kATuct2<1qI^-K=*VDq`B_ito}ppOvm&E0bnBUw%_+!!jB}~RHl;PzYPx3&C-~66 z{Y1X<==x*d77n9#XP$uR2uJO`!NM>j$^fH^a3dSkb6 zUvhG@*KF%&-y0-|Nt#rW|a#l1nDwyt<~8pyFfCU(nytzIsiBL-tPjX^pjNBXgzgsh6c0W!&HO z>pay4GChSdZT;o!dBy|!Sa64R<1+0{u9_Y!ZSgRL4Rv-pcGkH=xZWKPnv@X!8*w2 zdPp#5V{`z`18~EpF6R)0DI7TDzp&ap#yg`YC23{c`KhVUXnB#q0}fu|1|0VOHTA+m zX1JWlL>kaF>uHy)3S42okOC-`bvz)*g^$p`Nvn6ly^1!G_x1G9h2*C9q2GY0tl&i( zO9NdPO)dcA%~7ojhv)JDrYgy5`v&oqyF~x>JZRo99DZ(Phazg>$ag!5x$Euqa}>-#<1Ni;(l~? zt)|GeoUSXdtLfzbp!sm1(6V>rB~?1K+Kr_b9aUe^l%4Z!OiC z-NlEO!S=M7{N=x&hz}PDd~jEHA06qJ5Z(@lLs)2h{Hgym-9OK$spB$F@yZZQFghs| zF?DXwpdfnD*oD%3SGM@@yKQ5U8ujse{;~6>Q6W{;%{vdblnrI|rt~DNOvq_ZhI-4n z#Fxkg_nX5~E#=vKduW5EF_df`x)vS1WtP)Qf)jNt9`wu4L{3Oml=A9AsL*l#7)wQZ z>6Hz9gArxAUbvp%AroOfwMV%qj8sNaUcR2)-Dh&}BMEUVjWc@`H2K80ic+aRC8hDL z9?>7wAMcqL3Er1<^Az?H_BV$MyD8D5PkP;5wtc!{B$hTJHdh7`_*t>Vw4U)C;k(dB zv^9tnr64+zX`rYv`mZ@N2FwKUN@LfvP*Fr&qB1Lgqfv%R5oA&ADbB}w*akaK41+Qf zOJ#)z9DCeS+JUYT0$AG;Qw!gZ9K^RUJfn$~4HhV5Dy4qNUC4?>BOK4EZaW01{D;d+ zeX+x6L>^si{xk+plk1hd$n}qnaR4^1Nng@aQUMl_Fy@WLVagAWX=7%fBe48uLJ{zV zUcJIDdeNFDzik)OVLG%Hdu-YHOWS@Da)49mGIV5y(bsK1&faV;Z3AxN^D7eY808fK95u~KNciA| zfMeZUe8)foix`$PEul+?)&qtnTPlfr|%I;|MFg9VpQsPsSX~vpL`0eWYQ<~FVt2M z`?93hD0FY+P)%dmhBNin6RlY61K@ko6+G;*r-p@((_FXGA5&q)6{)UUYX-NltU&bA zTnit|mg%?Zp;Y97qa7=naS7~h>%~5U9H}GRSOfJUqpc#URqTya1l0rVBDmNa^~!xR zm4>iU5$!j#acPoIZ-kbVW;ZdWj!a@N_ZKch^JIfT z1d`Rgg}3PI5~FiNF^BRjsDS>P=*pvAv)H#da>DD2?o6@M2oU$6$A#xsX{tT~3eu>z z`0{n_at>ezHoV4r-i!cBx}gMlJ5psaC@5Cw@0!SW{6+#{EPa-QbqgLbwaR*9b19~D?SxSH(ZM42Eg$=UY+9#8 zHddz)pgR-0nuwdDu8=k<&=Nq8!k$>faFilIB~6UeJ^B`&?Y>J!#GIf=Ln%N>3Umq8R9^3Y zc!Kp0-?iHMe37ZF9D)Q1dkb4OmjTZ$a%wPgQ5|@vnM19Ia~~ar(}p;~KnRQB+`}R4 z%c8aTf)nz&98e-mFF(~;ocS)g1GXxwQgr_~-1?d5v6|;?Ri1(FSdB3g9r~7wpH~Hc zCX5EMQv8|R`@0uNcM2(!Sr>dOo33w!th<>4r^0r-5+R?zSDJ)7E3D8Fb=kX~SD+rN zphuNX5>B~)ZMzaSR34BiDfI)QeSh>DN=b$A|2*u^T75*{Fi<6=O^xEkr9{yh#H{}qt1wY6k zrCF&T6cI}WjpUHy_Dl9u2O+s{%(UxXzulQ(kSV%5QHf?9->}-NJ5kZ5lJqfPJVST@ zM9V|I`7TtftfQyhVT|Hk1uG1#YZi>+=#UK1u847{lNT*XRyA~4F?x(O_h*GqL%rnF z$bk+pFA<5;{5_=zXoZ!niDRF1xkO>_owRR+kA6E0rKA$G!VYyEDQUpgI|T|cU`<0q z*q#t=QwFUGIdrHDL`?K&jGCGrf@hSriC3iY<%*>|iS&rXpRV2g$;%nbqpA1trgBH# zv{FrO({EllEhOe{mw5x}gOL4)9+ESTZ5Sk9fw?r2VIuQfnyHbE`uoJ!(F4Exr9(+U z^9Bob)6aUk)BR%nW54Oe^90OASkd~yBE+wbP6JKU!Oq|y_CaYc;~on>uY98*o5Jk6 z;gVkpmR~AD3D1wgk>6$+#9Y+7pO#8f5=*c%>+g)A7IZq#4wQa}W}FK|mmO2MeV_2M zASad>*}5K_r2HTK(|^NzdwgBUe$OJtS)qO0{G!NsZ3^GZX#n zV#BcKrZ6`92(j*yoZ-B#>!k0>-;$J8;k$y!+S=ucFDA0=iT_8HA-q3pWoH;|z#YXDE*#NF^0>Ip?UgAvM=2DrgJk!+7@uw96sZ^wX83 zHkM3L3LZGxB~vfzs>sY=ZUb*a+Lx>+OHJFwoc>fA#ijM_P9Thw%J~@0@DoEG@9Qb%qG4LI|nWunK#YJUh72yq_RY;f^hYTsi;zaneU@{!qm_5C z9ui{b23T^Nt9i>PGe)3TvN8K^IWK7|VRM+MqZhSkWW^S$$e8UiAx_2|sy@L>I;Scc zAy6!%rCd-3*1Cix-xbWp@W?o~GflG7}? zB4JFFKH;^iYShz20w?XC!SwWfv3@qpC&dm`!on3Nk|N~3GfkE9!WFt7lr1)IN($P# zwPkc_ni?UmpA-0pi@}|2>#LsoAI-d#)0wmDuVnZ%qVlC4tWqhjzh=#Cjr@1bRt>iF z$br^^1@6g84I@jZoXCHfS|5xqU8CSvBJvgT3+*byB?{JmzKEo&AH-gZM{B-B&E*kN zk>UrpW*RzZbNb4?sjkGO4dLJKv*2ta=QX(e$tv2oUCz5P!gYVwx$C~FYn7B6=XL*6 zHOJ(}85;lVtC40piDO5SytKEQ1#5O2H`x-&va&Yz=L!C=1e%qDn^gPch1Wmq&J47S zNu23_8}us^FlwP(L`BxnfcD z-+lTI8j70vMp?R@dNvS=-N)eXl%#X6RKJ}RFOTZhxTHrvaW@40*5=(E2nw=K*E|u_2#`2z1gq`iiqfLcf+vg1{%uneq ziEBvm%ydSbBAjed-Kiv5NAu`e^!@B(Rhl4|d{H?i@) zaygbNq_^--glhI^#}1)By@p6M*zF1rF=$jQPon?G5ckhgs#WP)0RfLmQu4_Xa|@ye zWjFDL1t+?(dI3)^&I!3T`a1Y&hf~lqtsv!o5|J4m+TxOu6dlqhT%=G*UwXLB3Y0n? zObX=zu%UkE$9df7C@Sd$D6Ah5rxdZ#Mw^ugrRc6HAUiQ{+ z#`jqh$CXmj!$drOTT=%Po-ebceR#uiFT+~#gy`+?T8g3~FXwR7Qg;)lmnJE+yp0~t z8Zte$V^8049d6y(}BAB2PY2zavJArFFXJIZ(BZ z#`S%9&1v0E=En83PDM&ETIY%KU+B6q4LvMn#fYYKCX(rZ;d0E@PCy6mnE1y`zhl3# zz=H?NroL`%VTo4a%&d7iTBaTUjF81|h>j~#e(aSnX~G2@U8pV&!q!r!1RFjoWyW}W zis4PX{URtcq5=`v{9){B5!}O5Md!#R@hQIbE8#iwGvT(#S&$dEEE{3DH@M#gBJ@lq zBQ1WOAVO1u-*qXC>6E$?CqlDLh9#I+wxDD+R1}H|9sTG5aF=GurDrnW3>RMyqG9cA z6W0R!*-%DyzSsKbG3dKCv>X{lKD4QxkL_qKijf^T?5g-=prq=qj9Qx=Yp2NYn>Y7l z)Rxx&i^f8LrS0bYJ_aeRQ3y!b+0F1e0}p_9=|dV6kmkvqyo#p9X% z?y;==5$Ev7jJ4k&Fny7Ty&c$^EB&EI9>_R$&>C+%uT4Aj{$Nh| z-`+Q22l#FJfzz*ld)!vr4gC7|rE4fRG(0SgubJ5q+B$L-7qTf$f@eeW7Y`(j6~Z$ekh@mIFr@CiJRX^sj0 za7kqMOpA%A)gEhf}4^&vpg&Yx~&-<0Sil;#!Ff)Ay*2pmckfQ$LAYddl2$M5d#d07LVNhx)vngpI8Jl>FJ?!9~m8 z5JP$CfgwVQ*#|m)H?sjxlYECWt-dF<&i4qYnKBCpmFVQ8*hz#P*Bi7i2sl=TZQ=_E zz0C|5TXsT8gw^_Lc^oA*%=e5FnOaBsm3+)}+W3l3ecIlsxf)pw9!k#y*snYm*tT@x zzIRRPtvHFz+9q-?Z7t(s4Yp{En^521{aoisX=_csLa_S3JaC_qAioT9iE|o7Vx1jdj}JR()eqh~lGLP084DE~fUhIf zYqarUQ=uy-jw2pYg(4fDxBn=iUl)?7&k;?=JnDMLwW~nkqrm3WU;5#Oa@i2PhS{Bs z8@*N*)8kqTTRE}(a?P4E7WzjQz7E;3rsc)E1}E=5og}1E$|E=UJJR&40nCe6NNfR@ z`j%F22p+iCCFVWUI9?9&IC6I2`n=3>>M%gvnJBxQj@6Nv!7pm+0kjoo3J1@=$S?$R z-vQA04KJg%oYIGAAf>9`fSN4OBr>7vsFB5pudCEADj~(mH?Pen`&V8NnZ4H z;06EYq2Z!f3^uoSdHZ7t+1T9H@~%1rMrY(~L%Urpj7!?CkIF&UMm5kp^Q}F@P{;z1 z(HeHehRYomyQL5ZGc!FMTRBGOg(&q(L|r~kd2Vz^de_{oWO*(PqJb%8NWQy0ZyOC=~cVxAQv8OZy1YSDEc3;OAP@Y`Q^|1W?FAcbNf0_f7b=QNI=q2B zX+tm`^m~U<>OlCz=A$yrmVUbZ^TMfHApb!H1TsZo z;-w}*zz-d+2bs9Vv?sP-yPzD1s-((jw;1ebpW+^n@kY7JkU!ETz^64l@1@WQMB~Ps zs!C})q7XRvPJiO%DRf?OhUyCPc9@iG_Bi0)si&?XslSe&#wSPfuTgQH4pb5`>pt*| zZM?2NCJtqu98t_1m`w)CrF4>2X0QBi0+wy{?_yMie#2gPIrXB+WC(a{77;`A4lDz(oM%wd_( z=uHm{m#229cE6>`fmHvu^3V}z^z|l&ORNir7#eNC2W_GY)p3GGpDt49G|^ozu6J#; z5h?0{202;q`|NKjtLG}_z2`p2)ls7J8e=k~|6JjQ@EoWskBGvG={6cu@(m-AKiy^^ zC#0J2*-THa{HB&3!a;8dq~!i*>Fm(fC()UGClv#!#|yn~9RI02f1(=UY2q6(HPpY8 zF$A+4$46-0ZOz1}MmQkQx;^}MGgso%4okb|TIZYYTcdd#xl`5&(UCOs-DdO;dQ|N# zc+(*muAsA%>PEqP2C>r{c^rp67bS(V6ourWkzNO18awbXTsUeHo%NFsVFKwy+%t`u z%B$#^C!7VkL4jil)2OUa3Yk_z=@!m>K1$s(RhnNUlho59I0QK&@lnoewarL5vENKi zIu9vy){%1M3oOG9v|U-DGoQW)^w%gWJ0hr@ElSWCmWYW&utZ*S;_76lHT1n4Udalj z$C5P6uwUVn(QIbgozgB;1IZHs?StsXALODDRRBnA)|sEAZW>k88WNifQgFY%^Y}?L zyX?=nAF5>&U0O+g*}1X`E{s1;r^&aO-aMK;oo2qdbFbrrYVE!MB<155t~a7fqAp*q z|7QD-cWvvYYVsGgak_5lPl}tP8MKF&Em0b56)%29-j-y}iGehb+v+R>r$V%4brJJX zKwn$8`eO+F72au#_88H=@cI^0>(t?0yAZkpb4{8MSh`)UZA|yXi&BX0Yv((QJCc^U m-+Os+{zv~S^Z$!k(kj`nu8XL}Uzk1YO6tnmN;L|o@c#oYxRxCN literal 0 HcmV?d00001 diff --git a/examples/example-fastify-web-mcd/src/index.spec.ts b/examples/example-fastify-web-mcd/src/index.spec.ts new file mode 100644 index 0000000..763e143 --- /dev/null +++ b/examples/example-fastify-web-mcd/src/index.spec.ts @@ -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 = ''; +process.env.AUTH0_CLIENT_SECRET = ''; +process.env.AUTH0_SESSION_SECRET = ''; + +// 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'); + }); +}); diff --git a/examples/example-fastify-web-mcd/src/index.ts b/examples/example-fastify-web-mcd/src/index.ts new file mode 100644 index 0000000..efcb801 --- /dev/null +++ b/examples/example-fastify-web-mcd/src/index.ts @@ -0,0 +1,142 @@ +import Fastify, { FastifyReply, FastifyRequest } from 'fastify'; +import fastifyStatic from '@fastify/static'; +import fastifyView from '@fastify/view'; +import fastifyAuth0, { DomainResolver } from '@auth0/auth0-fastify'; +import type { StoreOptions } from '@auth0/auth0-fastify'; +import ejs from 'ejs'; +import 'dotenv/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const fastify = Fastify({ + logger: 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: './views', + layout: 'layout.ejs', +}); + +// Multiple Custom Domains (MCD) configuration. +// +// This single app serves two hostnames — brand-a.localhost and +// brand-b.localhost — on the same port, each mapped to a different Auth0 +// custom domain of the SAME Auth0 tenant. The mapping is driven by env vars so +// you can point it at your own custom domains. +const defaultAuth0Domain = process.env.AUTH0_DOMAIN as string; +const domainsByHost: Record = { + 'brand-a.localhost:3000': process.env.AUTH0_CUSTOM_DOMAIN_1 as string, + 'brand-b.localhost:3000': process.env.AUTH0_CUSTOM_DOMAIN_2 as string, +}; + +// Resolve the Auth0 custom domain for a given request host, falling back to the +// default domain when the host is not in the map. +// +// SECURITY: you are responsible for ensuring every resolved domain is a trusted +// custom domain of your Auth0 tenant. A resolver that returns an +// attacker-controlled value is a critical risk (auth bypass / SSRF). When +// inferring the host from request headers, run behind a trusted reverse proxy +// that sanitizes `Host` / `X-Forwarded-Host` before they reach the app. +function resolveAuth0Domain(host: string | undefined): string { + return (host && domainsByHost[host]) || defaultAuth0Domain; +} + +// A `DomainResolver` is called per request and receives the same per-request +// `StoreOptions` ({ request, reply }) the plugin passes internally to +// `auth0-server-js`. Passing it to `domain` (instead of a static string) +// enables MCD. +const domainResolver: DomainResolver = (storeOptions) => + resolveAuth0Domain(storeOptions?.request.headers.host); + +// Register the Auth0 plugin in resolver mode. +// +// `domain` is the resolver function rather than a static string. `appBaseUrl` +// is intentionally omitted: in resolver mode the SDK infers the base URL from +// each request's host/proto headers, so callbacks, redirects, and logout use +// the correct origin per host. Make sure every served origin is registered in +// Auth0 as an Allowed Callback URL and Allowed Logout URL. +fastify.register(fastifyAuth0, { + domain: domainResolver, + clientId: process.env.AUTH0_CLIENT_ID as string, + clientSecret: process.env.AUTH0_CLIENT_SECRET as string, + sessionSecret: process.env.AUTH0_SESSION_SECRET as string, + // Discovery (OIDC metadata + JWKS) is cached per resolved domain. Raise + // `maxEntries` when a single process serves more than ~100 distinct Auth0 + // domains within the TTL window, which is common in larger MCD fleets. + discoveryCache: { ttl: 600, maxEntries: 100 }, +}); + +fastify.get('/', async (request, reply) => { + const user = await fastify.auth0Client!.getUser({ request, reply }); + + return reply.viewAsync('index.ejs', { + isLoggedIn: !!user, + user, + host: request.headers.host, + auth0Domain: resolveAuth0Domain(request.headers.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, + host: request.headers.host, + auth0Domain: resolveAuth0Domain(request.headers.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, + host: request.headers.host, + auth0Domain: resolveAuth0Domain(request.headers.host), + }); + } +); + +const start = async () => { + try { + await fastify.listen({ port: 3000 }); + fastify.log.info('Server listening on http://brand-a.localhost:3000 and http://brand-b.localhost: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(); +} diff --git a/examples/example-fastify-web-mcd/tsconfig.json b/examples/example-fastify-web-mcd/tsconfig.json new file mode 100644 index 0000000..200701e --- /dev/null +++ b/examples/example-fastify-web-mcd/tsconfig.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "esModuleInterop": true, + "incremental": false, + "isolatedModules": true, + "lib": [ + "es2022" + ], + "module": "NodeNext", + "moduleDetection": "force", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "outDir": "dist", + "rootDir": "src" + }, + "exclude": ["dist", "**/*.spec.ts"] +} diff --git a/examples/example-fastify-web-mcd/views/index.ejs b/examples/example-fastify-web-mcd/views/index.ejs new file mode 100644 index 0000000..f25c3ce --- /dev/null +++ b/examples/example-fastify-web-mcd/views/index.ejs @@ -0,0 +1,6 @@ +<% if(isLoggedIn){ %> <% if(locals.user){ %> +

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

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

You are not logged in.

+<% } %> diff --git a/examples/example-fastify-web-mcd/views/layout.ejs b/examples/example-fastify-web-mcd/views/layout.ejs new file mode 100644 index 0000000..e06180d --- /dev/null +++ b/examples/example-fastify-web-mcd/views/layout.ejs @@ -0,0 +1,75 @@ + + + + + + Auth0-Fastify Multiple Custom Domains demo + + + + +
+ <% if (locals.host) { %> +

+ Serving from <%= locals.host %> +

+ <% } %> + <% if (locals.auth0Domain) { %> +

+ Resolved Auth0 domain: <%= locals.auth0Domain %> +

+ <% } %> + <%- body %> +
+ + + diff --git a/examples/example-fastify-web-mcd/views/private.ejs b/examples/example-fastify-web-mcd/views/private.ejs new file mode 100644 index 0000000..628de3c --- /dev/null +++ b/examples/example-fastify-web-mcd/views/private.ejs @@ -0,0 +1 @@ +This is a private page. diff --git a/examples/example-fastify-web-mcd/views/public.ejs b/examples/example-fastify-web-mcd/views/public.ejs new file mode 100644 index 0000000..182b0bd --- /dev/null +++ b/examples/example-fastify-web-mcd/views/public.ejs @@ -0,0 +1 @@ +This is a public page. diff --git a/llms.txt b/llms.txt index 689d170..08073fb 100644 --- a/llms.txt +++ b/llms.txt @@ -13,6 +13,7 @@ - [Monorepo README](./README.md): Overview, package list, and how to install/build the workspace (`npm install`, `npm run build`) and run the examples. - [Fastify web app example](./examples/example-fastify-web/README.md): Runnable server-rendered web app using `@auth0/auth0-fastify`. +- [Fastify web app MCD example](./examples/example-fastify-web-mcd/README.md): Runnable web app serving multiple custom domains of one Auth0 tenant via a per-request domain resolver. - [Fastify API example](./examples/example-fastify-api/README.md): Runnable protected API using `@auth0/auth0-fastify-api`. ## Development