@xenterprises/fastify-xauth-local 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitlab-ci.yml +27 -0
- package/README.md +453 -0
- package/package.json +39 -0
- package/src/routes/local.js +531 -0
- package/src/services/jwt.js +143 -0
- package/src/services/middleware.js +177 -0
- package/src/xAuthLocal.js +242 -0
- package/test/xAuthLocal.test.js +744 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Middleware Service
|
|
3
|
+
*
|
|
4
|
+
* Provides JWT authentication middleware with route exclusion support.
|
|
5
|
+
* Compatible with express-jwt `.unless()` pattern.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if a route should be excluded from authentication
|
|
10
|
+
*
|
|
11
|
+
* @param {string} url - Request URL
|
|
12
|
+
* @param {string} method - Request method
|
|
13
|
+
* @param {Array} excludedPaths - Array of path exclusion rules
|
|
14
|
+
* @returns {boolean} True if route should be excluded
|
|
15
|
+
*/
|
|
16
|
+
function isExcludedRoute(url, method, excludedPaths) {
|
|
17
|
+
if (!excludedPaths || excludedPaths.length === 0) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const rule of excludedPaths) {
|
|
22
|
+
// String pattern - exact prefix match
|
|
23
|
+
if (typeof rule === "string") {
|
|
24
|
+
if (url.startsWith(rule)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// RegExp pattern
|
|
31
|
+
if (rule instanceof RegExp) {
|
|
32
|
+
if (rule.test(url)) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Object pattern with url and optional methods (express-jwt style)
|
|
39
|
+
if (typeof rule === "object" && rule.url) {
|
|
40
|
+
let urlMatches = false;
|
|
41
|
+
|
|
42
|
+
// Check URL match
|
|
43
|
+
if (typeof rule.url === "string") {
|
|
44
|
+
urlMatches = url.startsWith(rule.url);
|
|
45
|
+
} else if (rule.url instanceof RegExp) {
|
|
46
|
+
urlMatches = rule.url.test(url);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!urlMatches) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check method match if specified
|
|
54
|
+
if (rule.methods && Array.isArray(rule.methods)) {
|
|
55
|
+
const upperMethod = method.toUpperCase();
|
|
56
|
+
if (rule.methods.map((m) => m.toUpperCase()).includes(upperMethod)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
// No method restriction, URL match is sufficient
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create authentication middleware
|
|
71
|
+
*
|
|
72
|
+
* @param {Object} jwtService - JWT service instance
|
|
73
|
+
* @param {Object} options - Middleware options
|
|
74
|
+
* @param {Array} [options.excludedPaths] - Paths to exclude from auth
|
|
75
|
+
* @param {string} [options.requestProperty] - Property to attach auth to request (default: 'auth')
|
|
76
|
+
* @param {boolean} [options.credentialsRequired] - Whether credentials are required (default: true)
|
|
77
|
+
* @param {Function} [options.getToken] - Custom function to extract token
|
|
78
|
+
* @returns {Function} Fastify onRequest hook
|
|
79
|
+
*/
|
|
80
|
+
export function createAuthMiddleware(jwtService, options = {}) {
|
|
81
|
+
const { excludedPaths = [], requestProperty = "auth", credentialsRequired = true, getToken } = options;
|
|
82
|
+
|
|
83
|
+
return async function authMiddleware(request, reply) {
|
|
84
|
+
// Check if route is excluded
|
|
85
|
+
if (isExcludedRoute(request.url, request.method, excludedPaths)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Extract token
|
|
90
|
+
let token;
|
|
91
|
+
if (getToken) {
|
|
92
|
+
token = getToken(request);
|
|
93
|
+
} else {
|
|
94
|
+
// Default: extract from Authorization header
|
|
95
|
+
const authHeader = request.headers.authorization;
|
|
96
|
+
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
97
|
+
token = authHeader.slice(7);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Handle missing token
|
|
102
|
+
if (!token) {
|
|
103
|
+
if (credentialsRequired) {
|
|
104
|
+
reply.code(401).send({
|
|
105
|
+
statusCode: 401,
|
|
106
|
+
error: "Unauthorized",
|
|
107
|
+
message: "No authorization token was found",
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Token not required, continue without auth
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Verify token
|
|
116
|
+
try {
|
|
117
|
+
const decoded = jwtService.verify(token);
|
|
118
|
+
|
|
119
|
+
// Attach to request (compatible with express-jwt's requestProperty)
|
|
120
|
+
request[requestProperty] = decoded;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
reply.code(401).send({
|
|
123
|
+
statusCode: 401,
|
|
124
|
+
error: "Unauthorized",
|
|
125
|
+
message: error.message || "Invalid token",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create role-based access control middleware
|
|
133
|
+
*
|
|
134
|
+
* @param {string|string[]} allowedRoles - Role(s) that can access the route
|
|
135
|
+
* @param {Object} options - Options
|
|
136
|
+
* @param {string} [options.requestProperty] - Property where auth is attached (default: 'auth')
|
|
137
|
+
* @param {string} [options.roleProperty] - Property in auth that contains role(s) (default: 'scope')
|
|
138
|
+
* @returns {Function} Fastify preHandler hook
|
|
139
|
+
*/
|
|
140
|
+
export function createRoleMiddleware(allowedRoles, options = {}) {
|
|
141
|
+
const { requestProperty = "auth", roleProperty = "scope" } = options;
|
|
142
|
+
|
|
143
|
+
// Normalize to array
|
|
144
|
+
const roles = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
|
|
145
|
+
|
|
146
|
+
return async function roleMiddleware(request, reply) {
|
|
147
|
+
const auth = request[requestProperty];
|
|
148
|
+
|
|
149
|
+
if (!auth) {
|
|
150
|
+
reply.code(401).send({
|
|
151
|
+
statusCode: 401,
|
|
152
|
+
error: "Unauthorized",
|
|
153
|
+
message: "Authentication required",
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Get user's roles from auth
|
|
159
|
+
const userRoles = auth[roleProperty];
|
|
160
|
+
|
|
161
|
+
// Normalize user roles to array
|
|
162
|
+
const userRoleArray = Array.isArray(userRoles) ? userRoles : userRoles ? [userRoles] : [];
|
|
163
|
+
|
|
164
|
+
// Check if user has any of the allowed roles
|
|
165
|
+
const hasRole = roles.some((role) => userRoleArray.includes(role));
|
|
166
|
+
|
|
167
|
+
if (!hasRole) {
|
|
168
|
+
reply.code(403).send({
|
|
169
|
+
statusCode: 403,
|
|
170
|
+
error: "Forbidden",
|
|
171
|
+
message: `Access denied. Required role(s): ${roles.join(", ")}`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export { isExcludedRoute };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xAuthLocal - Fastify JWT Authentication Plugin
|
|
3
|
+
*
|
|
4
|
+
* Provides JWT authentication with role-based access control,
|
|
5
|
+
* compatible with express-jwt patterns.
|
|
6
|
+
* Supports multiple auth configurations for different route prefixes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fp from "fastify-plugin";
|
|
10
|
+
import { createJwtService } from "./services/jwt.js";
|
|
11
|
+
import { createAuthMiddleware, createRoleMiddleware, isExcludedRoute } from "./services/middleware.js";
|
|
12
|
+
import { createLocalRoutes, hashPassword, comparePassword } from "./routes/local.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a key from prefix for the configs object
|
|
16
|
+
* @param {string} prefix - Route prefix
|
|
17
|
+
* @returns {string} Config key
|
|
18
|
+
*/
|
|
19
|
+
function getConfigKey(prefix) {
|
|
20
|
+
return prefix.replace(/^\//, "").replace(/\//g, "_") || "root";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* xAuthLocal Plugin
|
|
25
|
+
*
|
|
26
|
+
* @param {import('fastify').FastifyInstance} fastify - Fastify instance
|
|
27
|
+
* @param {Object} options - Plugin options
|
|
28
|
+
* @param {boolean} [options.active=true] - Enable/disable the plugin
|
|
29
|
+
* @param {Array} options.configs - Array of auth configurations
|
|
30
|
+
* @param {string} options.configs[].name - Unique name for this config
|
|
31
|
+
* @param {string} options.configs[].prefix - Route prefix to guard (e.g., '/api', '/admin')
|
|
32
|
+
* @param {string} [options.configs[].secret] - Symmetric secret for HS256
|
|
33
|
+
* @param {string} [options.configs[].publicKey] - Public key for RS256
|
|
34
|
+
* @param {string} [options.configs[].privateKey] - Private key for RS256
|
|
35
|
+
* @param {string} [options.configs[].algorithm] - Algorithm (default: RS256 for keys, HS256 for secret)
|
|
36
|
+
* @param {string} [options.configs[].expiresIn] - Token expiration (default: '4d')
|
|
37
|
+
* @param {string} [options.configs[].audience] - JWT audience claim
|
|
38
|
+
* @param {string} [options.configs[].issuer] - JWT issuer claim
|
|
39
|
+
* @param {string} [options.configs[].requestProperty] - Property to attach auth (default: 'auth')
|
|
40
|
+
* @param {boolean} [options.configs[].credentialsRequired] - Whether credentials are required (default: true)
|
|
41
|
+
* @param {Array} [options.configs[].excludedPaths] - Additional paths to exclude from auth
|
|
42
|
+
* @param {Object} [options.configs[].local] - Local routes configuration
|
|
43
|
+
* @param {boolean} [options.configs[].local.enabled] - Enable local auth routes
|
|
44
|
+
* @param {string} [options.configs[].local.loginPath] - Login route path (default: prefix + '/local')
|
|
45
|
+
* @param {string} [options.configs[].local.mePath] - Me route path (default: prefix + '/local/me')
|
|
46
|
+
* @param {boolean} [options.configs[].local.skipUserLookup] - Skip userLookup, use token data only for /me
|
|
47
|
+
* @param {Function} [options.configs[].local.userLookup] - Function to lookup user by email
|
|
48
|
+
* @param {Function} [options.configs[].local.createUser] - Function to create new user
|
|
49
|
+
* @param {Function} [options.configs[].local.passwordReset] - Function to handle password reset
|
|
50
|
+
* @param {number} [options.configs[].local.saltRounds] - bcrypt salt rounds (default: 10)
|
|
51
|
+
* @param {string} [options.basePath] - Base path for relative key paths
|
|
52
|
+
*/
|
|
53
|
+
async function xAuthLocalPlugin(fastify, options) {
|
|
54
|
+
const { active = true, configs = [], basePath = process.cwd() } = options;
|
|
55
|
+
|
|
56
|
+
// Early return if disabled
|
|
57
|
+
if (!active) {
|
|
58
|
+
fastify.log.info("xAuthLocal: Plugin disabled");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validate configs
|
|
63
|
+
if (!Array.isArray(configs) || configs.length === 0) {
|
|
64
|
+
throw new Error("xAuthLocal: configs array is required and must not be empty");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Store all auth instances
|
|
68
|
+
const authInstances = {};
|
|
69
|
+
|
|
70
|
+
// Process each config
|
|
71
|
+
for (const config of configs) {
|
|
72
|
+
const {
|
|
73
|
+
name,
|
|
74
|
+
prefix,
|
|
75
|
+
secret,
|
|
76
|
+
publicKey,
|
|
77
|
+
privateKey,
|
|
78
|
+
algorithm,
|
|
79
|
+
expiresIn = "4d",
|
|
80
|
+
audience,
|
|
81
|
+
issuer,
|
|
82
|
+
requestProperty = "auth",
|
|
83
|
+
credentialsRequired = true,
|
|
84
|
+
excludedPaths = [],
|
|
85
|
+
local = {},
|
|
86
|
+
getToken,
|
|
87
|
+
} = config;
|
|
88
|
+
|
|
89
|
+
// Validate required fields
|
|
90
|
+
if (!name) {
|
|
91
|
+
throw new Error("xAuthLocal: Each config must have a 'name'");
|
|
92
|
+
}
|
|
93
|
+
if (!prefix) {
|
|
94
|
+
throw new Error(`xAuthLocal: Config '${name}' must have a 'prefix'`);
|
|
95
|
+
}
|
|
96
|
+
if (!secret && !publicKey && !privateKey) {
|
|
97
|
+
throw new Error(`xAuthLocal: Config '${name}' requires secret or publicKey/privateKey`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Create JWT service for this config
|
|
101
|
+
const jwtService = createJwtService({
|
|
102
|
+
publicKey,
|
|
103
|
+
privateKey,
|
|
104
|
+
secret,
|
|
105
|
+
algorithm,
|
|
106
|
+
expiresIn,
|
|
107
|
+
audience,
|
|
108
|
+
issuer,
|
|
109
|
+
basePath,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Build excluded paths for this config
|
|
113
|
+
let configExcludedPaths = [...excludedPaths];
|
|
114
|
+
|
|
115
|
+
// Local route paths
|
|
116
|
+
const localPrefix = local.loginPath || `${prefix}/local`;
|
|
117
|
+
const mePath = local.mePath || `${localPrefix}/me`;
|
|
118
|
+
|
|
119
|
+
// Add local routes to excluded paths if enabled
|
|
120
|
+
if (local.enabled) {
|
|
121
|
+
configExcludedPaths.push(
|
|
122
|
+
{ url: new RegExp(`^${localPrefix}$`), methods: ["POST"] }, // Login
|
|
123
|
+
{ url: new RegExp(`^${localPrefix}/register$`), methods: ["POST"] }, // Register
|
|
124
|
+
{ url: new RegExp(`^${localPrefix}/password-reset$`), methods: ["POST", "PUT"] } // Password reset
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Create auth middleware for routes matching this prefix
|
|
129
|
+
const authMiddleware = createAuthMiddleware(jwtService, {
|
|
130
|
+
excludedPaths: configExcludedPaths,
|
|
131
|
+
requestProperty,
|
|
132
|
+
credentialsRequired,
|
|
133
|
+
getToken,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Register route-specific auth hook
|
|
137
|
+
fastify.addHook("onRequest", async (request, reply) => {
|
|
138
|
+
// Only apply to routes matching this prefix
|
|
139
|
+
if (request.url.startsWith(prefix)) {
|
|
140
|
+
await authMiddleware(request, reply);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Register local routes if enabled
|
|
145
|
+
if (local.enabled) {
|
|
146
|
+
const localRoutes = createLocalRoutes({
|
|
147
|
+
jwtService,
|
|
148
|
+
userLookup: local.userLookup,
|
|
149
|
+
createUser: local.createUser,
|
|
150
|
+
passwordReset: local.passwordReset,
|
|
151
|
+
saltRounds: local.saltRounds,
|
|
152
|
+
skipUserLookup: local.skipUserLookup,
|
|
153
|
+
mePath,
|
|
154
|
+
requestProperty,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await fastify.register(localRoutes, { prefix: localPrefix });
|
|
158
|
+
fastify.log.info(`xAuthLocal: Local routes for '${name}' registered at ${localPrefix}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Store instance info
|
|
162
|
+
authInstances[name] = {
|
|
163
|
+
name,
|
|
164
|
+
prefix,
|
|
165
|
+
jwt: jwtService,
|
|
166
|
+
requestProperty,
|
|
167
|
+
credentialsRequired,
|
|
168
|
+
hasLocalRoutes: local.enabled || false,
|
|
169
|
+
localPrefix: local.enabled ? localPrefix : null,
|
|
170
|
+
mePath: local.enabled ? mePath : null,
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create role-based middleware for this config
|
|
174
|
+
*/
|
|
175
|
+
requireRole: (roles, opts = {}) => {
|
|
176
|
+
return createRoleMiddleware(roles, {
|
|
177
|
+
requestProperty,
|
|
178
|
+
...opts,
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Create custom auth middleware for this config
|
|
184
|
+
*/
|
|
185
|
+
createMiddleware: (opts = {}) => {
|
|
186
|
+
return createAuthMiddleware(jwtService, {
|
|
187
|
+
requestProperty,
|
|
188
|
+
credentialsRequired,
|
|
189
|
+
...opts,
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check if a route is excluded from auth for this config
|
|
195
|
+
*/
|
|
196
|
+
isExcluded: (url, method) => {
|
|
197
|
+
return isExcludedRoute(url, method, configExcludedPaths);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Decorate fastify with xauthlocal namespace
|
|
203
|
+
fastify.decorate("xauthlocal", {
|
|
204
|
+
/**
|
|
205
|
+
* All auth configurations by name
|
|
206
|
+
*/
|
|
207
|
+
configs: authInstances,
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get a specific auth config by name
|
|
211
|
+
*/
|
|
212
|
+
get: (name) => authInstances[name],
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Password utilities
|
|
216
|
+
*/
|
|
217
|
+
password: {
|
|
218
|
+
hash: hashPassword,
|
|
219
|
+
compare: comparePassword,
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Summary config (read-only)
|
|
224
|
+
*/
|
|
225
|
+
config: {
|
|
226
|
+
configCount: configs.length,
|
|
227
|
+
configNames: Object.keys(authInstances),
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
fastify.log.info(`xAuthLocal: Plugin registered with ${configs.length} config(s)`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export default fp(xAuthLocalPlugin, {
|
|
235
|
+
fastify: "5.x",
|
|
236
|
+
name: "xauthlocal",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Named exports for convenience
|
|
240
|
+
export { createJwtService } from "./services/jwt.js";
|
|
241
|
+
export { createAuthMiddleware, createRoleMiddleware, isExcludedRoute } from "./services/middleware.js";
|
|
242
|
+
export { createLocalRoutes, hashPassword, comparePassword } from "./routes/local.js";
|