@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.
@@ -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";