webspresso 0.0.108 → 0.0.110

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.
Files changed (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -3
  3. package/bin/commands/add-esbuild.js +7 -3
  4. package/bin/commands/add-tailwind.js +9 -5
  5. package/bin/commands/build.js +20 -4
  6. package/bin/commands/dev.js +57 -7
  7. package/bin/commands/doctor.js +42 -1
  8. package/bin/commands/favicon-generate.js +10 -3
  9. package/bin/commands/new.js +8 -3
  10. package/bin/commands/start.js +3 -1
  11. package/bin/utils/add-command.js +16 -0
  12. package/core/app.js +45 -3
  13. package/core/auth/authenticate.js +3 -3
  14. package/core/auth/hash.js +167 -43
  15. package/core/auth/index.js +13 -1
  16. package/core/auth/jwt.js +21 -2
  17. package/core/auth/manager.js +63 -14
  18. package/core/auth/middleware.js +4 -2
  19. package/core/auth/oauth.js +5 -1
  20. package/core/auth/tokens.js +7 -2
  21. package/core/auth/totp.js +42 -6
  22. package/core/auth/user-agent.js +2 -0
  23. package/core/client/adapter/datastar.js +1401 -0
  24. package/core/client/api.js +1337 -0
  25. package/core/client/controller.js +1706 -0
  26. package/core/client/debug.js +367 -0
  27. package/core/client/devtool.js +1134 -0
  28. package/core/client/forms.js +1164 -0
  29. package/core/client/fragments.js +894 -0
  30. package/core/client/index.d.ts +59 -0
  31. package/core/client/index.js +391 -0
  32. package/core/client/lite.js +175 -0
  33. package/core/client/server-fragments.js +868 -0
  34. package/core/client/storage.js +339 -0
  35. package/core/client/utils.js +281 -0
  36. package/core/compression/middleware.js +277 -26
  37. package/core/context/app-context.js +25 -3
  38. package/core/context/request.js +32 -4
  39. package/core/errors/middleware.js +14 -0
  40. package/core/events/dispatcher.js +33 -0
  41. package/core/forms/flash.js +136 -102
  42. package/core/health/middleware.js +36 -6
  43. package/core/http/pipeline.js +45 -6
  44. package/core/index.js +25 -7
  45. package/core/kernel/view.js +41 -10
  46. package/core/metrics/middleware.js +30 -6
  47. package/core/metrics/registry.js +13 -5
  48. package/core/orm/cache/fingerprint.js +32 -11
  49. package/core/orm/cache/layer.js +19 -2
  50. package/core/orm/hidden.js +145 -0
  51. package/core/orm/model.js +48 -2
  52. package/core/orm/query-builder.js +343 -6
  53. package/core/orm/query-filters.js +14 -2
  54. package/core/orm/repository.js +64 -13
  55. package/core/orm/schema-helpers.js +9 -6
  56. package/core/orm/transaction.js +50 -6
  57. package/core/orm/types.js +4 -0
  58. package/core/queue/adapters/database.js +184 -2
  59. package/core/queue/adapters/memory.js +153 -5
  60. package/core/queue/adapters/redis.js +59 -4
  61. package/core/queue/job.js +14 -1
  62. package/core/queue/manager.js +115 -0
  63. package/core/resilience/circuit-breaker.js +27 -8
  64. package/core/services/builtins/exchange.js +174 -7
  65. package/core/services/builtins/file-manager.js +36 -0
  66. package/core/services/builtins/mail.js +27 -0
  67. package/core/services/builtins/queue.js +144 -0
  68. package/core/services/builtins/security.js +118 -0
  69. package/core/services/builtins/webhook.js +110 -0
  70. package/core/services/executor.js +38 -16
  71. package/core/services/index.js +4 -1
  72. package/core/sse/stream.js +28 -5
  73. package/core/utils/timeout.js +8 -1
  74. package/core/validation/compiler.js +10 -7
  75. package/index.d.ts +1320 -74
  76. package/index.js +128 -8
  77. package/package.json +75 -27
  78. package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
  79. package/plugins/admin-panel/index.js +19 -8
  80. package/plugins/csrf/index.js +3 -3
  81. package/plugins/data-exchange/export.js +214 -0
  82. package/plugins/data-exchange/import.js +48 -5
  83. package/plugins/data-exchange/index.js +70 -28
  84. package/plugins/data-exchange/preview.js +266 -0
  85. package/plugins/data-exchange/template.js +242 -0
  86. package/plugins/email/admin-component.js +783 -141
  87. package/plugins/email/api-handlers.js +51 -1
  88. package/plugins/email/index.js +5 -0
  89. package/plugins/email/service.js +48 -0
  90. package/plugins/file-manager/admin-component.js +1546 -70
  91. package/plugins/file-manager/api-handlers.js +166 -1
  92. package/plugins/file-manager/core.js +598 -8
  93. package/plugins/file-manager/index.js +8 -0
  94. package/plugins/index.js +7 -0
  95. package/plugins/mcp/auth.js +3 -3
  96. package/plugins/oauth/index.js +36 -3
  97. package/plugins/queue/admin-component.js +1092 -0
  98. package/plugins/queue/api-handlers.js +248 -0
  99. package/plugins/queue/index.js +69 -0
  100. package/plugins/rest-resources/index.js +7 -2
  101. package/plugins/security/admin-component.js +689 -0
  102. package/plugins/security/api-handlers.js +169 -0
  103. package/plugins/security/audit.js +153 -0
  104. package/plugins/security/bruteforce.js +244 -0
  105. package/plugins/security/firewall.js +459 -0
  106. package/plugins/security/headers.js +214 -0
  107. package/plugins/security/honeypot.js +234 -0
  108. package/plugins/security/incident-recorder.js +137 -0
  109. package/plugins/security/index.js +211 -0
  110. package/plugins/seo-checker/analyzer.js +20 -7
  111. package/plugins/upload/index.js +36 -0
  112. package/plugins/webhooks/admin-component.js +702 -175
  113. package/plugins/webhooks/api-handlers.js +75 -1
  114. package/plugins/webhooks/dispatcher.js +290 -11
  115. package/plugins/webhooks/index.js +37 -2
  116. package/plugins/webhooks/sender.js +56 -0
  117. package/plugins/xlsx/engine.js +19 -4
  118. package/src/api/api-loader.js +19 -1
  119. package/src/bundle/index.js +443 -17
  120. package/src/client-runtime/bootstrap-swup.js +9 -0
  121. package/src/client-runtime/mount.js +15 -20
  122. package/src/client-runtime/resolve.js +3 -2
  123. package/src/dev/api-typegen.js +1300 -0
  124. package/src/dev/banner.js +10 -1
  125. package/src/dev/colors.js +1 -1
  126. package/src/dev/index.js +7 -1
  127. package/src/dev/typegen.js +173 -17
  128. package/src/dev-reload/index.js +1 -1
  129. package/src/discovery/file-route-parser.js +17 -3
  130. package/src/discovery/scan-routes.js +4 -1
  131. package/src/file-router.js +253 -69
  132. package/src/helpers.js +46 -26
  133. package/src/njk-frontmatter.js +30 -4
  134. package/src/routing/cascading.js +26 -8
  135. package/src/routing/mount-discovered-routes.js +76 -0
  136. package/src/server.js +231 -70
  137. package/views/partials/webspresso-client-runtime.njk +1 -9
  138. package/.agents/ADMIN.md +0 -426
  139. package/.agents/AGENTS.md +0 -96
  140. package/.agents/ARCHITECTURE.md +0 -163
  141. package/.agents/AUTH.md +0 -581
  142. package/.agents/CACHE.md +0 -148
  143. package/.agents/CLI.md +0 -649
  144. package/.agents/COMPRESSION.md +0 -59
  145. package/.agents/CONTEXT.md +0 -152
  146. package/.agents/CONVENTIONS.md +0 -50
  147. package/.agents/EARLY_HINTS.md +0 -120
  148. package/.agents/EMAIL.md +0 -223
  149. package/.agents/ERRORS.md +0 -260
  150. package/.agents/EVENTS.md +0 -179
  151. package/.agents/FLAGS.md +0 -118
  152. package/.agents/HEALTH.md +0 -118
  153. package/.agents/LIMITS.md +0 -109
  154. package/.agents/LOGGING.md +0 -93
  155. package/.agents/MCP.md +0 -103
  156. package/.agents/METRICS.md +0 -127
  157. package/.agents/MIGRATION_JOBS.md +0 -95
  158. package/.agents/ORM.md +0 -700
  159. package/.agents/PLUGINS.md +0 -729
  160. package/.agents/QUEUE.md +0 -290
  161. package/.agents/RATE_LIMIT.md +0 -147
  162. package/.agents/REALTIME.md +0 -213
  163. package/.agents/RESILIENCE.md +0 -235
  164. package/.agents/ROUTING.md +0 -605
  165. package/.agents/SCALE_OUT.md +0 -109
  166. package/.agents/SECRETS.md +0 -117
  167. package/.agents/SERVICES.md +0 -341
  168. package/.agents/SHUTDOWN.md +0 -74
  169. package/.agents/STREAMING.md +0 -108
  170. package/.agents/WORKFLOW.md +0 -27
  171. package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
  172. package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
  173. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
  174. package/.agents/skills/webspresso-framework/SKILL.md +0 -89
  175. package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
  176. package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
  177. package/.agents/skills/webspresso-services/SKILL.md +0 -134
  178. package/.cursorrules +0 -14
  179. package/AGENTS.md +0 -203
  180. package/CLAUDE.md +0 -54
  181. package/core/kernel/run-demo.js +0 -58
  182. package/plugins/admin-panel/styles.js +0 -9
  183. package/plugins/data-exchange/export-xlsx.js +0 -119
  184. package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
package/core/auth/hash.js CHANGED
@@ -1,11 +1,9 @@
1
- /**
2
- * Webspresso Auth - Password Hashing
3
- * Bcrypt wrapper for secure password hashing
4
- * @module core/auth/hash
5
- */
6
-
1
+ const crypto = require('crypto');
2
+ const util = require('util');
7
3
  const { sha256, randomToken } = require('../utils/crypto');
8
4
 
5
+ const scryptAsync = util.promisify(crypto.scrypt);
6
+
9
7
  let bcrypt = null;
10
8
  let bcryptChecked = false;
11
9
  function getBcrypt() {
@@ -27,69 +25,191 @@ function getBcrypt() {
27
25
  const DEFAULT_ROUNDS = process.env.NODE_ENV === 'test' ? 1 : 12;
28
26
 
29
27
  /**
30
- * Hash a password using bcrypt
28
+ * Default scrypt parameters (OWASP recommended)
29
+ * In test environments, lower cost is used for test execution speed
30
+ */
31
+ const DEFAULT_SCRYPT_OPTIONS = {
32
+ cost: process.env.NODE_ENV === 'test' ? 1024 : 16384, // N
33
+ blockSize: 8, // r
34
+ parallelization: 1, // p
35
+ keylen: 64,
36
+ maxmem: 32 * 1024 * 1024, // 32MB
37
+ };
38
+
39
+ /**
40
+ * Hash a password using native crypto.scrypt (zero external dependencies)
31
41
  * @param {string} password - Plain text password
32
- * @param {number} [rounds=12] - Cost factor (rounds)
33
- * @returns {Promise<string>} Hashed password
42
+ * @param {Object} [options] - Scrypt options
43
+ * @param {number} [options.cost=16384] - CPU/memory cost parameter (N)
44
+ * @param {number} [options.blockSize=8] - Block size parameter (r)
45
+ * @param {number} [options.parallelization=1] - Parallelization parameter (p)
46
+ * @param {number} [options.keylen=64] - Desired key length in bytes
47
+ * @returns {Promise<string>} Standardized scrypt hash string
34
48
  */
35
- async function hash(password, rounds = DEFAULT_ROUNDS) {
36
- const b = getBcrypt();
37
- if (!b) {
38
- throw new Error('bcrypt is required for password hashing. Install it with: npm install bcrypt');
39
- }
40
-
49
+ async function hashScrypt(password, options = {}) {
41
50
  if (!password || typeof password !== 'string') {
42
51
  throw new Error('Password must be a non-empty string');
43
52
  }
44
-
45
- return b.hash(password, rounds);
53
+
54
+ const N = options.cost || options.N || DEFAULT_SCRYPT_OPTIONS.cost;
55
+ const r = options.blockSize || options.r || DEFAULT_SCRYPT_OPTIONS.blockSize;
56
+ const p = options.parallelization || options.p || DEFAULT_SCRYPT_OPTIONS.parallelization;
57
+ const keylen = options.keylen || DEFAULT_SCRYPT_OPTIONS.keylen;
58
+ const maxmem = options.maxmem || Math.max(DEFAULT_SCRYPT_OPTIONS.maxmem, 128 * N * r * 2);
59
+
60
+ const salt = options.salt ? Buffer.from(options.salt) : crypto.randomBytes(16);
61
+ const derivedKey = await scryptAsync(password, salt, keylen, { N, r, p, maxmem });
62
+
63
+ return `$scrypt$N=${N},r=${r},p=${p}$${salt.toString('hex')}$${derivedKey.toString('hex')}`;
46
64
  }
47
65
 
48
66
  /**
49
- * Verify a password against a hash
50
- * @param {string} password - Plain text password to verify
51
- * @param {string} hashedPassword - Hashed password to compare against
67
+ * Verify a password against an scrypt hash
68
+ * @param {string} password - Plain text password
69
+ * @param {string} hashedPassword - Standardized scrypt hash string
52
70
  * @returns {Promise<boolean>} True if password matches
53
71
  */
54
- async function verify(password, hashedPassword) {
55
- const b = getBcrypt();
56
- if (!b) {
57
- throw new Error('bcrypt is required for password verification. Install it with: npm install bcrypt');
72
+ async function verifyScrypt(password, hashedPassword) {
73
+ if (!password || !hashedPassword || typeof password !== 'string' || typeof hashedPassword !== 'string') {
74
+ return false;
58
75
  }
59
-
60
- if (!password || !hashedPassword) {
76
+
77
+ const match = hashedPassword.match(/^\$scrypt\$N=(\d+),r=(\d+),p=(\d+)\$([0-9a-fA-F]+)\$([0-9a-fA-F]+)$/);
78
+ if (!match) {
61
79
  return false;
62
80
  }
63
-
81
+
82
+ const N = parseInt(match[1], 10);
83
+ const r = parseInt(match[2], 10);
84
+ const p = parseInt(match[3], 10);
85
+ const salt = Buffer.from(match[4], 'hex');
86
+ const expectedKey = Buffer.from(match[5], 'hex');
87
+
88
+ const maxmem = Math.max(DEFAULT_SCRYPT_OPTIONS.maxmem, 128 * N * r * 2);
89
+
64
90
  try {
65
- return await b.compare(password, hashedPassword);
91
+ const derivedKey = await scryptAsync(password, salt, expectedKey.length, { N, r, p, maxmem });
92
+ if (derivedKey.length !== expectedKey.length) {
93
+ return false;
94
+ }
95
+ return crypto.timingSafeEqual(derivedKey, expectedKey);
66
96
  } catch {
67
97
  return false;
68
98
  }
69
99
  }
70
100
 
71
101
  /**
72
- * Check if a hash needs rehashing (e.g., cost factor changed)
73
- * @param {string} hashedPassword - Hashed password to check
74
- * @param {number} [rounds=12] - Desired cost factor
75
- * @returns {boolean} True if rehash is needed
102
+ * Hash a password using bcrypt (or native scrypt if bcrypt is unavailable or requested)
103
+ * @param {string} password - Plain text password
104
+ * @param {number|Object} [options=12] - Cost factor (rounds) or options object { algorithm: 'scrypt'|'bcrypt'|'auto', ... }
105
+ * @returns {Promise<string>} Hashed password
76
106
  */
77
- function needsRehash(hashedPassword, rounds = DEFAULT_ROUNDS) {
107
+ async function hash(password, options = DEFAULT_ROUNDS) {
108
+ if (!password || typeof password !== 'string') {
109
+ throw new Error('Password must be a non-empty string');
110
+ }
111
+
112
+ let algorithm = 'auto';
113
+ let rounds = DEFAULT_ROUNDS;
114
+ let scryptOpts = {};
115
+
116
+ if (typeof options === 'number') {
117
+ rounds = options;
118
+ } else if (options && typeof options === 'object') {
119
+ algorithm = options.algorithm || 'auto';
120
+ rounds = options.rounds || DEFAULT_ROUNDS;
121
+ scryptOpts = options;
122
+ }
123
+
78
124
  const b = getBcrypt();
125
+
126
+ if (algorithm === 'scrypt' || (!b && algorithm === 'auto')) {
127
+ return hashScrypt(password, scryptOpts);
128
+ }
129
+
79
130
  if (!b) {
80
- throw new Error('bcrypt is required. Install it with: npm install bcrypt');
131
+ throw new Error('bcrypt is required for bcrypt password hashing. Install it with: npm install bcrypt or use scrypt');
81
132
  }
82
-
83
- if (!hashedPassword) {
84
- return true;
133
+
134
+ return b.hash(password, rounds);
135
+ }
136
+
137
+ /**
138
+ * Verify a password against a hash (supports both bcrypt and native scrypt hashes)
139
+ * @param {string} password - Plain text password to verify
140
+ * @param {string} hashedPassword - Hashed password to compare against
141
+ * @returns {Promise<boolean>} True if password matches
142
+ */
143
+ async function verify(password, hashedPassword) {
144
+ if (!password || !hashedPassword || typeof password !== 'string' || typeof hashedPassword !== 'string') {
145
+ return false;
85
146
  }
86
-
87
- try {
88
- const hashRounds = b.getRounds(hashedPassword);
89
- return hashRounds < rounds;
90
- } catch {
147
+
148
+ // Handle native scrypt hash format
149
+ if (hashedPassword.startsWith('$scrypt$')) {
150
+ return verifyScrypt(password, hashedPassword);
151
+ }
152
+
153
+ // Handle bcrypt hash formats ($2a$, $2b$, $2y$)
154
+ if (hashedPassword.startsWith('$2')) {
155
+ const b = getBcrypt();
156
+ if (!b) {
157
+ throw new Error('bcrypt is required to verify bcrypt hashes. Install it with: npm install bcrypt');
158
+ }
159
+ try {
160
+ return await b.compare(password, hashedPassword);
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * Check if a hash needs rehashing (e.g., cost factor changed or migrating to scrypt)
171
+ * @param {string} hashedPassword - Hashed password to check
172
+ * @param {number|Object} [options=12] - Desired cost factor (rounds) or options object
173
+ * @returns {boolean} True if rehash is needed
174
+ */
175
+ function needsRehash(hashedPassword, options = DEFAULT_ROUNDS) {
176
+ if (!hashedPassword || typeof hashedPassword !== 'string') {
91
177
  return true;
92
178
  }
179
+
180
+ let desiredAlgorithm = 'auto';
181
+ let desiredRounds = DEFAULT_ROUNDS;
182
+ let desiredN = DEFAULT_SCRYPT_OPTIONS.cost;
183
+
184
+ if (typeof options === 'number') {
185
+ desiredRounds = options;
186
+ } else if (options && typeof options === 'object') {
187
+ desiredAlgorithm = options.algorithm || 'auto';
188
+ desiredRounds = options.rounds || DEFAULT_ROUNDS;
189
+ desiredN = options.cost || options.N || DEFAULT_SCRYPT_OPTIONS.cost;
190
+ }
191
+
192
+ if (hashedPassword.startsWith('$scrypt$')) {
193
+ if (desiredAlgorithm === 'bcrypt') return true;
194
+ const match = hashedPassword.match(/^\$scrypt\$N=(\d+)/);
195
+ if (!match) return true;
196
+ const currentN = parseInt(match[1], 10);
197
+ return currentN < desiredN;
198
+ }
199
+
200
+ if (hashedPassword.startsWith('$2')) {
201
+ if (desiredAlgorithm === 'scrypt') return true;
202
+ const b = getBcrypt();
203
+ if (!b) return true;
204
+ try {
205
+ const hashRounds = b.getRounds(hashedPassword);
206
+ return hashRounds < desiredRounds;
207
+ } catch {
208
+ return true;
209
+ }
210
+ }
211
+
212
+ return true;
93
213
  }
94
214
 
95
215
  /**
@@ -105,7 +225,7 @@ function generateToken(length = 32) {
105
225
  * Hash a high-entropy random lookup token for database storage (SHA-256).
106
226
  * Note: Used exclusively for 256-bit cryptographically secure random lookup tokens
107
227
  * (remember-me tokens, email verification tokens, password reset tokens), NOT user passwords.
108
- * User passwords must always be hashed using `hash()` (bcrypt).
228
+ * User passwords must always be hashed using `hash()` (bcrypt or scrypt).
109
229
  *
110
230
  * // CodeQL [js/insufficient-password-hash] SHA-256 is intentionally used for 256-bit high-entropy random lookup token hashing, not user passwords.
111
231
  * @param {string} token - High-entropy random token
@@ -121,7 +241,11 @@ module.exports = {
121
241
  hash,
122
242
  verify,
123
243
  needsRehash,
244
+ hashScrypt,
245
+ verifyScrypt,
246
+ DEFAULT_SCRYPT_OPTIONS,
124
247
  generateToken,
125
248
  hashToken,
126
249
  DEFAULT_ROUNDS,
127
250
  };
251
+
@@ -15,7 +15,16 @@ function getMiddleware() {
15
15
  }
16
16
  return _middleware;
17
17
  }
18
- const { hash, verify, needsRehash, generateToken, hashToken } = require('./hash');
18
+ const {
19
+ hash,
20
+ verify,
21
+ needsRehash,
22
+ hashScrypt,
23
+ verifyScrypt,
24
+ DEFAULT_SCRYPT_OPTIONS,
25
+ generateToken,
26
+ hashToken,
27
+ } = require('./hash');
19
28
  const { signJwt, verifyJwt, decodeJwt, clearJwtCache, HS256_HEADER_BASE64URL, MAX_PAYLOAD_SIZE } = require('./jwt');
20
29
  const {
21
30
  TOKEN_TYPES,
@@ -264,6 +273,9 @@ module.exports = {
264
273
  hash,
265
274
  verify,
266
275
  needsRehash,
276
+ hashScrypt,
277
+ verifyScrypt,
278
+ DEFAULT_SCRYPT_OPTIONS,
267
279
  generateToken,
268
280
  hashToken,
269
281
 
package/core/auth/jwt.js CHANGED
@@ -118,13 +118,23 @@ function verifyJwt(token, secret, options = {}) {
118
118
  verifiedJwtCache.set(cacheKey, cached);
119
119
 
120
120
  const nowSec = Math.floor(Date.now() / 1000);
121
+ const leewaySec = typeof options.leeway === 'number'
122
+ ? options.leeway
123
+ : (typeof options.clockTolerance === 'number' ? options.clockTolerance : 0);
124
+
121
125
  if (!options.ignoreExpiration && typeof cached.exp === 'number') {
122
- if (nowSec >= cached.exp) {
126
+ if (nowSec >= cached.exp + leewaySec) {
123
127
  verifiedJwtCache.delete(cacheKey);
124
128
  throw new Error('JWT token has expired');
125
129
  }
126
130
  }
127
131
 
132
+ if (typeof cached.nbf === 'number') {
133
+ if (nowSec + leewaySec < cached.nbf) {
134
+ throw new Error('JWT token is not active yet');
135
+ }
136
+ }
137
+
128
138
  if (options.issuer && cached.iss !== options.issuer) {
129
139
  throw new Error(`Invalid JWT issuer (expected ${options.issuer}, got ${cached.iss})`);
130
140
  }
@@ -188,13 +198,22 @@ function verifyJwt(token, secret, options = {}) {
188
198
 
189
199
  // 4. Check claims
190
200
  const nowSec = Math.floor(Date.now() / 1000);
201
+ const leewaySec = typeof options.leeway === 'number'
202
+ ? options.leeway
203
+ : (typeof options.clockTolerance === 'number' ? options.clockTolerance : 0);
191
204
 
192
205
  if (!options.ignoreExpiration && typeof payload.exp === 'number') {
193
- if (nowSec >= payload.exp) {
206
+ if (nowSec >= payload.exp + leewaySec) {
194
207
  throw new Error('JWT token has expired');
195
208
  }
196
209
  }
197
210
 
211
+ if (typeof payload.nbf === 'number') {
212
+ if (nowSec + leewaySec < payload.nbf) {
213
+ throw new Error('JWT token is not active yet');
214
+ }
215
+ }
216
+
198
217
  if (options.issuer && payload.iss !== options.issuer) {
199
218
  throw new Error(`Invalid JWT issuer (expected ${options.issuer}, got ${payload.iss})`);
200
219
  }
@@ -29,6 +29,36 @@ class AuthenticationError extends Error {
29
29
  }
30
30
  }
31
31
 
32
+ const WEAK_SECRETS_PATTERN = /^(changeme|secret|default|password|test|webspresso-|123456)/i;
33
+
34
+ /**
35
+ * Validate secret strength
36
+ * In production: throws Error if secret is missing, shorter than 32 chars, or uses common defaults.
37
+ * In development/test: logs a warning if weak.
38
+ * @param {string} name - Name of the secret setting / env var
39
+ * @param {string} value - Secret value
40
+ * @param {boolean} [isProd=false] - Whether environment is production
41
+ */
42
+ function assertStrongSecret(name, value, isProd = false) {
43
+ if (!value || typeof value !== 'string') {
44
+ throw new Error(`${name} is required.`);
45
+ }
46
+
47
+ const isWeak = value.length < 32 || WEAK_SECRETS_PATTERN.test(value);
48
+
49
+ if (isProd && isWeak) {
50
+ throw new Error(
51
+ `Insecure secret for ${name}: Production requires at least 32 high-entropy characters and cannot use default/weak words. Generate one with 'openssl rand -hex 32'.`
52
+ );
53
+ }
54
+
55
+ if (!isProd && isWeak && !process.env.VITEST && process.env.NODE_ENV !== 'test') {
56
+ console.warn(
57
+ `\x1b[33m[Webspresso Security Warning]\x1b[0m ${name} appears weak or uses a default value. In production, at least 32 high-entropy characters are strictly enforced.`
58
+ );
59
+ }
60
+ }
61
+
32
62
  /**
33
63
  * Default configuration
34
64
  */
@@ -117,12 +147,20 @@ class AuthManager extends EventEmitter {
117
147
  this.twoFactor = config.twoFactor || { enabled: false };
118
148
 
119
149
  if (config.jwt || config.secret) {
150
+ const isProd = process.env.NODE_ENV === 'production';
120
151
  const jwtSecret = typeof config.jwt === 'object' && config.jwt.secret
121
152
  ? config.jwt.secret
122
- : (config.secret || config.session?.secret || process.env.JWT_SECRET || process.env.AUTH_SESSION_SECRET || 'webspresso-jwt-secret');
153
+ : (config.secret || config.session?.secret || process.env.JWT_SECRET || process.env.AUTH_SESSION_SECRET || (isProd ? null : 'webspresso-jwt-secret'));
154
+
155
+ assertStrongSecret('JWT_SECRET / auth.secret', jwtSecret, isProd);
156
+
123
157
  const refreshSecret = typeof config.jwt === 'object' && config.jwt.refreshSecret
124
158
  ? config.jwt.refreshSecret
125
159
  : (process.env.JWT_REFRESH_SECRET || jwtSecret);
160
+
161
+ if (refreshSecret !== jwtSecret) {
162
+ assertStrongSecret('JWT_REFRESH_SECRET', refreshSecret, isProd);
163
+ }
126
164
  const expiresIn = typeof config.jwt === 'object' && config.jwt.expiresIn
127
165
  ? config.jwt.expiresIn
128
166
  : '24h';
@@ -231,10 +269,9 @@ class AuthManager extends EventEmitter {
231
269
  */
232
270
  getSessionConfig() {
233
271
  const config = { ...this.config.session };
272
+ const isProd = process.env.NODE_ENV === 'production';
234
273
 
235
- if (!config.secret) {
236
- throw new Error('Session secret is required. Set AUTH_SESSION_SECRET environment variable or pass session.secret in config.');
237
- }
274
+ assertStrongSecret('AUTH_SESSION_SECRET / session.secret', config.secret, isProd);
238
275
 
239
276
  // Auto-detect and configure distributed Redis session store
240
277
  if (!config.store) {
@@ -559,7 +596,10 @@ class AuthManager extends EventEmitter {
559
596
  * @param {string} tokenOrCode - Submitted 6-digit TOTP code or recovery code
560
597
  * @param {Object|string} secretOrUser - Secret string or user object with totp_secret / recovery_codes
561
598
  * @param {Object} [options]
562
- * @returns {{ success: boolean, type?: 'totp'|'recovery_code', delta?: number, remainingHashedCodes?: string[], matchedIndex?: number }}
599
+ * @param {number} [options.lastStep] - Last accepted TOTP counter (defaults to user.totp_last_step / totpLastStep)
600
+ * @param {boolean} [options.preventReuse=true] - Reject a TOTP code whose counter was already accepted
601
+ * @returns {{ success: boolean, type?: 'totp'|'recovery_code', delta?: number, step?: number, remainingHashedCodes?: string[], matchedIndex?: number }}
602
+ * On TOTP success, persist `step` (e.g. users.totp_last_step) to block replays across processes.
563
603
  */
564
604
  verify2Fa(tokenOrCode, secretOrUser, options = {}) {
565
605
  if (!tokenOrCode) {
@@ -578,10 +618,21 @@ class AuthManager extends EventEmitter {
578
618
 
579
619
  // Try TOTP code first
580
620
  if (secret) {
581
- const totpResult = self.totp.verifyTotp(tokenOrCode, secret, { ...options, detailed: true });
621
+ const preventReuse = options.preventReuse !== false;
622
+ let afterStep;
623
+ if (preventReuse) {
624
+ const persisted = options.lastStep ?? (secretOrUser && typeof secretOrUser === 'object'
625
+ ? (secretOrUser.totp_last_step ?? secretOrUser.totpLastStep)
626
+ : undefined);
627
+ const steps = [Number(persisted), self.totp.getLastUsedTotpStep(secret)]
628
+ .filter((n) => typeof n === 'number' && Number.isFinite(n));
629
+ if (steps.length > 0) afterStep = Math.max(...steps);
630
+ }
631
+ const totpResult = self.totp.verifyTotp(tokenOrCode, secret, { ...options, afterStep, detailed: true });
582
632
  if (totpResult.valid) {
633
+ if (preventReuse) self.totp.markTotpStepUsed(secret, totpResult.counter);
583
634
  this.set2FaVerified(true);
584
- return { success: true, type: 'totp', delta: totpResult.delta };
635
+ return { success: true, type: 'totp', delta: totpResult.delta, step: totpResult.counter };
585
636
  }
586
637
  }
587
638
 
@@ -895,13 +946,13 @@ class AuthManager extends EventEmitter {
895
946
  TOKEN_TYPES.PASSWORD_RESET,
896
947
  rawToken
897
948
  );
898
- if (!verified) {
949
+ // Claim the token before acting on it so concurrent requests cannot both redeem it
950
+ if (!verified || !(await consumeAuthToken(this.authTokens, verified.tokenHash))) {
899
951
  throw new AuthenticationError('Invalid or expired reset token', 'INVALID_TOKEN');
900
952
  }
901
953
 
902
954
  const hashed = await hashPassword(newPassword);
903
955
  await this.updateUser(verified.userId, { [this.passwordField]: hashed });
904
- await consumeAuthToken(this.authTokens, verified.tokenHash);
905
956
  await this.authTokens.deleteAllForUser(verified.userId, TOKEN_TYPES.PASSWORD_RESET);
906
957
 
907
958
  return { ok: true };
@@ -966,12 +1017,11 @@ class AuthManager extends EventEmitter {
966
1017
  TOKEN_TYPES.EMAIL_VERIFY,
967
1018
  rawToken
968
1019
  );
969
- if (!verified) {
1020
+ if (!verified || !(await consumeAuthToken(this.authTokens, verified.tokenHash))) {
970
1021
  throw new AuthenticationError('Invalid or expired verification token', 'INVALID_TOKEN');
971
1022
  }
972
1023
 
973
1024
  await this.updateUser(verified.userId, { [this.verifiedField]: new Date() });
974
- await consumeAuthToken(this.authTokens, verified.tokenHash);
975
1025
 
976
1026
  return { ok: true, userId: verified.userId };
977
1027
  }
@@ -1059,7 +1109,7 @@ class AuthManager extends EventEmitter {
1059
1109
  TOKEN_TYPES.MAGIC_LINK,
1060
1110
  rawToken
1061
1111
  );
1062
- if (!verified) {
1112
+ if (!verified || !(await consumeAuthToken(this.authTokens, verified.tokenHash))) {
1063
1113
  throw new AuthenticationError('Invalid or expired magic link', 'INVALID_MAGIC_LINK');
1064
1114
  }
1065
1115
 
@@ -1068,8 +1118,6 @@ class AuthManager extends EventEmitter {
1068
1118
  throw new AuthenticationError('User not found for magic link', 'USER_NOT_FOUND');
1069
1119
  }
1070
1120
 
1071
- await consumeAuthToken(this.authTokens, verified.tokenHash);
1072
-
1073
1121
  if (options.consumeAllForUser !== false) {
1074
1122
  await this.authTokens.deleteAllForUser(verified.userId, TOKEN_TYPES.MAGIC_LINK);
1075
1123
  }
@@ -1330,4 +1378,5 @@ module.exports = {
1330
1378
  AuthManager,
1331
1379
  AuthenticationError,
1332
1380
  DEFAULT_CONFIG,
1381
+ assertStrongSecret,
1333
1382
  };
@@ -45,6 +45,8 @@ function createAuthMiddleware(authManager) {
45
45
  * Sets up express-session with secure cookie enforcement
46
46
  */
47
47
  const sessionMiddleware = session({
48
+ resave: sessionConfig.resave ?? false,
49
+ saveUninitialized: sessionConfig.saveUninitialized ?? false,
48
50
  ...sessionConfig,
49
51
  cookie: {
50
52
  httpOnly: true,
@@ -125,14 +127,14 @@ function createAuthMiddleware(authManager) {
125
127
  function requireJwt(options = {}) {
126
128
  return async (req, res, next) => {
127
129
  const authHeader = req.headers?.authorization || req.headers?.Authorization;
128
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
130
+ if (!authHeader || !/^Bearer\s+/i.test(authHeader)) {
129
131
  return res.status(401).json({
130
132
  error: 'Unauthorized',
131
133
  message: 'Bearer token required',
132
134
  });
133
135
  }
134
136
 
135
- const token = authHeader.substring(7).trim();
137
+ const token = authHeader.replace(/^Bearer\s+/i, '').trim();
136
138
  try {
137
139
  const payload = authManager.verifyJwt(token, options);
138
140
  req.token = token;
@@ -8,6 +8,7 @@
8
8
  const crypto = require('crypto');
9
9
  const { fetchWithTimeout } = require('../utils/fetch');
10
10
  const { safeCompare, sha256, hmacSha256, base64UrlEncode, base64UrlDecode, randomToken } = require('../utils/crypto');
11
+ const { assertStrongSecret } = require('./manager');
11
12
 
12
13
  /**
13
14
  * Custom error class for OAuth operations
@@ -193,7 +194,10 @@ class OAuthClient {
193
194
  * @param {number} [config.stateTtlMs=600000] - State token TTL in ms
194
195
  */
195
196
  constructor(config = {}) {
196
- this.secret = config.secret || process.env.OAUTH_SECRET || process.env.SESSION_SECRET || 'webspresso-oauth-secret';
197
+ const isProd = process.env.NODE_ENV === 'production';
198
+ this.secret = config.secret || process.env.OAUTH_SECRET || process.env.SESSION_SECRET || (isProd ? null : 'webspresso-oauth-secret');
199
+ // State tokens are HMAC-signed with this secret; a well-known default would let anyone forge them.
200
+ if (isProd) assertStrongSecret('OAUTH_SECRET / oauth.secret', this.secret, true);
197
201
  this.stateTtlMs = config.stateTtlMs || 10 * 60 * 1000;
198
202
  this.timeout = typeof (config.timeout || config.requestTimeout) === 'number'
199
203
  ? (config.timeout || config.requestTimeout)
@@ -63,7 +63,7 @@ function createKnexAuthTokensAdapter(db) {
63
63
  return knex('auth_tokens').where({ token: tokenHash, type }).first();
64
64
  },
65
65
  delete: async (tokenHash) => {
66
- await knex('auth_tokens').where({ token: tokenHash }).delete();
66
+ return knex('auth_tokens').where({ token: tokenHash }).delete();
67
67
  },
68
68
  deleteAllForUser: async (userId, type) => {
69
69
  const q = knex('auth_tokens').where({ user_id: userId });
@@ -120,11 +120,16 @@ async function verifyAuthToken(authTokensAdapter, type, rawToken) {
120
120
  }
121
121
 
122
122
  /**
123
+ * Atomically claim a verified token by deleting it. Only the caller whose delete removed the row
124
+ * may act on the token, so concurrent requests cannot both redeem it.
125
+ * Adapters whose delete() returns no count are trusted (treated as claimed).
123
126
  * @param {Object} authTokensAdapter
124
127
  * @param {string} tokenHash
128
+ * @returns {Promise<boolean>} true when this caller claimed the token
125
129
  */
126
130
  async function consumeAuthToken(authTokensAdapter, tokenHash) {
127
- await authTokensAdapter.delete(tokenHash);
131
+ const deleted = await authTokensAdapter.delete(tokenHash);
132
+ return typeof deleted === 'number' ? deleted > 0 : true;
128
133
  }
129
134
 
130
135
  /**
package/core/auth/totp.js CHANGED
@@ -223,13 +223,16 @@ function generateTotp(secret, options = {}) {
223
223
  * @param {number} [options.step=30] - Time step in seconds
224
224
  * @param {number} [options.digits=6] - Expected digits
225
225
  * @param {string} [options.algorithm='sha1'] - 'sha1', 'sha256', 'sha512'
226
- * @param {boolean} [options.detailed=false] - Return { valid: boolean, delta: number|null } instead of boolean
227
- * @returns {boolean|{ valid: boolean, delta: number|null }}
226
+ * @param {number} [options.afterStep] - Reject counters <= this value (RFC 6238 §5.2 replay protection;
227
+ * pass the counter of the last accepted code)
228
+ * @param {boolean} [options.detailed=false] - Return { valid, delta, counter } instead of boolean
229
+ * @returns {boolean|{ valid: boolean, delta: number|null, counter?: number|null }}
228
230
  */
229
231
  function verifyTotp(token, secret, options = {}) {
230
232
  if (typeof token !== 'string') {
231
- return options.detailed ? { valid: false, delta: null } : false;
233
+ return options.detailed ? { valid: false, delta: null, counter: null } : false;
232
234
  }
235
+ const afterStep = typeof options.afterStep === 'number' ? options.afterStep : -1;
233
236
 
234
237
  const cleanToken = token.trim();
235
238
  const digits = options.digits || cleanToken.length || 6;
@@ -250,23 +253,54 @@ function verifyTotp(token, secret, options = {}) {
250
253
 
251
254
  for (let delta = -windowSize; delta <= windowSize; delta++) {
252
255
  const counter = currentCounter + delta;
253
- if (counter < 0) continue;
256
+ if (counter < 0 || counter <= afterStep) continue;
254
257
 
255
258
  const expected = generateHotp(secret, counter, { ...options, digits });
256
259
  if (timingSafeTokenCompare(cleanToken, expected)) {
257
260
  if (options.detailed) {
258
- return { valid: true, delta };
261
+ return { valid: true, delta, counter };
259
262
  }
260
263
  return true;
261
264
  }
262
265
  }
263
266
 
264
267
  if (options.detailed) {
265
- return { valid: false, delta: null };
268
+ return { valid: false, delta: null, counter: null };
266
269
  }
267
270
  return false;
268
271
  }
269
272
 
273
+ // In-process record of the last accepted TOTP counter per secret, so a code cannot be
274
+ // replayed within its validity window. Bounded; persist the returned step for cluster-wide safety.
275
+ const MAX_TRACKED_TOTP_SECRETS = 10000;
276
+ const lastUsedTotpSteps = new Map();
277
+
278
+ function secretKey(secret) {
279
+ return sha256(Buffer.isBuffer(secret) ? secret.toString('hex') : String(secret));
280
+ }
281
+
282
+ /**
283
+ * @param {string|Buffer} secret
284
+ * @returns {number|undefined} Last accepted counter for this secret in this process
285
+ */
286
+ function getLastUsedTotpStep(secret) {
287
+ return lastUsedTotpSteps.get(secretKey(secret));
288
+ }
289
+
290
+ /**
291
+ * @param {string|Buffer} secret
292
+ * @param {number} counter - Accepted counter
293
+ */
294
+ function markTotpStepUsed(secret, counter) {
295
+ const key = secretKey(secret);
296
+ const prev = lastUsedTotpSteps.get(key);
297
+ lastUsedTotpSteps.delete(key);
298
+ lastUsedTotpSteps.set(key, typeof prev === 'number' ? Math.max(prev, counter) : counter);
299
+ if (lastUsedTotpSteps.size > MAX_TRACKED_TOTP_SECRETS) {
300
+ lastUsedTotpSteps.delete(lastUsedTotpSteps.keys().next().value);
301
+ }
302
+ }
303
+
270
304
  /**
271
305
  * Generate standard Key URI for Google Authenticator / 1Password / Authy (otpauth://)
272
306
  * @param {Object} options
@@ -465,6 +499,8 @@ function createTotpSetup(options = {}) {
465
499
  }
466
500
 
467
501
  module.exports = {
502
+ getLastUsedTotpStep,
503
+ markTotpStepUsed,
468
504
  // Base32
469
505
  base32Encode,
470
506
  base32Decode,
@@ -57,6 +57,8 @@ function parseUserAgent(userAgent = '') {
57
57
 
58
58
  const cached = uaCache.get(ua);
59
59
  if (cached) {
60
+ uaCache.delete(ua);
61
+ uaCache.set(ua, cached);
60
62
  return { ...cached };
61
63
  }
62
64