webspresso 0.0.84 → 0.0.86

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,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