webspresso 0.0.84 → 0.0.85

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/README.md CHANGED
@@ -1316,6 +1316,86 @@ createApp({
1316
1316
  });
1317
1317
  ```
1318
1318
 
1319
+ ### Authentication & JWT Support (`quickAuth`)
1320
+
1321
+ Webspresso provides a zero-dependency **Dual Authentication** system (`webspresso/core/auth`):
1322
+ - **Session + Cookie**: Stateful auth for browser SSR routes and Admin Panel (`/`, `/_admin`).
1323
+ - **JWT + Refresh Token**: Stateless HS256 auth for REST APIs, Mobile Apps, and SPAs (`/api/*`).
1324
+
1325
+ ```javascript
1326
+ // createApp setup with quickAuth
1327
+ const { quickAuth } = require('webspresso/core/auth');
1328
+
1329
+ const auth = quickAuth({
1330
+ db,
1331
+ userModel: 'User',
1332
+ jwt: {
1333
+ expiresIn: '15m', // Access token duration (default: 24h)
1334
+ refreshExpiresIn: '30d', // Refresh token duration (default: 30d)
1335
+ },
1336
+ });
1337
+
1338
+ createApp({
1339
+ pagesDir: './pages',
1340
+ db,
1341
+ auth,
1342
+ });
1343
+ ```
1344
+
1345
+ **File-Based API Route Examples (`pages/api/*`):**
1346
+
1347
+ ```javascript
1348
+ // 1. pages/api/auth/login.post.js (Login & Token Generation)
1349
+ module.exports = {
1350
+ schema: ({ z }) => ({
1351
+ body: z.object({
1352
+ email: z.string().email(),
1353
+ password: z.string().min(1),
1354
+ }),
1355
+ }),
1356
+ async handler(req, res) {
1357
+ const { email, password } = req.input.body;
1358
+ const user = await req.auth.attempt(email, password);
1359
+ if (!user) return res.status(401).json({ error: 'Invalid credentials' });
1360
+
1361
+ const accessToken = req.auth.generateUserToken(user);
1362
+ const refreshToken = req.auth.generateRefreshToken(user);
1363
+
1364
+ return res.json({ accessToken, refreshToken, user: { id: user.id, email: user.email } });
1365
+ },
1366
+ };
1367
+ ```
1368
+
1369
+ ```javascript
1370
+ // 2. pages/api/auth/refresh.post.js (Token Rotation Endpoint)
1371
+ module.exports = {
1372
+ schema: ({ z }) => ({
1373
+ body: z.object({
1374
+ refreshToken: z.string(),
1375
+ }),
1376
+ }),
1377
+ async handler(req, res) {
1378
+ const { refreshToken } = req.input.body;
1379
+ try {
1380
+ const result = await req.auth.refreshAccessToken(refreshToken, { rotate: true });
1381
+ return res.json({ accessToken: result.accessToken, refreshToken: result.refreshToken });
1382
+ } catch (err) {
1383
+ return res.status(401).json({ error: err.message });
1384
+ }
1385
+ },
1386
+ };
1387
+ ```
1388
+
1389
+ ```javascript
1390
+ // 3. pages/api/profile.get.js (Protected REST API Route)
1391
+ module.exports = {
1392
+ middleware: ['jwt'],
1393
+ async handler(req, res) {
1394
+ return res.json({ user: req.user, token: req.token });
1395
+ },
1396
+ };
1397
+ ```
1398
+
1319
1399
  **With Schema Validation (same object shape):**
1320
1400
 
1321
1401
  ```javascript
@@ -54,7 +54,7 @@ function openFile(filePath, cwd) {
54
54
  if (process.platform === 'darwin') {
55
55
  execFileSync('open', [fp], { stdio: 'ignore' });
56
56
  } else if (process.platform === 'win32') {
57
- execFileSync('cmd', ['/c', 'start', '', fp], { stdio: 'ignore' });
57
+ execFileSync('explorer.exe', [fp.replace(/\//g, '\\')], { stdio: 'ignore' });
58
58
  } else {
59
59
  execFileSync('xdg-open', [fp], { stdio: 'ignore' });
60
60
  }
@@ -8,6 +8,7 @@ const { AuthManager, AuthenticationError, DEFAULT_CONFIG } = require('./manager'
8
8
  const { PolicyManager, AuthorizationError } = require('./policy');
9
9
  const { createAuthMiddleware, setupAuthMiddleware } = require('./middleware');
10
10
  const { hash, verify, needsRehash, generateToken, hashToken } = require('./hash');
11
+ const { signJwt, verifyJwt, decodeJwt } = require('./jwt');
11
12
  const {
12
13
  TOKEN_TYPES,
13
14
  createAuthTokensTable,
@@ -23,50 +24,9 @@ const {
23
24
  * @param {Object} [config.rememberTokens] - Remember token adapter
24
25
  * @param {Object} [config.session] - Session configuration
25
26
  * @param {Object} [config.rememberMe] - Remember me configuration
27
+ * @param {Object|boolean} [config.jwt] - JWT configuration
26
28
  * @param {Object} [config.routes] - Route configuration
27
29
  * @returns {AuthManager}
28
- *
29
- * @example
30
- * const auth = createAuth({
31
- * findUserById: async (id) => {
32
- * return await UserRepo.findById(id);
33
- * },
34
- *
35
- * findUserByCredentials: async (email, password) => {
36
- * const user = await UserRepo.findOne({ email });
37
- * if (user && await verify(password, user.password)) {
38
- * return user;
39
- * }
40
- * return null;
41
- * },
42
- *
43
- * // Optional: Remember me tokens
44
- * rememberTokens: {
45
- * create: async (userId, token, expiresAt) => {
46
- * await db.knex('remember_tokens').insert({
47
- * user_id: userId,
48
- * token,
49
- * expires_at: expiresAt,
50
- * });
51
- * },
52
- * find: async (token) => {
53
- * return await db.knex('remember_tokens').where({ token }).first();
54
- * },
55
- * delete: async (token) => {
56
- * await db.knex('remember_tokens').where({ token }).delete();
57
- * },
58
- * deleteAllForUser: async (userId) => {
59
- * await db.knex('remember_tokens').where({ user_id: userId }).delete();
60
- * },
61
- * },
62
- *
63
- * session: {
64
- * secret: process.env.SESSION_SECRET,
65
- * cookie: {
66
- * maxAge: 24 * 60 * 60 * 1000, // 1 day
67
- * },
68
- * },
69
- * });
70
30
  */
71
31
  function createAuth(config) {
72
32
  return new AuthManager(config);
@@ -82,6 +42,7 @@ function createAuth(config) {
82
42
  * @param {Object} [options.session] - Session config
83
43
  * @param {boolean} [options.rememberMe=true] - Enable remember me
84
44
  * @param {boolean} [options.authTokens=false] - Enable auth_tokens adapter (password reset / email verify)
45
+ * @param {Object|boolean} [options.jwt=false] - Enable JWT support or JWT config
85
46
  * @param {string} [options.verifiedField='email_verified_at'] - Verified timestamp column
86
47
  * @returns {AuthManager}
87
48
  */
@@ -94,6 +55,7 @@ function quickAuth(options) {
94
55
  session = {},
95
56
  rememberMe = true,
96
57
  authTokens: enableAuthTokens = false,
58
+ jwt = false,
97
59
  verifiedField = 'email_verified_at',
98
60
  } = options;
99
61
 
@@ -127,6 +89,7 @@ function quickAuth(options) {
127
89
  identifierField,
128
90
  passwordField,
129
91
  verifiedField,
92
+ jwt,
130
93
 
131
94
  session: {
132
95
  secret: process.env.SESSION_SECRET || session.secret,
@@ -218,6 +181,11 @@ module.exports = {
218
181
  needsRehash,
219
182
  generateToken,
220
183
  hashToken,
184
+
185
+ // JWT utilities
186
+ signJwt,
187
+ verifyJwt,
188
+ decodeJwt,
221
189
 
222
190
  // Migration helpers
223
191
  createRememberTokensTable,
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Webspresso Auth - Zero-dependency JWT implementation
3
+ * Uses Node.js native crypto module for HS256 HMAC-SHA256 signatures
4
+ * @module core/auth/jwt
5
+ */
6
+
7
+ const crypto = require('crypto');
8
+
9
+ /**
10
+ * Base64URL encode string or Buffer
11
+ * @param {string|Buffer} input
12
+ * @returns {string}
13
+ */
14
+ function base64UrlEncode(input) {
15
+ const buf = Buffer.isBuffer(input) ? input : Buffer.from(input, 'utf8');
16
+ return buf
17
+ .toString('base64')
18
+ .replace(/=/g, '')
19
+ .replace(/\+/g, '-')
20
+ .replace(/\//g, '_');
21
+ }
22
+
23
+ /**
24
+ * Base64URL decode string
25
+ * @param {string} input
26
+ * @returns {string}
27
+ */
28
+ function base64UrlDecode(input) {
29
+ let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
30
+ while (base64.length % 4 !== 0) {
31
+ base64 += '=';
32
+ }
33
+ return Buffer.from(base64, 'base64').toString('utf8');
34
+ }
35
+
36
+ /**
37
+ * Parse duration string (e.g. '1h', '7d', '15m', '3600') into seconds
38
+ * @param {string|number} duration
39
+ * @returns {number} Seconds
40
+ */
41
+ function parseDuration(duration) {
42
+ if (typeof duration === 'number') {
43
+ return duration;
44
+ }
45
+ if (typeof duration !== 'string') {
46
+ return 3600; // Default 1 hour
47
+ }
48
+
49
+ const match = duration.trim().match(/^(\d+)\s*([smhd])?$/i);
50
+ if (!match) {
51
+ const parsed = parseInt(duration, 10);
52
+ return isNaN(parsed) ? 3600 : parsed;
53
+ }
54
+
55
+ const value = parseInt(match[1], 10);
56
+ const unit = (match[2] || 's').toLowerCase();
57
+
58
+ switch (unit) {
59
+ case 's': return value;
60
+ case 'm': return value * 60;
61
+ case 'h': return value * 3600;
62
+ case 'd': return value * 86400;
63
+ default: return value;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Sign payload to create HS256 JWT string
69
+ * @param {Object} payload - Payload object
70
+ * @param {string} secret - Secret key for signing
71
+ * @param {Object} [options] - Signing options
72
+ * @param {string|number} [options.expiresIn='24h'] - Expiration time (seconds or '1h', '7d', etc.)
73
+ * @param {string} [options.algorithm='HS256'] - Signature algorithm (HS256 supported)
74
+ * @param {string} [options.issuer] - Token issuer (iss)
75
+ * @param {string} [options.subject] - Token subject (sub)
76
+ * @param {string} [options.audience] - Token audience (aud)
77
+ * @returns {string} JWT string
78
+ */
79
+ function signJwt(payload, secret, options = {}) {
80
+ if (!secret) {
81
+ throw new Error('Secret is required to sign JWT');
82
+ }
83
+
84
+ const algorithm = options.algorithm || 'HS256';
85
+ if (algorithm !== 'HS256') {
86
+ throw new Error(`Unsupported algorithm '${algorithm}'. Only HS256 is supported.`);
87
+ }
88
+
89
+ const header = {
90
+ alg: algorithm,
91
+ typ: 'JWT',
92
+ };
93
+
94
+ const nowSec = Math.floor(Date.now() / 1000);
95
+ const claims = {
96
+ iat: nowSec,
97
+ };
98
+
99
+ if (options.expiresIn !== false) {
100
+ const ttlSec = parseDuration(options.expiresIn ?? '24h');
101
+ claims.exp = nowSec + ttlSec;
102
+ }
103
+
104
+ if (options.issuer) claims.iss = options.issuer;
105
+ if (options.subject) claims.sub = options.subject;
106
+ if (options.audience) claims.aud = options.audience;
107
+
108
+ const fullPayload = {
109
+ ...claims,
110
+ ...payload,
111
+ };
112
+
113
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
114
+ const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
115
+
116
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
117
+ const signature = crypto
118
+ .createHmac('sha256', secret)
119
+ .update(signingInput)
120
+ .digest();
121
+
122
+ const encodedSignature = base64UrlEncode(signature);
123
+
124
+ return `${signingInput}.${encodedSignature}`;
125
+ }
126
+
127
+ /**
128
+ * Verify and decode HS256 JWT string
129
+ * @param {string} token - JWT string
130
+ * @param {string} secret - Secret key
131
+ * @param {Object} [options] - Verification options
132
+ * @param {boolean} [options.ignoreExpiration=false] - Ignore token expiration
133
+ * @param {string} [options.issuer] - Expected issuer
134
+ * @param {string} [options.audience] - Expected audience
135
+ * @returns {Object} Decoded payload
136
+ * @throws {Error} If token is invalid or expired
137
+ */
138
+ function verifyJwt(token, secret, options = {}) {
139
+ if (!secret) {
140
+ throw new Error('Secret is required to verify JWT');
141
+ }
142
+
143
+ if (!token || typeof token !== 'string') {
144
+ throw new Error('JWT token must be a non-empty string');
145
+ }
146
+
147
+ const parts = token.split('.');
148
+ if (parts.length !== 3) {
149
+ throw new Error('Invalid JWT format');
150
+ }
151
+
152
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
153
+
154
+ let header;
155
+ let payload;
156
+
157
+ try {
158
+ header = JSON.parse(base64UrlDecode(encodedHeader));
159
+ } catch (e) {
160
+ throw new Error('Invalid JWT header JSON');
161
+ }
162
+
163
+ try {
164
+ payload = JSON.parse(base64UrlDecode(encodedPayload));
165
+ } catch (e) {
166
+ throw new Error('Invalid JWT payload JSON');
167
+ }
168
+
169
+ if (header.alg !== 'HS256') {
170
+ throw new Error(`Unsupported algorithm '${header.alg}' in JWT header`);
171
+ }
172
+
173
+ // Re-compute signature
174
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
175
+ const expectedSignatureBuf = crypto
176
+ .createHmac('sha256', secret)
177
+ .update(signingInput)
178
+ .digest();
179
+
180
+ const actualSignatureBuf = Buffer.from(encodedSignature.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
181
+
182
+ if (
183
+ expectedSignatureBuf.length !== actualSignatureBuf.length ||
184
+ !crypto.timingSafeEqual(expectedSignatureBuf, actualSignatureBuf)
185
+ ) {
186
+ throw new Error('Invalid JWT signature');
187
+ }
188
+
189
+ // Check claims
190
+ const nowSec = Math.floor(Date.now() / 1000);
191
+
192
+ if (!options.ignoreExpiration && typeof payload.exp === 'number') {
193
+ if (nowSec >= payload.exp) {
194
+ throw new Error('JWT token has expired');
195
+ }
196
+ }
197
+
198
+ if (options.issuer && payload.iss !== options.issuer) {
199
+ throw new Error(`Invalid JWT issuer (expected ${options.issuer}, got ${payload.iss})`);
200
+ }
201
+
202
+ if (options.audience && payload.aud !== options.audience) {
203
+ throw new Error(`Invalid JWT audience (expected ${options.audience}, got ${payload.aud})`);
204
+ }
205
+
206
+ return payload;
207
+ }
208
+
209
+ /**
210
+ * Decode JWT without verifying signature
211
+ * @param {string} token
212
+ * @returns {{ header: Object, payload: Object }|null}
213
+ */
214
+ function decodeJwt(token) {
215
+ if (!token || typeof token !== 'string') return null;
216
+ const parts = token.split('.');
217
+ if (parts.length !== 3) return null;
218
+
219
+ try {
220
+ const header = JSON.parse(base64UrlDecode(parts[0]));
221
+ const payload = JSON.parse(base64UrlDecode(parts[1]));
222
+ return { header, payload };
223
+ } catch (e) {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ module.exports = {
229
+ signJwt,
230
+ verifyJwt,
231
+ decodeJwt,
232
+ base64UrlEncode,
233
+ base64UrlDecode,
234
+ parseDuration,
235
+ };
@@ -6,6 +6,7 @@
6
6
 
7
7
  const { generateToken, hashToken } = require('./hash');
8
8
  const { PolicyManager } = require('./policy');
9
+ const { signJwt, verifyJwt, decodeJwt } = require('./jwt');
9
10
  const {
10
11
  TOKEN_TYPES,
11
12
  createAuthToken,
@@ -47,6 +48,13 @@ const DEFAULT_CONFIG = {
47
48
  cookieName: 'remember_token',
48
49
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
49
50
  },
51
+ jwt: {
52
+ enabled: false,
53
+ secret: null,
54
+ refreshSecret: null,
55
+ expiresIn: '24h',
56
+ refreshExpiresIn: '30d',
57
+ },
50
58
  routes: {
51
59
  login: '/login',
52
60
  logout: '/logout',
@@ -98,6 +106,30 @@ class AuthManager {
98
106
  emailVerification: config.notifications?.emailVerification || null,
99
107
  welcome: config.notifications?.welcome || null,
100
108
  };
109
+
110
+ if (config.jwt) {
111
+ const jwtSecret = typeof config.jwt === 'object' && config.jwt.secret
112
+ ? config.jwt.secret
113
+ : (config.session?.secret || process.env.JWT_SECRET || process.env.AUTH_SESSION_SECRET || 'webspresso-jwt-secret');
114
+ const refreshSecret = typeof config.jwt === 'object' && config.jwt.refreshSecret
115
+ ? config.jwt.refreshSecret
116
+ : (process.env.JWT_REFRESH_SECRET || jwtSecret);
117
+ const expiresIn = typeof config.jwt === 'object' && config.jwt.expiresIn
118
+ ? config.jwt.expiresIn
119
+ : '24h';
120
+ const refreshExpiresIn = typeof config.jwt === 'object' && config.jwt.refreshExpiresIn
121
+ ? config.jwt.refreshExpiresIn
122
+ : '30d';
123
+ this.jwt = {
124
+ enabled: true,
125
+ secret: jwtSecret,
126
+ refreshSecret,
127
+ expiresIn,
128
+ refreshExpiresIn,
129
+ };
130
+ } else {
131
+ this.jwt = null;
132
+ }
101
133
 
102
134
  // Policy manager instance
103
135
  this.policies = new PolicyManager();
@@ -144,6 +176,8 @@ class AuthManager {
144
176
  }
145
177
 
146
178
  const mergedCookie = {
179
+ httpOnly: true,
180
+ sameSite: 'lax',
147
181
  ...DEFAULT_CONFIG.session.cookie,
148
182
  ...(config.cookie || {}),
149
183
  };
@@ -170,6 +204,42 @@ class AuthManager {
170
204
  const self = this;
171
205
 
172
206
  return {
207
+ manager: self,
208
+
209
+ /**
210
+ * Generate JWT access token for user
211
+ */
212
+ generateUserToken(user, options) {
213
+ return self.generateUserToken(user, options);
214
+ },
215
+
216
+ /**
217
+ * Generate JWT refresh token for user
218
+ */
219
+ generateRefreshToken(user, options) {
220
+ return self.generateRefreshToken(user, options);
221
+ },
222
+
223
+ /**
224
+ * Refresh access token using refresh token
225
+ */
226
+ async refreshAccessToken(refreshToken, options) {
227
+ return await self.refreshAccessToken(refreshToken, options);
228
+ },
229
+
230
+ /**
231
+ * Create JWT payload
232
+ */
233
+ createJwt(payload, options) {
234
+ return self.createJwt(payload, options);
235
+ },
236
+
237
+ /**
238
+ * Verify JWT token
239
+ */
240
+ verifyJwt(token, options) {
241
+ return self.verifyJwt(token, options);
242
+ },
173
243
  /**
174
244
  * Attempt login with credentials
175
245
  * @param {string} identifier - Email or username
@@ -598,6 +668,129 @@ class AuthManager {
598
668
  await this.notifications.welcome(user, {});
599
669
  }
600
670
  }
671
+
672
+ /**
673
+ * Create JWT token
674
+ * @param {Object} payload
675
+ * @param {Object} [options]
676
+ * @returns {string} JWT string
677
+ */
678
+ createJwt(payload, options = {}) {
679
+ const secret = options.secret || this.jwt?.secret || this.config.session?.secret || process.env.JWT_SECRET;
680
+ if (!secret) {
681
+ throw new Error('JWT secret is missing');
682
+ }
683
+ const expiresIn = options.expiresIn || this.jwt?.expiresIn || '24h';
684
+ return signJwt(payload, secret, { expiresIn, ...options });
685
+ }
686
+
687
+ /**
688
+ * Verify JWT token
689
+ * @param {string} token
690
+ * @param {Object} [options]
691
+ * @returns {Object} Decoded payload
692
+ */
693
+ verifyJwt(token, options = {}) {
694
+ const secret = options.secret || this.jwt?.secret || this.config.session?.secret || process.env.JWT_SECRET;
695
+ if (!secret) {
696
+ throw new Error('JWT secret is missing');
697
+ }
698
+ const payload = verifyJwt(token, secret, options);
699
+ if (options.isAccess !== false && payload.type === 'refresh') {
700
+ throw new Error('Refresh token cannot be used as an access token');
701
+ }
702
+ return payload;
703
+ }
704
+
705
+ /**
706
+ * Generate JWT token for user
707
+ * @param {Object} user
708
+ * @param {Object} [options]
709
+ * @returns {string} JWT string
710
+ */
711
+ generateUserToken(user, options = {}) {
712
+ if (!user || !user.id) {
713
+ throw new Error('User object with id is required');
714
+ }
715
+ const payload = {
716
+ id: user.id,
717
+ type: 'access',
718
+ [this.identifierField]: user[this.identifierField],
719
+ ...options.payload,
720
+ };
721
+ return this.createJwt(payload, options);
722
+ }
723
+
724
+ /**
725
+ * Generate JWT Refresh token for user
726
+ * @param {Object} user
727
+ * @param {Object} [options]
728
+ * @returns {string} Refresh token string
729
+ */
730
+ generateRefreshToken(user, options = {}) {
731
+ if (!user || !user.id) {
732
+ throw new Error('User object with id is required');
733
+ }
734
+ const secret = options.secret || this.jwt?.refreshSecret || this.jwt?.secret || this.config.session?.secret || process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET;
735
+ if (!secret) {
736
+ throw new Error('JWT refresh secret is missing');
737
+ }
738
+ const expiresIn = options.expiresIn || this.jwt?.refreshExpiresIn || '30d';
739
+ const payload = {
740
+ id: user.id,
741
+ type: 'refresh',
742
+ jti: generateToken(16),
743
+ [this.identifierField]: user[this.identifierField],
744
+ ...options.payload,
745
+ };
746
+ return signJwt(payload, secret, { expiresIn, ...options });
747
+ }
748
+
749
+ /**
750
+ * Refresh access token using valid refresh token
751
+ * @param {string} refreshToken
752
+ * @param {Object} [options]
753
+ * @returns {Promise<{ accessToken: string, refreshToken: string, user: Object }>}
754
+ */
755
+ async refreshAccessToken(refreshToken, options = {}) {
756
+ const secret = options.secret || this.jwt?.refreshSecret || this.jwt?.secret || this.config.session?.secret || process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET;
757
+ if (!secret) {
758
+ throw new Error('JWT refresh secret is missing');
759
+ }
760
+
761
+ const payload = verifyJwt(refreshToken, secret, options);
762
+ if (payload.type !== 'refresh') {
763
+ throw new AuthenticationError('Invalid refresh token type', 'INVALID_REFRESH_TOKEN');
764
+ }
765
+
766
+ const user = await this.findUserById(payload.id);
767
+ if (!user) {
768
+ throw new AuthenticationError('User not found for refresh token', 'USER_NOT_FOUND');
769
+ }
770
+
771
+ const accessToken = this.generateUserToken(user, options.accessOptions);
772
+ const rotate = options.rotate ?? true;
773
+ const newRefreshToken = rotate ? this.generateRefreshToken(user, options.refreshOptions) : refreshToken;
774
+
775
+ return {
776
+ accessToken,
777
+ refreshToken: newRefreshToken,
778
+ user,
779
+ };
780
+ }
781
+
782
+ /**
783
+ * Get requireJwt guard middleware for Express routes
784
+ * @param {Object} [options]
785
+ * @returns {Function} Express middleware
786
+ */
787
+ requireJwt(options = {}) {
788
+ if (!this._authMiddleware) {
789
+ const { createAuthMiddleware } = require('./middleware');
790
+ this._authMiddleware = createAuthMiddleware(this);
791
+ }
792
+ return this._authMiddleware.requireJwt(options);
793
+ }
601
794
  }
602
795
 
603
796
  module.exports = {
@@ -28,7 +28,7 @@ function createAuthMiddleware(authManager) {
28
28
 
29
29
  /**
30
30
  * Authenticate middleware
31
- * Loads user from session or remember me token
31
+ * Loads user from Bearer JWT header, session, or remember me token
32
32
  * Should run on every request
33
33
  */
34
34
  async function authenticate(req, res, next) {
@@ -39,8 +39,27 @@ function createAuthMiddleware(authManager) {
39
39
  // Create request-bound auth helper
40
40
  req.auth = authManager.createRequestAuth(req, res);
41
41
 
42
- // Try to load user from session
43
- if (req.session?.userId) {
42
+ // Check Bearer JWT token header first
43
+ const authHeader = req.headers?.authorization || req.headers?.Authorization;
44
+ if (authHeader && authHeader.startsWith('Bearer ')) {
45
+ const token = authHeader.substring(7).trim();
46
+ try {
47
+ const payload = authManager.verifyJwt(token);
48
+ req.token = token;
49
+ req.jwtPayload = payload;
50
+ if (payload.id) {
51
+ const user = await authManager.findUserById(payload.id);
52
+ req.user = user || payload;
53
+ } else {
54
+ req.user = payload;
55
+ }
56
+ } catch (jwtErr) {
57
+ // Invalid or expired JWT token header
58
+ }
59
+ }
60
+
61
+ // Try to load user from session if not authenticated by JWT
62
+ if (!req.user && req.session?.userId) {
44
63
  const user = await authManager.findUserById(req.session.userId);
45
64
  if (user) {
46
65
  req.user = user;
@@ -50,7 +69,7 @@ function createAuthMiddleware(authManager) {
50
69
  }
51
70
  }
52
71
 
53
- // If no user in session, try remember me token
72
+ // If no user in session or JWT, try remember me token
54
73
  if (!req.user && authManager.rememberTokens) {
55
74
  const user = await authManager.verifyRememberToken(req, res);
56
75
  if (user) {
@@ -96,6 +115,44 @@ function createAuthMiddleware(authManager) {
96
115
  };
97
116
  }
98
117
 
118
+ /**
119
+ * Require JWT middleware
120
+ * Enforces valid Bearer JWT token in Authorization header
121
+ * @param {Object} [options] - Verification options
122
+ */
123
+ function requireJwt(options = {}) {
124
+ return async (req, res, next) => {
125
+ const authHeader = req.headers?.authorization || req.headers?.Authorization;
126
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
127
+ return res.status(401).json({
128
+ error: 'Unauthorized',
129
+ message: 'Bearer token required',
130
+ });
131
+ }
132
+
133
+ const token = authHeader.substring(7).trim();
134
+ try {
135
+ const payload = authManager.verifyJwt(token, options);
136
+ req.token = token;
137
+ req.jwtPayload = payload;
138
+
139
+ if (payload.id) {
140
+ const user = await authManager.findUserById(payload.id);
141
+ req.user = user || payload;
142
+ } else {
143
+ req.user = payload;
144
+ }
145
+
146
+ next();
147
+ } catch (err) {
148
+ return res.status(401).json({
149
+ error: 'Unauthorized',
150
+ message: err.message || 'Invalid JWT token',
151
+ });
152
+ }
153
+ };
154
+ }
155
+
99
156
  /**
100
157
  * Require guest middleware
101
158
  * Redirects authenticated users away
@@ -192,6 +249,9 @@ function createAuthMiddleware(authManager) {
192
249
  case 'auth':
193
250
  return requireAuth({ api: args.includes('api') });
194
251
 
252
+ case 'jwt':
253
+ return requireJwt();
254
+
195
255
  case 'guest':
196
256
  return requireGuest();
197
257
 
@@ -216,6 +276,7 @@ function createAuthMiddleware(authManager) {
216
276
 
217
277
  // Guard middleware
218
278
  requireAuth,
279
+ requireJwt,
219
280
  requireGuest,
220
281
  requireCan,
221
282
  requireVerified,
@@ -225,6 +286,7 @@ function createAuthMiddleware(authManager) {
225
286
 
226
287
  // Aliases for route config (factories — file-router calls requireAuth(opts) / requireGuest(opts))
227
288
  auth: requireAuth,
289
+ jwt: requireJwt,
228
290
  guest: requireGuest,
229
291
  };
230
292
  }
package/index.d.ts CHANGED
@@ -567,6 +567,28 @@ export function swaggerPlugin(options?: Record<string, unknown>): WebspressoPlug
567
567
 
568
568
  export function healthCheckPlugin(options?: Record<string, unknown>): WebspressoPlugin;
569
569
 
570
+ export interface CorsPluginOptions {
571
+ origin?:
572
+ | string
573
+ | string[]
574
+ | RegExp
575
+ | ((
576
+ origin: string,
577
+ callback: (err: Error | null, allow?: boolean | string) => void
578
+ ) => void);
579
+ methods?: string | string[];
580
+ allowedHeaders?: string | string[];
581
+ exposedHeaders?: string | string[];
582
+ credentials?: boolean;
583
+ maxAge?: number;
584
+ preflightContinue?: boolean;
585
+ optionsSuccessStatus?: number;
586
+ routes?: string | string[] | boolean;
587
+ global?: boolean;
588
+ }
589
+
590
+ export function corsPlugin(options?: CorsPluginOptions): WebspressoPlugin;
591
+
570
592
  export interface RedirectRule {
571
593
  from: string | RegExp;
572
594
  to: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.84",
3
+ "version": "0.0.85",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -72,7 +72,7 @@
72
72
  "nodemailer": "^9.0.3",
73
73
  "nunjucks": "^3.2.4",
74
74
  "sanitize-html": "^2.17.3",
75
- "sharp": "^0.33.5",
75
+ "sharp": "^0.35.3",
76
76
  "swup": "^4.8.3",
77
77
  "yaml": "^2.8.3",
78
78
  "zod": "^3.23.0",
@@ -116,7 +116,7 @@
116
116
  "chokidar": "^3.5.3",
117
117
  "dotenv": "^16.3.1",
118
118
  "express-rate-limit": "^8.4.1",
119
- "release-it": "^19.0.0",
119
+ "release-it": "^20.2.1",
120
120
  "supertest": "^6.3.4",
121
121
  "typescript": "~5.6.3",
122
122
  "vitest": "^3.0.0"
@@ -71,10 +71,15 @@ function adminPanelPlugin(options = {}) {
71
71
  version: '2.0.0',
72
72
  description: 'Modular admin panel for Webspresso with extensions support',
73
73
 
74
- // CSP requirements for admin panel scripts
74
+ // CSP requirements for admin panel scripts, styles, and custom page iframe sources
75
75
  // Note: cdn.quilljs.com 301-redirects to cdn.jsdelivr.net; CSP is enforced on the final URL.
76
76
  csp: {
77
- styleSrc: ['https://cdn.quilljs.com', 'https://cdn.jsdelivr.net'],
77
+ styleSrc: [
78
+ 'https://cdn.quilljs.com',
79
+ 'https://cdn.jsdelivr.net',
80
+ 'https://unpkg.com',
81
+ 'https://cdn.tailwindcss.com',
82
+ ],
78
83
  scriptSrc: [
79
84
  'https://cdn.quilljs.com',
80
85
  'https://cdn.jsdelivr.net',
@@ -82,6 +87,7 @@ function adminPanelPlugin(options = {}) {
82
87
  'https://cdn.tailwindcss.com',
83
88
  ],
84
89
  connectSrc: ['https://unpkg.com', 'https://cdn.tailwindcss.com', 'https://cdn.jsdelivr.net'],
90
+ frameSrc: ["'self'", 'data:', 'blob:', 'https:'],
85
91
  },
86
92
  enabled,
87
93
  registry, // Expose registry for external configuration
@@ -281,7 +287,7 @@ function adminPanelPlugin(options = {}) {
281
287
 
282
288
  // Custom pages API routes
283
289
  ctx.addRoute('get', `${adminPath}/api/extensions/pages/:pageId/data`, requireAuth, pageHandlers.getPageData);
284
- ctx.addRoute('post', `${adminPath}/api/extensions/pages/:pageId/actions/:actionId`, requireAuth, pageHandlers.executePageAction);
290
+ ctx.addRoute('all', `${adminPath}/api/extensions/pages/:pageId/actions/:actionId`, requireAuth, pageHandlers.executePageAction);
285
291
 
286
292
  // User management API routes
287
293
  if (userHandlers) {
@@ -128,7 +128,15 @@ function createCustomPageApiHandlers(options) {
128
128
  return res.status(404).json({ error: 'Action not found' });
129
129
  }
130
130
 
131
- const result = await action({ db, req, body: req.body, user: req.session?.adminUser });
131
+ const payload = { ...(req.query || {}), ...(req.body || {}) };
132
+ const result = await action({
133
+ db,
134
+ req,
135
+ body: payload,
136
+ payload,
137
+ query: req.query || {},
138
+ user: req.session?.adminUser,
139
+ });
132
140
  res.json({ success: true, result });
133
141
  } catch (error) {
134
142
  res.status(500).json({ error: error.message });
@@ -243,6 +243,38 @@
243
243
  });
244
244
  }
245
245
 
246
+ function sanitizeClientHtml(htmlStr) {
247
+ if (!htmlStr) return '';
248
+ try {
249
+ var parser = new DOMParser();
250
+ var doc = parser.parseFromString(htmlStr, 'text/html');
251
+ var forbiddenTags = ['script', 'iframe', 'object', 'embed', 'applet', 'base'];
252
+ forbiddenTags.forEach(function (tag) {
253
+ var nodes = doc.querySelectorAll(tag);
254
+ for (var i = 0; i < nodes.length; i++) {
255
+ nodes[i].parentNode.removeChild(nodes[i]);
256
+ }
257
+ });
258
+ var allNodes = doc.querySelectorAll('*');
259
+ for (var i = 0; i < allNodes.length; i++) {
260
+ var node = allNodes[i];
261
+ var attrs = Array.prototype.slice.call(node.attributes);
262
+ for (var j = 0; j < attrs.length; j++) {
263
+ var attrName = attrs[j].name.toLowerCase();
264
+ var attrVal = attrs[j].value.toLowerCase().trim();
265
+ if (attrName.indexOf('on') === 0) {
266
+ node.removeAttribute(attrs[j].name);
267
+ } else if ((attrName === 'href' || attrName === 'src') && attrVal.indexOf('javascript:') === 0) {
268
+ node.removeAttribute(attrs[j].name);
269
+ }
270
+ }
271
+ }
272
+ return doc.body.innerHTML;
273
+ } catch (e) {
274
+ return '';
275
+ }
276
+ }
277
+
246
278
  function updateDomFields(entryId, data) {
247
279
  qsa('[data-ws-content-entry="' + entryId + '"][data-ws-content-field]').forEach(function (el) {
248
280
  var field = el.getAttribute('data-ws-content-field');
@@ -250,7 +282,7 @@
250
282
  var val = data[field];
251
283
  var isHtml = el.getAttribute('data-ws-content-html') === 'true';
252
284
  if (isHtml) {
253
- el.innerHTML = val || '';
285
+ el.innerHTML = sanitizeClientHtml(val);
254
286
  } else {
255
287
  el.textContent = val != null ? val : '';
256
288
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Webspresso CORS Plugin
3
+ * Zero-dependency CORS middleware & preflight handler
4
+ * @module plugins/cors
5
+ */
6
+
7
+ /**
8
+ * Configure CORS middleware
9
+ * @param {Object} [options] - CORS configuration
10
+ * @param {string|string[]|RegExp|Function} [options.origin='*'] - Allowed origin(s)
11
+ * @param {string|string[]} [options.methods] - Allowed HTTP methods
12
+ * @param {string|string[]} [options.allowedHeaders] - Allowed request headers
13
+ * @param {string|string[]} [options.exposedHeaders] - Headers exposed to response
14
+ * @param {boolean} [options.credentials=false] - Allow credentials (cookies/authorization headers)
15
+ * @param {number} [options.maxAge=86400] - Access-Control-Max-Age header in seconds
16
+ * @param {boolean} [options.preflightContinue=false] - Pass OPTIONS to next handler
17
+ * @param {number} [options.optionsSuccessStatus=204] - OPTIONS preflight success status
18
+ * @param {string|string[]|boolean} [options.routes=true] - Route prefixes or true for all
19
+ * @returns {Function} Express middleware handler
20
+ */
21
+ function createCorsMiddleware(options = {}) {
22
+ const defaults = {
23
+ origin: '*',
24
+ methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
25
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'],
26
+ exposedHeaders: [],
27
+ credentials: false,
28
+ maxAge: 86400,
29
+ preflightContinue: false,
30
+ optionsSuccessStatus: 204,
31
+ routes: true,
32
+ };
33
+
34
+ const config = { ...defaults, ...options };
35
+
36
+ const methodsStr = Array.isArray(config.methods)
37
+ ? config.methods.join(',')
38
+ : String(config.methods);
39
+
40
+ const allowedHeadersStr = Array.isArray(config.allowedHeaders)
41
+ ? config.allowedHeaders.join(',')
42
+ : String(config.allowedHeaders);
43
+
44
+ const exposedHeadersStr = Array.isArray(config.exposedHeaders)
45
+ ? config.exposedHeaders.join(',')
46
+ : String(config.exposedHeaders);
47
+
48
+ function isOriginAllowed(reqOrigin, callback) {
49
+ if (!config.origin || config.origin === '*') {
50
+ return callback(null, config.origin);
51
+ }
52
+ if (!reqOrigin) {
53
+ return callback(null, false);
54
+ }
55
+ if (typeof config.origin === 'string') {
56
+ return callback(null, config.origin === reqOrigin ? reqOrigin : false);
57
+ }
58
+ if (Array.isArray(config.origin)) {
59
+ for (const item of config.origin) {
60
+ if (typeof item === 'string' && item === reqOrigin) {
61
+ return callback(null, reqOrigin);
62
+ }
63
+ if (item instanceof RegExp && item.test(reqOrigin)) {
64
+ return callback(null, reqOrigin);
65
+ }
66
+ }
67
+ return callback(null, false);
68
+ }
69
+ if (config.origin instanceof RegExp) {
70
+ return callback(null, config.origin.test(reqOrigin) ? reqOrigin : false);
71
+ }
72
+ if (typeof config.origin === 'function') {
73
+ return config.origin(reqOrigin, callback);
74
+ }
75
+ return callback(null, false);
76
+ }
77
+
78
+ function shouldApplyToRoute(reqPath) {
79
+ if (config.routes === true || !config.routes) return true;
80
+ const routesList = Array.isArray(config.routes) ? config.routes : [config.routes];
81
+ return routesList.some((prefix) => reqPath.startsWith(prefix));
82
+ }
83
+
84
+ return (req, res, next) => {
85
+ const reqPath = req.path || req.url || '/';
86
+
87
+ if (!shouldApplyToRoute(reqPath)) {
88
+ return next();
89
+ }
90
+
91
+ const reqOrigin = req.headers.origin;
92
+
93
+ isOriginAllowed(reqOrigin, (err, allowedOrigin) => {
94
+ if (err) {
95
+ return next(err);
96
+ }
97
+
98
+ if (allowedOrigin) {
99
+ if (allowedOrigin === '*' && config.credentials) {
100
+ res.setHeader('Access-Control-Allow-Origin', reqOrigin || '*');
101
+ } else {
102
+ res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
103
+ }
104
+ res.setHeader('Vary', 'Origin');
105
+ }
106
+
107
+ if (config.credentials) {
108
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
109
+ }
110
+
111
+ if (exposedHeadersStr) {
112
+ res.setHeader('Access-Control-Expose-Headers', exposedHeadersStr);
113
+ }
114
+
115
+ // Preflight OPTIONS request
116
+ if (req.method === 'OPTIONS') {
117
+ if (allowedOrigin) {
118
+ if (methodsStr) {
119
+ res.setHeader('Access-Control-Allow-Methods', methodsStr);
120
+ }
121
+ if (allowedHeadersStr) {
122
+ res.setHeader('Access-Control-Allow-Headers', allowedHeadersStr);
123
+ } else if (req.headers['access-control-request-headers']) {
124
+ res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
125
+ }
126
+ if (config.maxAge) {
127
+ res.setHeader('Access-Control-Max-Age', String(config.maxAge));
128
+ }
129
+ }
130
+
131
+ if (config.preflightContinue) {
132
+ return next();
133
+ }
134
+
135
+ res.statusCode = config.optionsSuccessStatus;
136
+ res.setHeader('Content-Length', '0');
137
+ return res.end();
138
+ }
139
+
140
+ next();
141
+ });
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Webspresso CORS Plugin Factory
147
+ * @param {Object} [options] - CORS options
148
+ * @returns {Object} Plugin definition
149
+ */
150
+ function corsPlugin(options = {}) {
151
+ return {
152
+ name: 'cors',
153
+ version: '1.0.0',
154
+ options,
155
+ register(ctx) {
156
+ const corsMiddleware = createCorsMiddleware(options);
157
+
158
+ if (ctx.middlewares) {
159
+ ctx.middlewares.cors = corsMiddleware;
160
+ }
161
+
162
+ if (ctx.app && options.global !== false) {
163
+ ctx.app.use(corsMiddleware);
164
+ }
165
+ },
166
+ };
167
+ }
168
+
169
+ corsPlugin.createCorsMiddleware = createCorsMiddleware;
170
+
171
+ module.exports = corsPlugin;
@@ -150,7 +150,14 @@ function csrfPlugin(options = {}) {
150
150
  secret = cookieOptions.signed ? req.signedCookies[cookieKey] : req.cookies[cookieKey];
151
151
  if (!secret) {
152
152
  secret = crypto.randomBytes(32).toString('hex');
153
- res.cookie(cookieKey, secret, cookieOptions);
153
+ const isHttps = req.secure || req.headers?.['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true';
154
+ const effectiveCookieOptions = {
155
+ httpOnly: true,
156
+ sameSite: 'lax',
157
+ ...cookieOptions,
158
+ secure: cookieOpts.secure !== undefined ? cookieOpts.secure : isHttps,
159
+ };
160
+ res.cookie(cookieKey, secret, effectiveCookieOptions);
154
161
  }
155
162
  } else {
156
163
  // Session based
package/plugins/index.js CHANGED
@@ -24,6 +24,7 @@ const { rateLimitPlugin } = require('./rate-limit');
24
24
  const emailPlugin = require('./email');
25
25
  const contentPlugin = require('./content');
26
26
  const csrfPlugin = require('./csrf');
27
+ const corsPlugin = require('./cors');
27
28
 
28
29
  module.exports = {
29
30
  sitemapPlugin,
@@ -47,5 +48,6 @@ module.exports = {
47
48
  emailPlugin,
48
49
  contentPlugin,
49
50
  csrfPlugin,
51
+ corsPlugin,
50
52
  };
51
53
 
package/src/server.js CHANGED
@@ -349,8 +349,9 @@ function createApp(options = {}) {
349
349
  if (finalConfig.contentSecurityPolicy && finalConfig.contentSecurityPolicy.directives) {
350
350
  const directives = finalConfig.contentSecurityPolicy.directives;
351
351
  for (const [directive, sources] of Object.entries(pluginCspSources)) {
352
- if (sources.size > 0 && directives[directive]) {
353
- directives[directive] = [...directives[directive], ...Array.from(sources)];
352
+ if (sources.size > 0) {
353
+ const existing = (directives[directive] || []).filter(s => s !== "'none'");
354
+ directives[directive] = [...existing, ...Array.from(sources)];
354
355
  }
355
356
  }
356
357
  }
@@ -385,6 +386,7 @@ function createApp(options = {}) {
385
386
  // Add auth middleware to named middlewares for route config
386
387
  middlewares.auth = authMiddleware.auth;
387
388
  middlewares.guest = authMiddleware.guest;
389
+ middlewares.jwt = authMiddleware.jwt;
388
390
  }
389
391
 
390
392
  // Under Vitest, shared API fixtures use `fixtureRequireAuth`; default no-op unless overridden.
@@ -72,19 +72,27 @@ project/
72
72
  - **CSP / Helmet:** production **`script-src 'self'`** works for **`/__webspresso/client-runtime/`**; some Alpine builds may need **`unsafe-eval`** — validate for your version or use a stricter build.
73
73
  - **Longer doc:** **[`doc/index.html#client-runtime`](../../../doc/index.html#client-runtime)** · README **Client runtime**.
74
74
 
75
- ### Session authentication — essentials
76
-
77
- - **Import:** `const { createAuth, quickAuth, hash, verify, createRememberTokensTable } = require('webspresso/core/auth')` (published `core/` tree on npm; **not** re-exported from package root).
78
- - **`createAuth({ findUserById, findUserByCredentials, session: { secret }, rememberTokens?, ... })`** — adapter pattern; optional **remember-me** via `rememberTokens: { create, find, delete, deleteAllForUser }` + **`createRememberTokensTable(knex)`** for the default table shape.
79
- - **`quickAuth({ db, userModel, identifierField, passwordField, rememberMe })`** — wires **`getRepository`** + bcrypt **`verify`**; optional Knex **`remember_tokens`** when `rememberMe: true`.
75
+ ### Dual authentication (Session + JWT & Refresh Tokens) — essentials
76
+
77
+ - **Import:** `const { createAuth, quickAuth, hash, verify, signJwt, verifyJwt, decodeJwt, createRememberTokensTable } = require('webspresso/core/auth')` (published `core/` tree on npm; **not** re-exported from package root).
78
+ - **Dual Architecture:**
79
+ - **Session + Cookie (Stateful):** Primary for browser SSR routes and Admin Panel (`/`, `/_admin`). Uses `httpOnly` secure cookies.
80
+ - **JWT + Refresh Token (Stateless):** Built-in zero-dependency HS256 auth for REST APIs, Mobile Apps, and SPAs (`/api/*`). Uses `Authorization: Bearer <token>` for access tokens and rotatable Refresh Tokens.
81
+ - **`createAuth({ findUserById, findUserByCredentials, session: { secret }, jwt: { secret, refreshSecret, expiresIn, refreshExpiresIn }, rememberTokens?, ... })`** — adapter pattern; optional **remember-me** via `rememberTokens` and opt-in **JWT / Refresh Tokens**.
82
+ - **`quickAuth({ db, userModel, identifierField, passwordField, rememberMe, jwt })`** — wires **`getRepository`** + bcrypt **`verify`**; optional Knex **`remember_tokens`** and **JWT / Refresh Token** manager (`jwt: true` or `jwt: { expiresIn: '15m', refreshExpiresIn: '30d' }`).
83
+ - **JWT Helper Methods:**
84
+ - **`auth.generateUserToken(user, options)`** — generates short-lived access JWT (`type: 'access'`).
85
+ - **`auth.generateRefreshToken(user, options)`** — generates long-lived refresh JWT (`type: 'refresh'`).
86
+ - **`auth.refreshAccessToken(refreshToken, options)`** — verifies refresh token and issues new access token + rotated refresh token.
87
+ - **`auth.requireJwt()`** — guard middleware enforcing valid `Bearer` access token.
80
88
  - **Request API** (after global authenticate): **`req.auth.attempt(id, password, { remember })`**, **`login`**, **`logout`**, **`check`**, **`guest`**, **`user`**, **`id`**, **`can` / `cannot` / `authorize`** (policies: **`auth.definePolicy`**, **`defineGate`**, **`beforePolicy`**).
81
- - **Route config:** `middleware: ['auth']` (must be logged in) or `['guest']` (logged-out only). For JSON APIs mounted in **`setupRoutes`**, use **`ctx.authMiddleware.requireAuth({ api: true })`** for 401 JSON instead of redirect.
89
+ - **Route config:** `middleware: ['auth']` (SSR logged in), `middleware: ['jwt']` (API Bearer token), or `['guest']` (logged-out only). For JSON APIs mounted in **`setupRoutes`**, use **`ctx.authMiddleware.requireJwt()`** or **`requireAuth({ api: true })`**.
82
90
  - **Login page pitfall:** a **`pages/login.njk`** can register **before** `setupRoutes` and bypass **`requireGuest`**. Prefer login GET/POST in **`setupRoutes`** with templates under **`views/`** only, or omit **`pages/login.njk`** — see [`tests/e2e/auth.spec.js`](../../../tests/e2e/auth.spec.js).
83
91
  - **Admin panel** uses a **separate** session (`req.session.adminUser`, `/_admin/api/auth/*`); it does **not** replace **`createApp({ auth })`** for site users.
84
92
  - **Site users in the admin UI (`userManagement`):** Opt-in on **`adminPanelPlugin`**. Set **`userManagement: { enabled: true, model: 'User', fields?: { ... } }`** so the SPA shows **Users**: sidebar **All Users** / **Add User** link to **`/_admin/models/{model}`** and **`/_admin/models/{model}/new`** (same RecordList/RecordForm as other models). **`/_admin/users`**, **`/_admin/users/new`**, **`/_admin/users/:id/edit`** remain SPA aliases that redirect there; **`/_admin/users/sessions`** is **Active Sessions**. The **`model`** must match site auth (e.g. **`quickAuth({ userModel: 'User', ... })`** / **`createAuth`**) and must have **`admin: { enabled: true, ... }`** on **`defineModel`** so admin CRUD metadata loads; otherwise Users screens look empty. Pass **`auth: authManager`** with the **same** **`AuthManager`** as **`createApp({ auth: authManager })`** for **Active Sessions** / revoke (**`rememberTokens`** / **`remember_me`**); without **`auth`**, user CRUD still works via **`db.getRepository(model)`**, but session endpoints return empty or “not enabled”.
85
93
  - **Wiring:** `plugins: [ adminPanelPlugin({ db, auth: authManager, userManagement: { enabled: true, model: 'User' } }) ]` alongside `createApp({ ..., auth: authManager })`. Admin staff log in at **`/_admin`**; end users use your normal site login — two different cookies/sessions.
86
94
 
87
- Longer narrative: **[`doc/index.html#authentication`](../../../doc/index.html#authentication)** · **[`#admin-user-management`](../../../doc/index.html#admin-user-management)** · README **Authentication (session)** and **Admin Panel Plugin**.
95
+ Longer narrative: **[`doc/index.html#authentication`](../../../doc/index.html#authentication)** · **[`#admin-user-management`](../../../doc/index.html#admin-user-management)** · README **Authentication (session & JWT)** and **Admin Panel Plugin**.
88
96
 
89
97
  ---
90
98