strapi-security-suite 0.3.0 → 0.3.2

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.
@@ -3,7 +3,7 @@ import { useFetchClient, Page } from "@strapi/strapi/admin";
3
3
  import { Routes, Route } from "react-router-dom";
4
4
  import { useState, useEffect } from "react";
5
5
  import { Box, Typography, Alert, Flex, Divider, NumberInput, Switch, Button } from "@strapi/design-system";
6
- import { A as API_BASE_PATH, S as SUCCESS_ALERT_DURATION } from "./index-CAEB836L.mjs";
6
+ import { A as API_BASE_PATH, S as SUCCESS_ALERT_DURATION } from "./index-ZKJuPZEH.mjs";
7
7
  const HomePage = () => {
8
8
  const client = useFetchClient();
9
9
  const [config, setConfig] = useState({
@@ -5,7 +5,7 @@ const admin = require("@strapi/strapi/admin");
5
5
  const reactRouterDom = require("react-router-dom");
6
6
  const react = require("react");
7
7
  const designSystem = require("@strapi/design-system");
8
- const index = require("./index-ub4Bl9QF.js");
8
+ const index = require("./index-BGBd43He.js");
9
9
  const HomePage = () => {
10
10
  const client = admin.useFetchClient();
11
11
  const [config, setConfig] = react.useState({
@@ -49,9 +49,8 @@ const index = {
49
49
  const response = await originalFetch(...args);
50
50
  const captured = response.headers.get(ADMIN_TOKEN_HEADER);
51
51
  if (captured) {
52
- window.stop();
53
52
  window.location.reload();
54
- return;
53
+ return new Response(null, { status: 401 });
55
54
  }
56
55
  return response;
57
56
  };
@@ -71,7 +70,7 @@ const index = {
71
70
  id: getTrad("settings.title"),
72
71
  defaultMessage: "Security Suite"
73
72
  },
74
- Component: () => Promise.resolve().then(() => require("./App-CfPg1Thn.js")),
73
+ Component: () => Promise.resolve().then(() => require("./App-ConqHB2Q.js")),
75
74
  permissions: [
76
75
  {
77
76
  action: "plugin::strapi-security-suite.access",
@@ -48,9 +48,8 @@ const index = {
48
48
  const response = await originalFetch(...args);
49
49
  const captured = response.headers.get(ADMIN_TOKEN_HEADER);
50
50
  if (captured) {
51
- window.stop();
52
51
  window.location.reload();
53
- return;
52
+ return new Response(null, { status: 401 });
54
53
  }
55
54
  return response;
56
55
  };
@@ -70,7 +69,7 @@ const index = {
70
69
  id: getTrad("settings.title"),
71
70
  defaultMessage: "Security Suite"
72
71
  },
73
- Component: () => import("./App-t96Cfein.mjs"),
72
+ Component: () => import("./App-CBOxzfqu.mjs"),
74
73
  permissions: [
75
74
  {
76
75
  action: "plugin::strapi-security-suite.access",
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-ub4Bl9QF.js");
2
+ const index = require("../_chunks/index-BGBd43He.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-CAEB836L.mjs";
1
+ import { i } from "../_chunks/index-ZKJuPZEH.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -3,7 +3,16 @@ const jwt = require("jsonwebtoken");
3
3
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
4
4
  const jwt__default = /* @__PURE__ */ _interopDefault(jwt);
5
5
  const revokedTokenSet = /* @__PURE__ */ new Set();
6
- const revokedConnectionTokens = /* @__PURE__ */ new Set();
6
+ const revokedConnectionTokens = /* @__PURE__ */ new Map();
7
+ const TOKEN_TTL = 30 * 60 * 1e3;
8
+ function pruneExpiredTokens() {
9
+ const cutoff = Date.now() - TOKEN_TTL;
10
+ for (const [token, revokedAt] of revokedConnectionTokens) {
11
+ if (revokedAt < cutoff) {
12
+ revokedConnectionTokens.delete(token);
13
+ }
14
+ }
15
+ }
7
16
  const sessionActivityMap = /* @__PURE__ */ new Map();
8
17
  const PLUGIN_ID = "strapi-security-suite";
9
18
  const CONTENT_TYPES = {
@@ -12,6 +21,7 @@ const CONTENT_TYPES = {
12
21
  const SERVICES = {
13
22
  AUTO_LOGOUT_CHECKER: "autoLogoutChecker"
14
23
  };
24
+ const CTX_ADMIN_USER = Symbol.for("security-suite:adminUser");
15
25
  const CHECK_INTERVAL = 5e3;
16
26
  const DEFAULT_AUTOLOGOUT_TIME = 30;
17
27
  const MS_PER_MINUTE = 6e4;
@@ -29,7 +39,9 @@ const COOKIES = {
29
39
  SESSION: "koa.sess",
30
40
  SESSION_SIG: "koa.sess.sig",
31
41
  /** Strapi v5 admin refresh-token cookie (managed by session manager). */
32
- REFRESH_TOKEN: "strapi_admin_refresh"
42
+ REFRESH_TOKEN: "strapi_admin_refresh",
43
+ /** JWT access-token cookie set by Strapi EE SSO authentication flow. */
44
+ JWT_TOKEN: "jwtToken"
33
45
  };
34
46
  const HEADERS = {
35
47
  /** Header that signals the frontend to force-reload (session revoked). */
@@ -37,7 +49,6 @@ const HEADERS = {
37
49
  /** Required so the browser exposes custom headers in fetch responses. */
38
50
  EXPOSE_HEADERS: "Access-Control-Expose-Headers"
39
51
  };
40
- const ADMIN_TOKEN_FALLBACK = "email.admin";
41
52
  const ERROR_MESSAGES = {
42
53
  SETTINGS_NOT_FOUND: "Security settings not found.",
43
54
  INSUFFICIENT_PERMISSIONS: "Insufficient permissions.",
@@ -60,8 +71,49 @@ const DEFAULT_SETTINGS = {
60
71
  enablePasswordManagement: true
61
72
  };
62
73
  const VALID_SETTINGS_KEYS = new Set(Object.keys(DEFAULT_SETTINGS));
74
+ class PluginError extends Error {
75
+ /**
76
+ * @param {string} message - Internal message (for logs)
77
+ * @param {string} sanitizedMessage - Safe message for the client
78
+ * @param {number} [statusCode=400] - HTTP status code
79
+ */
80
+ constructor(message, sanitizedMessage, statusCode = HTTP_STATUS.BAD_REQUEST) {
81
+ super(message);
82
+ this.name = "PluginError";
83
+ this.sanitizedMessage = sanitizedMessage;
84
+ this.statusCode = statusCode;
85
+ }
86
+ }
87
+ class ValidationError extends PluginError {
88
+ /**
89
+ * @param {string} message - Internal message
90
+ * @param {string} [sanitizedMessage='Validation failed.'] - Client-safe message
91
+ */
92
+ constructor(message, sanitizedMessage = "Validation failed.") {
93
+ super(message, sanitizedMessage, HTTP_STATUS.BAD_REQUEST);
94
+ this.name = "ValidationError";
95
+ }
96
+ }
97
+ function clearSessionCookies(ctx) {
98
+ if (ctx.session !== void 0) {
99
+ ctx.session = null;
100
+ }
101
+ const expireOpts = { expires: /* @__PURE__ */ new Date(0), path: "/", httpOnly: true };
102
+ ctx.cookies.set(COOKIES.SESSION, "", expireOpts);
103
+ ctx.cookies.set(COOKIES.SESSION_SIG, "", expireOpts);
104
+ ctx.cookies.set(COOKIES.REFRESH_TOKEN, "", {
105
+ expires: /* @__PURE__ */ new Date(0),
106
+ path: "/admin",
107
+ httpOnly: true
108
+ });
109
+ ctx.cookies.set(COOKIES.JWT_TOKEN, "", {
110
+ expires: /* @__PURE__ */ new Date(0),
111
+ path: "/",
112
+ httpOnly: false
113
+ });
114
+ }
63
115
  async function trackActivity(ctx, next) {
64
- const adminUser = ctx.session?.user;
116
+ const adminUser = ctx.state[CTX_ADMIN_USER];
65
117
  let key = adminUser?.id ? `${adminUser.id}:${adminUser.email}` : null;
66
118
  const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
67
119
  if (bearerToken && revokedConnectionTokens.has(bearerToken)) {
@@ -76,7 +128,7 @@ async function trackActivity(ctx, next) {
76
128
  return;
77
129
  }
78
130
  if (ctx.path.includes(LOGOUT_PATH)) {
79
- ctx.session = null;
131
+ clearSessionCookies(ctx);
80
132
  key = null;
81
133
  }
82
134
  if (key) {
@@ -86,14 +138,22 @@ async function trackActivity(ctx, next) {
86
138
  await next();
87
139
  }
88
140
  const loginLocks = /* @__PURE__ */ new Set();
141
+ function cleanupLoginState(ctx) {
142
+ const email = ctx.request.body?.email;
143
+ loginLocks.delete(email);
144
+ if (email && ctx.status < 400) {
145
+ revokedTokenSet.delete(email);
146
+ }
147
+ }
89
148
  async function preventMultipleSessions(ctx, next) {
90
149
  const isLoginPost = ctx.path === LOGIN_PATH && ctx.method === "POST";
91
- const alreadyAdmin = ctx.session?.user;
92
150
  if (!isLoginPost) {
93
151
  return await next();
94
152
  }
95
- if (alreadyAdmin) {
96
- strapi.log.debug(`[${PLUGIN_ID}] Skipping session lock. ${JSON.stringify(alreadyAdmin)}`);
153
+ if (ctx.state[CTX_ADMIN_USER]) {
154
+ strapi.log.debug(
155
+ `[${PLUGIN_ID}] Skipping session lock. ${JSON.stringify(ctx.state[CTX_ADMIN_USER])}`
156
+ );
97
157
  return await next();
98
158
  }
99
159
  try {
@@ -127,30 +187,22 @@ async function preventMultipleSessions(ctx, next) {
127
187
  try {
128
188
  await next();
129
189
  } finally {
130
- if (ctx.path === LOGIN_PATH && ctx.method === "POST") {
131
- const email = ctx.request.body?.email;
132
- loginLocks.delete(email);
133
- }
190
+ cleanupLoginState(ctx);
134
191
  }
135
192
  }
136
193
  async function rejectRevokedTokens(ctx, next) {
137
- const sessionUser = ctx.session?.user;
138
- if (!sessionUser?.email) return await next();
139
- const { id, email: adminEmail } = sessionUser;
194
+ const adminUser = ctx.state[CTX_ADMIN_USER];
195
+ if (!adminUser?.email) return await next();
196
+ const { id, email: adminEmail } = adminUser;
140
197
  const key = id && adminEmail ? `${id}:${adminEmail}` : null;
141
198
  try {
142
199
  if (adminEmail && revokedTokenSet.has(adminEmail)) {
143
200
  ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, adminEmail);
144
201
  ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
145
- ctx.session = null;
146
- ctx.cookies.set(COOKIES.REFRESH_TOKEN, "", {
147
- expires: /* @__PURE__ */ new Date(0),
148
- path: "/admin",
149
- httpOnly: true
150
- });
202
+ clearSessionCookies(ctx);
151
203
  const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
152
204
  if (bearerToken) {
153
- revokedConnectionTokens.add(bearerToken);
205
+ revokedConnectionTokens.set(bearerToken, Date.now());
154
206
  }
155
207
  sessionActivityMap.delete(key);
156
208
  revokedTokenSet.delete(adminEmail);
@@ -173,29 +225,25 @@ async function rejectRevokedTokens(ctx, next) {
173
225
  }
174
226
  async function interceptRenewToken(ctx, next) {
175
227
  if (ctx.path.includes(LOGOUT_PATH)) {
176
- const adminUser = ctx.session?.user;
228
+ const adminUser = ctx.state[CTX_ADMIN_USER];
177
229
  strapi.log.debug(`[${PLUGIN_ID}] Logout captured: ${JSON.stringify(adminUser)}`);
178
230
  if (adminUser?.id) {
179
231
  strapi.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).clearSessionActivity(adminUser.id, adminUser.email);
180
232
  const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
181
233
  if (bearerToken) {
182
- revokedConnectionTokens.add(bearerToken);
234
+ revokedConnectionTokens.set(bearerToken, Date.now());
183
235
  }
184
- ctx.session = null;
236
+ clearSessionCookies(ctx);
185
237
  sessionActivityMap.delete(`${adminUser.id}:${adminUser.email}`);
186
238
  }
187
239
  await next();
188
240
  return;
189
241
  }
190
242
  if (ctx.path.includes(ACCESS_TOKEN_PATH) || ctx.path.includes(CONTENT_PATH)) {
191
- const { email } = ctx.session?.user || {};
192
- if (!email) {
193
- ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, ADMIN_TOKEN_FALLBACK);
194
- ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
195
- await next();
196
- return;
243
+ const adminUser = ctx.state[CTX_ADMIN_USER];
244
+ if (adminUser?.email) {
245
+ strapi.log.debug(`[${PLUGIN_ID}] Token renewal intercepted for ${adminUser.email}`);
197
246
  }
198
- strapi.log.debug(`[${PLUGIN_ID}] Token renewal intercepted for ${email}`);
199
247
  }
200
248
  await next();
201
249
  }
@@ -208,9 +256,8 @@ async function seedUserInfos(ctx, next) {
208
256
  const token = authHeader.split("Bearer ")[1];
209
257
  if (!token) return await next();
210
258
  const decodedToken = jwt__default.default.decode(token);
211
- const session = ctx.session?.user ?? null;
212
259
  const adminId = decodedToken?.userId;
213
- if (!adminId || session?.id) {
260
+ if (!adminId || ctx.state[CTX_ADMIN_USER]?.id) {
214
261
  return await next();
215
262
  }
216
263
  const adminUser = await strapi.db.query("admin::user").findOne({
@@ -221,25 +268,18 @@ async function seedUserInfos(ctx, next) {
221
268
  strapi.log.debug(`[${PLUGIN_ID}] No admin user found with ID ${adminId}`);
222
269
  return await next();
223
270
  }
224
- if (revokedTokenSet.has(adminUser.email)) {
225
- strapi.log.debug(
226
- `[${PLUGIN_ID}] Admin ${adminUser.email} is in revoked set — skipping hydration`
227
- );
228
- return await next();
229
- }
230
- const userInfos = {
271
+ ctx.state[CTX_ADMIN_USER] = {
231
272
  id: adminUser.id,
232
273
  email: adminUser.email,
233
274
  firstname: adminUser.firstname,
234
275
  lastname: adminUser.lastname,
235
276
  roles: adminUser.roles
236
277
  };
237
- ctx.session.user = userInfos;
238
278
  const key = `${adminUser.id}:${adminUser.email}`;
239
279
  if (!sessionActivityMap.has(key)) {
240
280
  sessionActivityMap.set(key, Date.now());
241
281
  }
242
- strapi.log.debug(`[${PLUGIN_ID}] Session hydrated for admin ${adminUser.email}`);
282
+ strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email}`);
243
283
  return await next();
244
284
  } catch (error) {
245
285
  strapi.log.error(`[${PLUGIN_ID}] Failed to decode or hydrate admin token:`, error);
@@ -373,29 +413,6 @@ const securitySettings = {
373
413
  const contentTypes = {
374
414
  "security-settings": securitySettings
375
415
  };
376
- class PluginError extends Error {
377
- /**
378
- * @param {string} message - Internal message (for logs)
379
- * @param {string} sanitizedMessage - Safe message for the client
380
- * @param {number} [statusCode=400] - HTTP status code
381
- */
382
- constructor(message, sanitizedMessage, statusCode = HTTP_STATUS.BAD_REQUEST) {
383
- super(message);
384
- this.name = "PluginError";
385
- this.sanitizedMessage = sanitizedMessage;
386
- this.statusCode = statusCode;
387
- }
388
- }
389
- class ValidationError extends PluginError {
390
- /**
391
- * @param {string} message - Internal message
392
- * @param {string} [sanitizedMessage='Validation failed.'] - Client-safe message
393
- */
394
- constructor(message, sanitizedMessage = "Validation failed.") {
395
- super(message, sanitizedMessage, HTTP_STATUS.BAD_REQUEST);
396
- this.name = "ValidationError";
397
- }
398
- }
399
416
  const validateSettingsPayload = (body) => {
400
417
  if (!body || typeof body !== "object" || Array.isArray(body)) {
401
418
  throw new ValidationError(
@@ -557,6 +574,7 @@ const autoLogoutChecker = ({ strapi: strapi2 }) => ({
557
574
  }
558
575
  interval = setInterval(async () => {
559
576
  try {
577
+ pruneExpiredTokens();
560
578
  const settings = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
561
579
  const autoLogoutTime = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
562
580
  const now = Date.now();
@@ -1,6 +1,15 @@
1
1
  import jwt from "jsonwebtoken";
2
2
  const revokedTokenSet = /* @__PURE__ */ new Set();
3
- const revokedConnectionTokens = /* @__PURE__ */ new Set();
3
+ const revokedConnectionTokens = /* @__PURE__ */ new Map();
4
+ const TOKEN_TTL = 30 * 60 * 1e3;
5
+ function pruneExpiredTokens() {
6
+ const cutoff = Date.now() - TOKEN_TTL;
7
+ for (const [token, revokedAt] of revokedConnectionTokens) {
8
+ if (revokedAt < cutoff) {
9
+ revokedConnectionTokens.delete(token);
10
+ }
11
+ }
12
+ }
4
13
  const sessionActivityMap = /* @__PURE__ */ new Map();
5
14
  const PLUGIN_ID = "strapi-security-suite";
6
15
  const CONTENT_TYPES = {
@@ -9,6 +18,7 @@ const CONTENT_TYPES = {
9
18
  const SERVICES = {
10
19
  AUTO_LOGOUT_CHECKER: "autoLogoutChecker"
11
20
  };
21
+ const CTX_ADMIN_USER = Symbol.for("security-suite:adminUser");
12
22
  const CHECK_INTERVAL = 5e3;
13
23
  const DEFAULT_AUTOLOGOUT_TIME = 30;
14
24
  const MS_PER_MINUTE = 6e4;
@@ -26,7 +36,9 @@ const COOKIES = {
26
36
  SESSION: "koa.sess",
27
37
  SESSION_SIG: "koa.sess.sig",
28
38
  /** Strapi v5 admin refresh-token cookie (managed by session manager). */
29
- REFRESH_TOKEN: "strapi_admin_refresh"
39
+ REFRESH_TOKEN: "strapi_admin_refresh",
40
+ /** JWT access-token cookie set by Strapi EE SSO authentication flow. */
41
+ JWT_TOKEN: "jwtToken"
30
42
  };
31
43
  const HEADERS = {
32
44
  /** Header that signals the frontend to force-reload (session revoked). */
@@ -34,7 +46,6 @@ const HEADERS = {
34
46
  /** Required so the browser exposes custom headers in fetch responses. */
35
47
  EXPOSE_HEADERS: "Access-Control-Expose-Headers"
36
48
  };
37
- const ADMIN_TOKEN_FALLBACK = "email.admin";
38
49
  const ERROR_MESSAGES = {
39
50
  SETTINGS_NOT_FOUND: "Security settings not found.",
40
51
  INSUFFICIENT_PERMISSIONS: "Insufficient permissions.",
@@ -57,8 +68,49 @@ const DEFAULT_SETTINGS = {
57
68
  enablePasswordManagement: true
58
69
  };
59
70
  const VALID_SETTINGS_KEYS = new Set(Object.keys(DEFAULT_SETTINGS));
71
+ class PluginError extends Error {
72
+ /**
73
+ * @param {string} message - Internal message (for logs)
74
+ * @param {string} sanitizedMessage - Safe message for the client
75
+ * @param {number} [statusCode=400] - HTTP status code
76
+ */
77
+ constructor(message, sanitizedMessage, statusCode = HTTP_STATUS.BAD_REQUEST) {
78
+ super(message);
79
+ this.name = "PluginError";
80
+ this.sanitizedMessage = sanitizedMessage;
81
+ this.statusCode = statusCode;
82
+ }
83
+ }
84
+ class ValidationError extends PluginError {
85
+ /**
86
+ * @param {string} message - Internal message
87
+ * @param {string} [sanitizedMessage='Validation failed.'] - Client-safe message
88
+ */
89
+ constructor(message, sanitizedMessage = "Validation failed.") {
90
+ super(message, sanitizedMessage, HTTP_STATUS.BAD_REQUEST);
91
+ this.name = "ValidationError";
92
+ }
93
+ }
94
+ function clearSessionCookies(ctx) {
95
+ if (ctx.session !== void 0) {
96
+ ctx.session = null;
97
+ }
98
+ const expireOpts = { expires: /* @__PURE__ */ new Date(0), path: "/", httpOnly: true };
99
+ ctx.cookies.set(COOKIES.SESSION, "", expireOpts);
100
+ ctx.cookies.set(COOKIES.SESSION_SIG, "", expireOpts);
101
+ ctx.cookies.set(COOKIES.REFRESH_TOKEN, "", {
102
+ expires: /* @__PURE__ */ new Date(0),
103
+ path: "/admin",
104
+ httpOnly: true
105
+ });
106
+ ctx.cookies.set(COOKIES.JWT_TOKEN, "", {
107
+ expires: /* @__PURE__ */ new Date(0),
108
+ path: "/",
109
+ httpOnly: false
110
+ });
111
+ }
60
112
  async function trackActivity(ctx, next) {
61
- const adminUser = ctx.session?.user;
113
+ const adminUser = ctx.state[CTX_ADMIN_USER];
62
114
  let key = adminUser?.id ? `${adminUser.id}:${adminUser.email}` : null;
63
115
  const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
64
116
  if (bearerToken && revokedConnectionTokens.has(bearerToken)) {
@@ -73,7 +125,7 @@ async function trackActivity(ctx, next) {
73
125
  return;
74
126
  }
75
127
  if (ctx.path.includes(LOGOUT_PATH)) {
76
- ctx.session = null;
128
+ clearSessionCookies(ctx);
77
129
  key = null;
78
130
  }
79
131
  if (key) {
@@ -83,14 +135,22 @@ async function trackActivity(ctx, next) {
83
135
  await next();
84
136
  }
85
137
  const loginLocks = /* @__PURE__ */ new Set();
138
+ function cleanupLoginState(ctx) {
139
+ const email = ctx.request.body?.email;
140
+ loginLocks.delete(email);
141
+ if (email && ctx.status < 400) {
142
+ revokedTokenSet.delete(email);
143
+ }
144
+ }
86
145
  async function preventMultipleSessions(ctx, next) {
87
146
  const isLoginPost = ctx.path === LOGIN_PATH && ctx.method === "POST";
88
- const alreadyAdmin = ctx.session?.user;
89
147
  if (!isLoginPost) {
90
148
  return await next();
91
149
  }
92
- if (alreadyAdmin) {
93
- strapi.log.debug(`[${PLUGIN_ID}] Skipping session lock. ${JSON.stringify(alreadyAdmin)}`);
150
+ if (ctx.state[CTX_ADMIN_USER]) {
151
+ strapi.log.debug(
152
+ `[${PLUGIN_ID}] Skipping session lock. ${JSON.stringify(ctx.state[CTX_ADMIN_USER])}`
153
+ );
94
154
  return await next();
95
155
  }
96
156
  try {
@@ -124,30 +184,22 @@ async function preventMultipleSessions(ctx, next) {
124
184
  try {
125
185
  await next();
126
186
  } finally {
127
- if (ctx.path === LOGIN_PATH && ctx.method === "POST") {
128
- const email = ctx.request.body?.email;
129
- loginLocks.delete(email);
130
- }
187
+ cleanupLoginState(ctx);
131
188
  }
132
189
  }
133
190
  async function rejectRevokedTokens(ctx, next) {
134
- const sessionUser = ctx.session?.user;
135
- if (!sessionUser?.email) return await next();
136
- const { id, email: adminEmail } = sessionUser;
191
+ const adminUser = ctx.state[CTX_ADMIN_USER];
192
+ if (!adminUser?.email) return await next();
193
+ const { id, email: adminEmail } = adminUser;
137
194
  const key = id && adminEmail ? `${id}:${adminEmail}` : null;
138
195
  try {
139
196
  if (adminEmail && revokedTokenSet.has(adminEmail)) {
140
197
  ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, adminEmail);
141
198
  ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
142
- ctx.session = null;
143
- ctx.cookies.set(COOKIES.REFRESH_TOKEN, "", {
144
- expires: /* @__PURE__ */ new Date(0),
145
- path: "/admin",
146
- httpOnly: true
147
- });
199
+ clearSessionCookies(ctx);
148
200
  const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
149
201
  if (bearerToken) {
150
- revokedConnectionTokens.add(bearerToken);
202
+ revokedConnectionTokens.set(bearerToken, Date.now());
151
203
  }
152
204
  sessionActivityMap.delete(key);
153
205
  revokedTokenSet.delete(adminEmail);
@@ -170,29 +222,25 @@ async function rejectRevokedTokens(ctx, next) {
170
222
  }
171
223
  async function interceptRenewToken(ctx, next) {
172
224
  if (ctx.path.includes(LOGOUT_PATH)) {
173
- const adminUser = ctx.session?.user;
225
+ const adminUser = ctx.state[CTX_ADMIN_USER];
174
226
  strapi.log.debug(`[${PLUGIN_ID}] Logout captured: ${JSON.stringify(adminUser)}`);
175
227
  if (adminUser?.id) {
176
228
  strapi.plugin(PLUGIN_ID).service(SERVICES.AUTO_LOGOUT_CHECKER).clearSessionActivity(adminUser.id, adminUser.email);
177
229
  const bearerToken = ctx.get("authorization")?.split("Bearer ")[1];
178
230
  if (bearerToken) {
179
- revokedConnectionTokens.add(bearerToken);
231
+ revokedConnectionTokens.set(bearerToken, Date.now());
180
232
  }
181
- ctx.session = null;
233
+ clearSessionCookies(ctx);
182
234
  sessionActivityMap.delete(`${adminUser.id}:${adminUser.email}`);
183
235
  }
184
236
  await next();
185
237
  return;
186
238
  }
187
239
  if (ctx.path.includes(ACCESS_TOKEN_PATH) || ctx.path.includes(CONTENT_PATH)) {
188
- const { email } = ctx.session?.user || {};
189
- if (!email) {
190
- ctx.set(HEADERS.ADMIN_TOKEN_SIGNAL, ADMIN_TOKEN_FALLBACK);
191
- ctx.set(HEADERS.EXPOSE_HEADERS, HEADERS.ADMIN_TOKEN_SIGNAL);
192
- await next();
193
- return;
240
+ const adminUser = ctx.state[CTX_ADMIN_USER];
241
+ if (adminUser?.email) {
242
+ strapi.log.debug(`[${PLUGIN_ID}] Token renewal intercepted for ${adminUser.email}`);
194
243
  }
195
- strapi.log.debug(`[${PLUGIN_ID}] Token renewal intercepted for ${email}`);
196
244
  }
197
245
  await next();
198
246
  }
@@ -205,9 +253,8 @@ async function seedUserInfos(ctx, next) {
205
253
  const token = authHeader.split("Bearer ")[1];
206
254
  if (!token) return await next();
207
255
  const decodedToken = jwt.decode(token);
208
- const session = ctx.session?.user ?? null;
209
256
  const adminId = decodedToken?.userId;
210
- if (!adminId || session?.id) {
257
+ if (!adminId || ctx.state[CTX_ADMIN_USER]?.id) {
211
258
  return await next();
212
259
  }
213
260
  const adminUser = await strapi.db.query("admin::user").findOne({
@@ -218,25 +265,18 @@ async function seedUserInfos(ctx, next) {
218
265
  strapi.log.debug(`[${PLUGIN_ID}] No admin user found with ID ${adminId}`);
219
266
  return await next();
220
267
  }
221
- if (revokedTokenSet.has(adminUser.email)) {
222
- strapi.log.debug(
223
- `[${PLUGIN_ID}] Admin ${adminUser.email} is in revoked set — skipping hydration`
224
- );
225
- return await next();
226
- }
227
- const userInfos = {
268
+ ctx.state[CTX_ADMIN_USER] = {
228
269
  id: adminUser.id,
229
270
  email: adminUser.email,
230
271
  firstname: adminUser.firstname,
231
272
  lastname: adminUser.lastname,
232
273
  roles: adminUser.roles
233
274
  };
234
- ctx.session.user = userInfos;
235
275
  const key = `${adminUser.id}:${adminUser.email}`;
236
276
  if (!sessionActivityMap.has(key)) {
237
277
  sessionActivityMap.set(key, Date.now());
238
278
  }
239
- strapi.log.debug(`[${PLUGIN_ID}] Session hydrated for admin ${adminUser.email}`);
279
+ strapi.log.debug(`[${PLUGIN_ID}] Admin identified: ${adminUser.email}`);
240
280
  return await next();
241
281
  } catch (error) {
242
282
  strapi.log.error(`[${PLUGIN_ID}] Failed to decode or hydrate admin token:`, error);
@@ -370,29 +410,6 @@ const securitySettings = {
370
410
  const contentTypes = {
371
411
  "security-settings": securitySettings
372
412
  };
373
- class PluginError extends Error {
374
- /**
375
- * @param {string} message - Internal message (for logs)
376
- * @param {string} sanitizedMessage - Safe message for the client
377
- * @param {number} [statusCode=400] - HTTP status code
378
- */
379
- constructor(message, sanitizedMessage, statusCode = HTTP_STATUS.BAD_REQUEST) {
380
- super(message);
381
- this.name = "PluginError";
382
- this.sanitizedMessage = sanitizedMessage;
383
- this.statusCode = statusCode;
384
- }
385
- }
386
- class ValidationError extends PluginError {
387
- /**
388
- * @param {string} message - Internal message
389
- * @param {string} [sanitizedMessage='Validation failed.'] - Client-safe message
390
- */
391
- constructor(message, sanitizedMessage = "Validation failed.") {
392
- super(message, sanitizedMessage, HTTP_STATUS.BAD_REQUEST);
393
- this.name = "ValidationError";
394
- }
395
- }
396
413
  const validateSettingsPayload = (body) => {
397
414
  if (!body || typeof body !== "object" || Array.isArray(body)) {
398
415
  throw new ValidationError(
@@ -554,6 +571,7 @@ const autoLogoutChecker = ({ strapi: strapi2 }) => ({
554
571
  }
555
572
  interval = setInterval(async () => {
556
573
  try {
574
+ pruneExpiredTokens();
557
575
  const settings = await strapi2.documents(CONTENT_TYPES.SECURITY_SETTINGS).findFirst({});
558
576
  const autoLogoutTime = (settings?.autoLogoutTime ?? DEFAULT_AUTOLOGOUT_TIME) * MS_PER_MINUTE;
559
577
  const now = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-security-suite",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "All-in-one authentication and session security plugin for Strapi v5",
5
5
  "license": "MIT",
6
6
  "author": "(LPIX-11) <mohamed.johnson@orange-sonatel.com>",