@xenterprises/fastify-xauth-local
v1.1.2
Published
Fastify plugin for JWT authentication with role-based access control - compatible with Express JWT patterns
Downloads
375
Readme
xAuthLocal
Fastify 5 plugin for JWT authentication with role-based access control, supporting multiple authentication configurations for different route prefixes.
Features
- Multiple Auth Configs: Separate authentication for different route prefixes (e.g.,
/api,/admin,/portal) - JWT Authentication: RS256 (RSA keys) or HS256 (symmetric secret) support per config
- Route Exclusions: Express-jwt compatible
.unless()style patterns - Role-Based Access Control: Support for multiple roles via
scopeclaim - Local Auth Routes: Built-in login, register, me, and password-reset endpoints per config
- Skip User Lookup: Option to use token data only for
/meendpoint (no database call) - Backwards Compatible: Uses
request.authpattern familiar from express-jwt
Installation
npm install xauthlocal jsonwebtoken bcryptjsQuick Start
Single Config
import Fastify from "fastify";
import xAuthLocal from "xauthlocal";
const fastify = Fastify();
await fastify.register(xAuthLocal, {
configs: [
{
name: "api",
prefix: "/api",
secret: process.env.JWT_SECRET,
excludedPaths: ["/api/public", "/api/health"],
},
],
});
// Protected route
fastify.get("/api/users", async (request) => {
// request.auth contains the decoded JWT payload
return { userId: request.auth.id };
});
await fastify.listen({ port: 3000 });Multiple Configs
await fastify.register(xAuthLocal, {
configs: [
{
name: "api",
prefix: "/api",
secret: process.env.API_SECRET,
local: {
enabled: true,
userLookup: async (email) => db.users.findByEmail(email),
createUser: async (userData) => db.users.create(userData),
skipUserLookup: true, // /me returns token data, no DB call
},
},
{
name: "admin",
prefix: "/admin",
secret: process.env.ADMIN_SECRET,
local: {
enabled: true,
userLookup: async (email) => db.admins.findByEmail(email),
skipUserLookup: false, // /me fetches fresh data from DB
},
},
{
name: "portal",
prefix: "/portal",
publicKey: "./keys/portal-public.pem",
privateKey: "./keys/portal-private.pem",
},
],
});
// Each config protects routes under its prefix
// Tokens are config-specific (API token won't work for admin routes)Configuration Options
Plugin Options
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| configs | Array | Yes | - | Array of auth configurations |
| basePath | string | No | process.cwd() | Base path for relative key paths |
| active | boolean | No | true | Enable/disable the plugin |
Config Options
Each config in the configs array supports:
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| name | string | Yes | - | Unique identifier for this config |
| prefix | string | Yes | - | Route prefix to protect (e.g., /api) |
| secret | string | Yes* | - | Symmetric secret for HS256 |
| publicKey | string | Yes* | - | Public key content or path for RS256 |
| privateKey | string | Yes* | - | Private key content or path for RS256 |
| algorithm | string | No | Auto | 'RS256' for keys, 'HS256' for secret |
| expiresIn | string | No | '4d' | Default token expiration |
| audience | string | No | - | JWT audience claim |
| issuer | string | No | - | JWT issuer claim |
| excludedPaths | Array | No | [] | Paths to exclude from auth |
| requestProperty | string | No | 'auth' | Property to attach decoded token |
| credentialsRequired | boolean | No | true | Whether token is required |
| getToken | Function | No | - | Custom token extraction function |
| local | Object | No | - | Local routes configuration |
*One of secret or publicKey/privateKey is required per config.
Local Route Options
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| enabled | boolean | No | false | Enable local auth routes |
| loginPath | string | No | {prefix}/local | Login route path |
| mePath | string | No | {loginPath}/me | Me route path |
| skipUserLookup | boolean | No | false | Use token data only for /me |
| userLookup | Function | Yes** | - | Function to lookup user by email |
| createUser | Function | No | - | Function to create new user |
| passwordReset | Function | No | - | Function to handle password reset |
| saltRounds | number | No | 10 | bcrypt salt rounds |
**Required if local routes are enabled.
Route Exclusions
Exclude routes from authentication using patterns compatible with express-jwt:
await fastify.register(xAuthLocal, {
configs: [
{
name: "api",
prefix: "/api",
secret: process.env.JWT_SECRET,
excludedPaths: [
// String prefix match
"/api/public",
"/api/health",
// Regex match
/^\/api\/v\d+\/public/,
// Object with URL and optional methods
{ url: "/api/webhook", methods: ["POST"] },
{ url: /^\/api\/callback/, methods: ["GET", "POST"] },
// Object without methods (matches all methods)
{ url: "/api/status" },
],
},
],
});Role-Based Access Control
Use the requireRole helper to protect routes by role:
const apiConfig = fastify.xauthlocal.get("api");
// Single role required
fastify.get(
"/api/admin/dashboard",
{ preHandler: [apiConfig.requireRole("admin")] },
async (request) => ({ dashboard: true })
);
// Multiple roles (any match)
fastify.get(
"/api/manage/users",
{ preHandler: [apiConfig.requireRole(["admin", "manager"])] },
async (request) => ({ users: [] })
);Roles are read from the scope claim in the JWT payload:
const apiConfig = fastify.xauthlocal.get("api");
const token = apiConfig.jwt.sign({
id: 1,
email: "[email protected]",
scope: ["admin", "user"], // Array of roles
});Local Auth Routes
When local.enabled is true, the following endpoints are registered:
| Method | Path | Auth Required | Description |
|--------|------|---------------|-------------|
| POST | {prefix}/local | No | Login with email/password |
| GET | {prefix}/local/me | Yes | Get current user |
| POST | {prefix}/local/register | No | Register new user |
| POST | {prefix}/local/password-reset | No | Request password reset |
| PUT | {prefix}/local/password-reset | No | Complete password reset |
skipUserLookup Option
Control whether /me endpoint makes a database call:
{
name: "api",
prefix: "/api",
secret: process.env.JWT_SECRET,
local: {
enabled: true,
userLookup: async (email) => db.users.findByEmail(email),
skipUserLookup: true, // Returns token data directly, no DB call
},
}skipUserLookup: true: Returns user data stored in the JWT token (faster, no DB call)skipUserLookup: false: Fetches fresh user data from database viauserLookup(default)
Login Request/Response
// POST /api/local
// Request
{
"email": "[email protected]",
"password": "password123"
}
// Response
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": 1,
"email": "[email protected]",
"first_name": "John",
"last_name": "Doe",
"admin": false,
"scope": ["user"]
}
}API
After registration, access the plugin via fastify.xauthlocal:
// Get all configs
fastify.xauthlocal.configs; // { api: {...}, admin: {...} }
// Get specific config by name
const apiConfig = fastify.xauthlocal.get("api");
// JWT Service (per config)
apiConfig.jwt.sign(payload, options);
apiConfig.jwt.verify(token, options);
apiConfig.jwt.decode(token);
// Role middleware factory (per config)
apiConfig.requireRole("admin");
apiConfig.requireRole(["admin", "manager"]);
// Custom middleware factory (per config)
apiConfig.createMiddleware({
excludedPaths: ["/special"],
credentialsRequired: false,
});
// Check if route is excluded (per config)
apiConfig.isExcluded("/api/public/health", "GET"); // true
// Config info (per config)
apiConfig.name; // "api"
apiConfig.prefix; // "/api"
apiConfig.hasLocalRoutes; // true
apiConfig.localPrefix; // "/api/local"
apiConfig.mePath; // "/api/local/me"
// Password utilities (global)
await fastify.xauthlocal.password.hash("password123");
await fastify.xauthlocal.password.compare("password123", hash);
// Summary config (read-only)
fastify.xauthlocal.config;
// {
// configCount: 2,
// configNames: ["api", "admin"]
// }Using RSA Keys
Generate keys:
# Generate private key
openssl genrsa -out private.pem 2048
# Generate public key
openssl rsa -in private.pem -pubout -out public.pemConfigure with key paths or content:
// Using file paths
await fastify.register(xAuthLocal, {
configs: [
{
name: "api",
prefix: "/api",
publicKey: "./keys/public.pem",
privateKey: "./keys/private.pem",
},
],
});
// Using key content (e.g., from environment)
await fastify.register(xAuthLocal, {
configs: [
{
name: "api",
prefix: "/api",
publicKey: process.env.JWT_PUBLIC_KEY,
privateKey: process.env.JWT_PRIVATE_KEY,
},
],
});Optional Authentication
Allow routes to work with or without authentication:
await fastify.register(xAuthLocal, {
configs: [
{
name: "api",
prefix: "/api",
secret: process.env.JWT_SECRET,
credentialsRequired: false,
},
],
});
fastify.get("/api/posts", async (request) => {
if (request.auth) {
// Authenticated user - return personalized content
return getPersonalizedPosts(request.auth.id);
}
// Anonymous user - return public content
return getPublicPosts();
});Complete Example
import Fastify from "fastify";
import xAuthLocal from "xauthlocal";
const fastify = Fastify({ logger: true });
// In-memory users for demo
const users = new Map();
const admins = new Map();
await fastify.register(xAuthLocal, {
configs: [
{
name: "api",
prefix: "/api",
secret: process.env.API_SECRET || "api-development-secret",
expiresIn: "7d",
excludedPaths: ["/api/health", { url: "/api/public", methods: ["GET"] }],
local: {
enabled: true,
userLookup: async (email) => users.get(email),
createUser: async (userData) => {
const user = { id: Date.now(), ...userData, scope: ["user"] };
users.set(userData.email, user);
return user;
},
passwordReset: async (email) => {
console.log(`Password reset requested for ${email}`);
},
skipUserLookup: true, // Fast /me endpoint
},
},
{
name: "admin",
prefix: "/admin",
secret: process.env.ADMIN_SECRET || "admin-development-secret",
expiresIn: "1d",
local: {
enabled: true,
userLookup: async (email) => admins.get(email),
skipUserLookup: false, // Always fetch fresh admin data
},
},
],
});
// Public routes
fastify.get("/api/health", async () => ({ status: "ok" }));
fastify.get("/api/public/info", async () => ({ version: "1.0.0" }));
// Protected API routes
fastify.get("/api/profile", async (request) => ({
id: request.auth.id,
email: request.auth.email,
}));
// Admin-only route
const adminConfig = fastify.xauthlocal.get("admin");
fastify.get(
"/admin/stats",
{ preHandler: [adminConfig.requireRole("admin")] },
async () => ({ users: users.size, admins: admins.size })
);
await fastify.listen({ port: 3000 });
console.log("Server running on http://localhost:3000");
console.log("API: POST /api/local to login, POST /api/local/register to create account");
console.log("Admin: POST /admin/local to login");Testing
npm testLicense
ISC
