@xenterprises/fastify-xauth-nile
v1.5.0
Published
Fastify plugin for Nile Auth (@niledatabase/server) with multi-instance support, session middleware, and tenant context
Readme
@xenterprises/fastify-xauth-nile
Fastify 5 plugin for Nile Auth built on the
@niledatabase/server SDK. It is the Fastify counterpart of
@niledatabase/express: it mounts the generated nile-auth routes, wraps each
request in nile.withContext({ headers, tenantId }), and exposes session and
tenant helpers. Supports multiple named instances. For teams that want Nile's
managed multi-tenant auth without hand-rolling request forwarding.
Testing Fastify sample: ../../../sample/nile (Fastify 5 + this plugin,
file: local package). Not in nuxt-layers/kitchen-sink — Nile is a
mutually exclusive auth provider and needs NILEDB_*. Suite real-API
checks also live in fastify-kitchen-sink/.
Install
npm install @xenterprises/fastify-xauth-nile fastify@5Minimal example
Pass credentials from the Nile console (NILEDB_*) into register() — the
plugin never reads process.env itself.
import Fastify from "fastify";
import xAuthNile from "@xenterprises/fastify-xauth-nile";
const app = Fastify();
const superAdmins = (process.env.NILE_SUPERADMINS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
await app.register(xAuthNile, {
configs: [
{
name: "api",
// nile-auth API URL + database credentials from the Nile console
apiUrl: process.env.NILEDB_API_URL,
user: process.env.NILEDB_USER,
password: process.env.NILEDB_PASSWORD,
databaseName: process.env.NILEDB_NAME,
// Required when the frontend is on a different origin than this API
origin: process.env.NILE_ORIGIN, // e.g. https://app.example.com
// false only on local HTTP; true for production / staging / unset NODE_ENV
secureCookies: process.env.NODE_ENV === "development" ? false : true,
superAdmins,
protectedPaths: [
...(superAdmins.length ? [{ prefix: "/admin", access: "platform" }] : []),
{ prefix: "/portal", access: "session" },
{ prefix: "/affiliates", access: "tenant" },
],
},
],
});
// Nile routes are live under the SDK routePrefix (default `/api`):
// GET /api/auth/session, /api/auth/csrf, /api/me, /api/tenants, ...
// POST /api/signup, /api/auth/signin/:provider, /api/auth/callback/:provider, ...
const auth = app.xAuthNile.default;
app.post("/internal/login", async (request) =>
auth.signIn(request, { email: request.body.email, password: request.body.password })
);
app.get(
"/api/private/me",
{ preHandler: auth.requireAuth() },
async (request) => request.user
);
await app.listen({ port: 3000 });Options
app.register(xAuthNile, options) — options.configs is a required
non-empty array; each entry configures one Nile instance.
Required (plugin + SDK)
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
| name | string | — | yes | Unique instance name (keyed under fastify.xAuthNile.configs) |
| apiUrl | string (URL) | — | yes | nile-auth API base URL (NILEDB_API_URL), e.g. https://us-west-2.api.thenile.dev/v2/databases/<db-id> |
| user | string | — | yes | Nile database user (NILEDB_USER) — the SDK cannot be constructed without it |
| password | string | — | yes | Nile database password (NILEDB_PASSWORD) |
| databaseName | string | — | yes | Nile database name (NILEDB_NAME) |
Plugin-owned
These are not passed to Nile().
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
| basePath | string | — (mount all Nile paths) | no | If set, only mount SDK paths under this prefix (e.g. /api/auth for auth-only). Must be unique per overlapping instance via the resulting routes |
| prefix | string | — | no | Shorthand: session-protect this path (access: "session"). Prefer protectedPaths when you have more than one surface |
| excludedPaths | array | [] | no | Extra paths excluded from prefix / default path-guard protection |
| protectedPaths | array | [] | no | Path surfaces: { prefix, access } where access is platform (superadmin), session (signed-in user), or tenant (session + tenant id) |
| superAdmins | string[] | [] | if any path is platform | User ids and/or emails allowed on platform paths |
| environment | string | — | no | Optional. If you omit secureCookies, development/dev → insecure cookies. Prefer the ternary on secureCookies instead |
| tenantHeader | string | x-tenant-id | no | Header consulted by requireTenant() / withContext() (after URL param, before cookie) |
| forwardHeaders | string[] | — | no | Extra request headers forwarded to nile-auth beyond the built-in allowlist (cookie, content-type, authorization, accept, accept-language). Blocked headers (host, nile-origin, …) always stay blocked |
| sdkTimeout | number (ms) | — (no timeout) | no | Abandon a stalled nile-auth call after this many ms — forwarded routes reply 504, requireAuth() replies 504 instead of hanging |
| cookiePath | string | — (unchanged from Nile) | no | Rewrite or append the Path attribute on outgoing Set-Cookie headers, e.g. cookiePath: "/" when guarding routes outside routePrefix |
| stripLocationHeader | boolean | false | no | When true, strips the location header from forwarded Nile responses (e.g. to suppress upstream redirects in direct-API mode) |
| verifyTenant | boolean | false | no | Also verify the session user's membership of the resolved tenant (nile.tenants.get under the request context) in requireTenant() and access: "tenant" guards — 403 on mismatch. Costs one SDK query per tenant request; default is format-validation only |
Passed through to Nile()
These match the Nile SDK configuration. Any other non-plugin key is also forwarded.
| Option | Type | Default | Required | Description |
|---|---|---|---|---|
| routePrefix | string | /api | no | Prefix the SDK applies to generated routes ({routePrefix}/auth/session, {routePrefix}/me, {routePrefix}/signup, {routePrefix}/tenants, …) |
| origin | string (URL) | — | no | Frontend origin sent as nile-origin (redirects/cookies). Set this when the UI is on a different host/port than this API. The forwarded Request URL always uses this server's host |
| callbackUrl | string | — | no | Override the client-provided auth callback URL |
| secureCookies | boolean | true | no | Passed to the SDK. Use process.env.NODE_ENV === "development" ? false : true — not === "production", which would disable cookies when NODE_ENV is unset |
| debug | boolean | false | no | Verbose Nile SDK logging |
| databaseId | string | derived from apiUrl | no | Nile database id (NILEDB_ID) |
| db | object | — | no | pg pool config for tenant-aware DB access (NilePoolConfig) |
| headers | object | Headers | — | no | Extra headers sent with every nile-auth API request. Include cookie to forward a session |
| tenantId | string | — | no | Fallback tenant id for withContext / requireTenant() when the request has none. Not passed to Nile() (that would be process-wide) |
| userId | string | — | no | Not passed to Nile(). Prefer request-scoped session |
| extensions | array | — | no | Nile SDK extensions |
| routes | object | — | no | Overrides for generated nile-auth paths only (e.g. { SIGNIN: '/v2/auth/signin' }). Not app guards — use protectedPaths for /admin / /portal |
| logger | function | — | no | Custom Nile SDK logger |
| useLastContext | boolean | — | no | Ignored on the constructor. Every plugin withContext call sets useLastContext: false |
| skipHostHeader | boolean | — | no | Skip setting the Host header on nile-auth fetches |
Decorators
fastify.xAuthNile
| Property | Description |
|---|---|
| get(name) | Return the named instance (or undefined) |
| default | The first configured instance |
| configs | Record<name, instance> of all instances |
Each instance exposes:
| Property | Signature | Description |
|---|---|---|
| nile | Server | The raw @niledatabase/server SDK instance (nile.auth, nile.users, nile.tenants, nile.query, nile.db) |
| config | object | The merged instance configuration |
| getSession(request) | Promise<session \| undefined> | GET /api/auth/session. Do not pass the Fastify request to nile.auth.getSession |
| getCsrf(request) | Promise<{ csrfToken }> | GET /api/auth/csrf |
| listProviders(request) | Promise<object> | GET /api/auth/providers |
| signIn(request, creds) | Promise<user \| Response> | POST /api/auth/signin. Object { email, password } uses the credentials provider. signIn(request, 'google') / signIn(request, 'email', creds) pass a provider name |
| signOut(request) | Promise<Response> | POST /api/auth/signout |
| signUp(request, payload) | Promise<user \| Response> | POST /api/signup |
| forgotPassword(request, payload) | Promise<Response> | POST /api/auth/reset-password (send email) |
| resetPassword(request, payload) | Promise<Response> | Complete a password reset |
| callback(request, provider) | Promise<Response> | OAuth callback helper |
| mfa(request, payload) | Promise<object \| Response> | POST/PUT/DELETE /api/auth/mfa |
| refreshSession(request) | Promise<Response> | POST /api/auth/session/token |
| getMe(request) | Promise<user \| Response> | GET /api/me — identify the session principal |
| withContext(request, fn) | Promise<T> | Run fn(nile) inside a request-scoped Nile context (headers + resolved tenant id) |
| requireAuth() | preHandler | 401 unless the request has a valid Nile session; attaches request.auth / request.user |
| requireTenant() | preHandler | 400 unless a tenant id resolves (URL param tenantId > tenantHeader > nile.tenant-id cookie); attaches request.tenantId. With verifyTenant: true, also 403s when the session user is not a member |
| requireSuperAdmin() | preHandler | 403 unless the session user id or email is in superAdmins. Call after requireAuth() |
Request-scoped
request.auth— the full Nile session object (set byrequireAuth())request.user—session.userwhen present (set byrequireAuth())request.tenantId— resolved tenant id (set byrequireTenant()/access: "tenant"). Presence only, not membership.request.authAccess—platform|session|tenantwhen aprotectedPaths(orprefix) guard matchedrequest.isSuperAdmin—trueafter a successful platform /requireSuperAdmin()check
Routes
Per instance, every path in nile.paths (plus documented extras the SDK
exposes on nile.routes but omits from paths: MFA, verify-email, invites,
user-tenants) is registered as GET/POST/PUT/PATCH/DELETE and
forwarded to nile.handlers[METHOD] inside nile.withContext({ headers, tenantId }).
{param} templates become Fastify :param. Status codes, headers, and
set-cookie values are passed through untouched.
Default routePrefix /api therefore serves the full nile-auth surface,
including:
| Method | Path |
|---|---|
| GET | /api/auth/session, /api/auth/csrf, /api/auth/providers, /api/auth/signin, /api/auth/signin/:provider, /api/me, /api/tenants, … |
| POST | /api/auth/signin, /api/auth/signin/:provider, /api/auth/session/token, /api/signup (then /api/auth/verify-email when Nile requires verification), /api/auth/callback/:provider, /api/auth/mfa, /api/tenants, … |
| PUT | /api/tenants/:tenantId, /api/users, /api/auth/mfa, … |
| DELETE | /api/tenants/:tenantId, /api/tenants/:tenantId/users/:userId, /api/auth/mfa, … |
When protectedPaths (or the shorthand prefix) is configured, an
onRequest hook applies the matching access mode except on mounted Nile
routes and excludedPaths:
| access | Prefix example | Checks |
|---|---|---|
| platform | /admin | Session + superAdmins allowlist. No tenant required |
| session | /portal | Session only |
| tenant | /affiliates | Session + tenant id (URL param / header / cookie) |
prefix is a directory guard (/admin and /admin/...). Matching is
boundary-aware: /admin does not match /administrator. One-off routes
outside these prefixes use preHandler: auth.requireAuth() /
requireSuperAdmin() / requireTenant().
SDK routes is not where app surfaces go. That object remaps generated
nile-auth paths (SIGNIN, CSRF, ME, …). /admin and /portal belong in
protectedPaths. xAuthBetter’s basePath (/api/auth) is this plugin’s
routePrefix (/api → /api/auth/csrf). Better’s prefix is
protectedPaths.
If the frontend posts application/x-www-form-urlencoded (CSRF + credentials),
the plugin registers a string parser for that content type unless one already
exists (register @fastify/formbody before this plugin if you need parsed
objects app-wide).
Error behavior
- Registration throws on: missing/empty
configs, missingname, missing/invalidapiUrl, missinguser/password/databaseName, invalid types for SDK options (origin,debug,secureCookies,db, …), duplicatename, overlapping Nile routes across instances, or an SDK instance with no mountable paths. Error messages follow the suite format:xauthnile: missing required option `apiUrl` ... e.g. ... - Forwarded routes reply
502 Bad Gateway({ statusCode: 502, error: "Bad Gateway", message: "xauthnile: nile-auth request failed" }) when the underlying Nile SDK fetch fails, preventing upstream URLs or credentials from leaking into client responses. sdkTimeoutmakes forwarded routes andrequireAuth()reply504 Gateway Timeoutwhen nile-auth stalls instead of hanging the connection.requireAuth()replies401with{ statusCode, error, message }when there is no session or session validation fails.requireTenant()replies400when no tenant id resolves; withverifyTenant: true, replies403when membership check fails.- Unsupported methods or unmatched Nile handlers reply
404.
Tenant-aware database access
Use withContext so SDK calls see the request's cookies and tenant id.
requireTenant() only resolves an id (URL param > header > cookie); it
does not check that the session user belongs to that tenant.
Tenant ids must be A-Za-z0-9_- (Nile UUIDs match). Anything else is ignored
so it cannot break the SDK's SET nile.tenant_id statement.
app.get(
"/api/todos",
{ preHandler: [auth.requireAuth(), auth.requireTenant()] },
async (request) => {
return app.xAuthNile.default.withContext(request, async (nile) => {
const res = await nile.query("select * from todos");
return res.rows;
});
}
);Pooled connections are released automatically when the Fastify instance closes.
Security notes
Rate limiting is your job. The mounted auth surface (
/api/auth/signin,/api/signup,/api/auth/reset-password, …) is unauthenticated by design, and aPOST /api/signupthat Nile refuses with "verification required" triggers a real verification email to the submitted address. Put@fastify/rate-limit(or an edge rule) in front of these routes in production:await app.register(import("@fastify/rate-limit"), { max: 100, timeWindow: "1 minute", }); // registered before xAuthNile so the hooks wrap the auth routes tooTenant ids are client-controlled.
requireTenant()validates the format of the id only unlessverifyTenant: trueis set; without it, any signed-in user can claim any tenant id via URL param, header, or cookie. Authorization then comes from Nile's Postgres RLS underwithContext— never trustrequest.tenantIdfor access decisions outside SDK queries.Set
originin production. It is the base for verification-email links after signup and is sent to nile-auth asnile-origin. Whenoriginis configured, the client-controllednile.callback-urlcookie is only trusted if it matches that origin; withoutorigin, links fall back to the request'sHostheader.CORS is not handled. The plugin forwards nile-auth responses without
access-control-*headers; add@fastify/corsyourself if a browser client on another origin calls these routes. PreflightOPTIONSis not mounted.rawResponse: truehelpers (getSession,signIn,refreshSession, …) return the rawResponse— applying itsset-cookieheaders to the Fastify reply is then your responsibility. Use the exportedapplyResponseCookies(reply, response)helper (it readsHeaders.getSetCookie(); plainHeaders.forEachmerges multiple cookies into one invalid header).Session cookies across route surfaces: Set
cookiePath: "/"if your application guards surfaces outsideroutePrefix(e.g./portalor/adminwhenroutePrefixis/api). Nile Auth issues cookies withPath={routePrefix}by default;cookiePath: "/"ensures browsers send session cookies to both/apiand out-of-prefix routes without requiring proxy-level cookie rewrites.
Requirements
- Node.js >= 20 (Node 22+ to run the test suite)
- Fastify ^5.0.0
- A Nile workspace + database
Development
npm test # node --test, offline, Nile SDK mocked
npm run lint # biome check
npm run format # biome format --write
npm run typecheck # tsc -p tsconfig.types.jsonConsumer env template: .env.example (not published). Copy to .env and run
node --env-file=.env examples/basic.js. Real-API checks live in the suite
kitchen-sink (fastify-kitchen-sink/), not in this package.
