strapi-plugin-oidc 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { randomUUID, randomBytes } from "node:crypto";
1
+ import { randomUUID, randomBytes, createHash } from "node:crypto";
2
2
  import pkceChallenge from "pkce-challenge";
3
3
  import strapiUtils from "@strapi/utils";
4
4
  import generator from "generate-password";
@@ -18,18 +18,31 @@ function resolveEnforceOIDC(strapi2, dbValue) {
18
18
  if (configValue !== null) return configValue;
19
19
  return dbValue ?? false;
20
20
  }
21
+ const PLUGIN_UID = "plugin::strapi-plugin-oidc";
22
+ const DEFAULT_RETENTION_DAYS = 90;
23
+ function getPluginConfig() {
24
+ return strapi.config.get(PLUGIN_UID);
25
+ }
26
+ function getRetentionDays() {
27
+ const config2 = getPluginConfig();
28
+ return Number(config2.AUDIT_LOG_RETENTION_DAYS ?? DEFAULT_RETENTION_DAYS);
29
+ }
30
+ function isAuditLogEnabled() {
31
+ return getRetentionDays() !== 0;
32
+ }
21
33
  async function bootstrap({ strapi: strapi2 }) {
34
+ const adminUrl = strapi2.config.get("admin.url", "/admin");
35
+ const authRoutes = [
36
+ `${adminUrl}/login`,
37
+ `${adminUrl}/register`,
38
+ `${adminUrl}/register-admin`,
39
+ `${adminUrl}/forgot-password`,
40
+ `${adminUrl}/reset-password`
41
+ ];
42
+ const tokenRefreshPath = `${adminUrl}/token/refresh`;
22
43
  const enforceOidcMiddleware = async (ctx, next) => {
23
- const adminUrl = strapi2.config.get("admin.url", "/admin");
24
- const authRoutes = [
25
- `${adminUrl}/login`,
26
- `${adminUrl}/register`,
27
- `${adminUrl}/register-admin`,
28
- `${adminUrl}/forgot-password`,
29
- `${adminUrl}/reset-password`
30
- ];
31
44
  const isPostAuth = authRoutes.includes(ctx.request.path) && ctx.request.method === "POST";
32
- const isTokenRefresh = ctx.request.path === `${adminUrl}/token/refresh` && ctx.request.method === "POST";
45
+ const isTokenRefresh = ctx.request.path === tokenRefreshPath && ctx.request.method === "POST";
33
46
  if (isPostAuth || isTokenRefresh) {
34
47
  try {
35
48
  const whitelistService2 = strapi2.plugin("strapi-plugin-oidc").service("whitelist");
@@ -110,14 +123,12 @@ async function bootstrap({ strapi: strapi2 }) {
110
123
  where: { oauth_type: "4" }
111
124
  });
112
125
  if (oidcRoleCount === 0) {
113
- const editorRole = await strapi2.query("admin::role").findOne({
114
- where: { code: "strapi-editor" }
115
- });
116
- if (editorRole) {
126
+ const defaultRole = await strapi2.query("admin::role").findOne({ where: { code: "strapi-editor" } }) ?? await strapi2.query("admin::role").findOne({});
127
+ if (defaultRole) {
117
128
  await strapi2.query("plugin::strapi-plugin-oidc.roles").create({
118
129
  data: {
119
130
  oauth_type: "4",
120
- roles: [editorRole.id.toString()]
131
+ roles: [defaultRole.id.toString()]
121
132
  }
122
133
  });
123
134
  }
@@ -125,25 +136,18 @@ async function bootstrap({ strapi: strapi2 }) {
125
136
  } catch (err) {
126
137
  strapi2.log.warn("Could not initialize default OIDC role:", err.message);
127
138
  }
128
- strapi2.db.lifecycles.subscribe({
129
- models: ["admin::user"],
130
- async afterUpdate(event) {
131
- const { result } = event;
132
- if (!result?.email) return;
133
- const query = strapi2.query("plugin::strapi-plugin-oidc.whitelists");
134
- const whitelistEntry = await query.findOne({ where: { email: result.email } });
135
- if (!whitelistEntry) return;
136
- const userWithRoles = await strapi2.query("admin::user").findOne({
137
- where: { id: result.id },
138
- populate: ["roles"]
139
- });
140
- if (userWithRoles?.roles) {
141
- const roleIds = userWithRoles.roles.map((r) => r.id.toString());
142
- await query.update({
143
- where: { id: whitelistEntry.id },
144
- data: { roles: roleIds }
145
- });
146
- }
139
+ strapi2.cron.add({
140
+ "strapi-plugin-oidc-audit-log-cleanup": {
141
+ task: async () => {
142
+ try {
143
+ const retentionDays = getRetentionDays();
144
+ await strapi2.plugin("strapi-plugin-oidc").service("auditLog").cleanup(retentionDays);
145
+ } catch (err) {
146
+ strapi2.log.warn("[strapi-plugin-oidc] Audit log cleanup failed:", err.message);
147
+ }
148
+ },
149
+ options: { rule: "0 0 * * *" }
150
+ // daily at midnight
147
151
  }
148
152
  });
149
153
  }
@@ -164,41 +168,58 @@ const config = {
164
168
  OIDC_GIVEN_NAME_FIELD: "given_name",
165
169
  OIDC_END_SESSION_ENDPOINT: "",
166
170
  OIDC_SSO_BUTTON_TEXT: "Login via SSO",
167
- OIDC_ENFORCE: null
171
+ OIDC_ENFORCE: null,
168
172
  // null = use DB setting; true/false = override DB (useful for lockout recovery)
173
+ AUDIT_LOG_RETENTION_DAYS: 90,
174
+ OIDC_GROUP_FIELD: "groups",
175
+ OIDC_GROUP_ROLE_MAP: "{}"
169
176
  },
170
177
  validator() {
171
178
  }
172
179
  };
173
- const info$2 = { "singularName": "roles", "pluralName": "oidc-roles", "collectionName": "oidc-roles", "displayName": "oidc-role", "description": "" };
180
+ const info$3 = { "singularName": "roles", "pluralName": "oidc-roles", "collectionName": "oidc-roles", "displayName": "oidc-role", "description": "" };
181
+ const options$2 = { "draftAndPublish": false };
182
+ const pluginOptions$2 = { "content-manager": { "visible": false }, "content-type-builder": { "visible": false } };
183
+ const attributes$2 = { "oauth_type": { "type": "string", "configurable": false, "required": true }, "roles": { "type": "json", "configurable": false } };
184
+ const schema$2 = {
185
+ info: info$3,
186
+ options: options$2,
187
+ pluginOptions: pluginOptions$2,
188
+ attributes: attributes$2
189
+ };
190
+ const roles = {
191
+ schema: schema$2
192
+ };
193
+ const info$2 = { "singularName": "whitelists", "pluralName": "whitelists", "collectionName": "whitelists", "displayName": "whitelist", "description": "" };
174
194
  const options$1 = { "draftAndPublish": false };
175
195
  const pluginOptions$1 = { "content-manager": { "visible": false }, "content-type-builder": { "visible": false } };
176
- const attributes$1 = { "oauth_type": { "type": "string", "configurable": false, "required": true }, "roles": { "type": "json", "configurable": false } };
196
+ const attributes$1 = { "email": { "type": "string", "configurable": false, "required": true, "unique": true } };
177
197
  const schema$1 = {
178
198
  info: info$2,
179
199
  options: options$1,
180
200
  pluginOptions: pluginOptions$1,
181
201
  attributes: attributes$1
182
202
  };
183
- const roles = {
203
+ const whitelists = {
184
204
  schema: schema$1
185
205
  };
186
- const info$1 = { "singularName": "whitelists", "pluralName": "whitelists", "collectionName": "whitelists", "displayName": "whitelist", "description": "" };
206
+ const info$1 = { "singularName": "audit-log", "pluralName": "audit-logs", "collectionName": "audit_logs", "displayName": "OIDC Audit Log" };
187
207
  const options = { "draftAndPublish": false };
188
208
  const pluginOptions = { "content-manager": { "visible": false }, "content-type-builder": { "visible": false } };
189
- const attributes = { "email": { "type": "string", "configurable": false, "required": true, "unique": true }, "roles": { "type": "json", "configurable": false } };
209
+ const attributes = { "action": { "type": "string", "required": true }, "email": { "type": "string" }, "ip": { "type": "string" }, "detailsKey": { "type": "string" }, "detailsParams": { "type": "json" } };
190
210
  const schema = {
191
211
  info: info$1,
192
212
  options,
193
213
  pluginOptions,
194
214
  attributes
195
215
  };
196
- const whitelists = {
216
+ const auditLog$1 = {
197
217
  schema
198
218
  };
199
219
  const contentTypes = {
200
220
  roles,
201
- whitelists
221
+ whitelists,
222
+ "audit-log": auditLog$1
202
223
  };
203
224
  function getExpiredCookieOptions(strapi2, ctx) {
204
225
  const isProduction = strapi2.config.get("environment") === "production";
@@ -216,7 +237,131 @@ function clearAuthCookies(strapi2, ctx) {
216
237
  const options2 = getExpiredCookieOptions(strapi2, ctx);
217
238
  ctx.cookies.set("strapi_admin_refresh", "", options2);
218
239
  ctx.cookies.set("oidc_authenticated", "", { ...options2, path: "/" });
240
+ ctx.cookies.set("oidc_access_token", "", { ...options2, path: "/" });
241
+ ctx.cookies.set("oidc_user_email", "", { ...options2, path: "/" });
219
242
  }
243
+ const errorCodes = {
244
+ TOKEN_EXCHANGE_FAILED: "TOKEN_EXCHANGE_FAILED",
245
+ USERINFO_FETCH_FAILED: "USERINFO_FETCH_FAILED",
246
+ ID_TOKEN_PARSE_FAILED: "ID_TOKEN_PARSE_FAILED",
247
+ NONCE_MISMATCH: "NONCE_MISMATCH",
248
+ ROLE_UPDATE_FAILED: "ROLE_UPDATE_FAILED",
249
+ USER_CREATION_FAILED: "USER_CREATION_FAILED",
250
+ WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED"
251
+ };
252
+ function getErrorDetail(key, params) {
253
+ switch (key) {
254
+ case "token_exchange_failed":
255
+ return `Token exchange failed with HTTP status ${params?.status ?? "unknown"}`;
256
+ case "userinfo_fetch_failed":
257
+ return `UserInfo endpoint returned HTTP ${params?.status ?? "unknown"}`;
258
+ case "role_update_failed":
259
+ return `Role update failed for user ${params?.userId}: ${params?.error ?? "unknown"}`;
260
+ case "user_creation_failed":
261
+ return `User creation failed for ${params?.email}: ${params?.error ?? "unknown"}`;
262
+ case "id_token_parse_failed":
263
+ return `ID token parse failed: ${params?.error ?? "unknown"}`;
264
+ case "sign_in_unknown":
265
+ return `Unknown sign-in error: ${params?.error ?? "unknown"}`;
266
+ default:
267
+ return void 0;
268
+ }
269
+ }
270
+ const en = {
271
+ "global.plugins.strapi-plugin-oidc": "OIDC Plugin",
272
+ "page.title": "Configure OIDC default role(s) and access controls.",
273
+ "roles.notes": "Select the default role(s) assigned to new users upon their first login. This setting does not affect existing users.",
274
+ "page.save": "Save Changes",
275
+ "page.save.success": "Updated settings",
276
+ "page.save.error": "Update failed.",
277
+ "page.add": "Add",
278
+ "page.cancel": "Cancel",
279
+ "page.ok": "OK",
280
+ "roles.title": "Default Role(s)",
281
+ "roles.placeholder": "Select default role(s)",
282
+ "whitelist.title": "Whitelist",
283
+ "whitelist.error.unique": "Already registered email address.",
284
+ "whitelist.description": "Restrict OIDC authentication to specific email addresses. Users receive roles from the default OIDC role mapping or their OIDC group.",
285
+ "alert.title.success": "Success",
286
+ "alert.title.error": "Error",
287
+ "alert.title.info": "Info",
288
+ "pagination.previous": "Go to previous page",
289
+ "pagination.page": "Go to page {page}",
290
+ "pagination.next": "Go to next page",
291
+ "whitelist.table.no": "No.",
292
+ "whitelist.table.email": "Email",
293
+ "whitelist.table.created": "Created At",
294
+ "whitelist.delete.title": "Confirmation",
295
+ "whitelist.delete.description": "Are you sure you want to delete:",
296
+ "whitelist.delete.note": "This will not delete the user account in Strapi.",
297
+ "whitelist.toggle.enabled": "Enabled",
298
+ "whitelist.toggle.disabled": "Disabled",
299
+ "whitelist.email.placeholder": "Email address",
300
+ "whitelist.roles.placeholder": "Select specific role(s)",
301
+ "whitelist.table.roles": "Role(s)",
302
+ "whitelist.table.empty": "No email addresses",
303
+ "whitelist.delete.label": "Delete",
304
+ "page.title.oidc": "OIDC",
305
+ "enforce.title": "Enforce OIDC Login",
306
+ "enforce.toggle.enabled": "Enabled",
307
+ "enforce.toggle.disabled": "Disabled",
308
+ "enforce.warning": "Make sure OIDC is setup correctly before saving changes, you won't be able to login normally.",
309
+ "enforce.config.info": "Enforcement is controlled by the OIDC_ENFORCE config variable and cannot be changed here.",
310
+ "login.settings.title": "Login Settings",
311
+ "login.sso": "Login via SSO",
312
+ "whitelist.count": "{count, plural, one {# entry} other {# entries}}",
313
+ "whitelist.import": "Import",
314
+ "whitelist.export": "Export",
315
+ "whitelist.delete.all.label": "Delete All",
316
+ "whitelist.delete.all.title": "Delete All Entries",
317
+ "whitelist.delete.all.description": "This will permanently remove all {count, plural, one {# entry} other {# entries}} from the whitelist. Unsaved changes will be lost.",
318
+ "whitelist.import.error": "Invalid file — expected a JSON array of objects with an email field.",
319
+ "whitelist.import.success": "Imported {count, plural, one {# new entry} other {# new entries}}.",
320
+ "whitelist.import.none": "No new entries — all emails are already in the whitelist.",
321
+ "unsaved.title": "Unsaved Changes",
322
+ "unsaved.description": "You have unsaved changes that will be lost if you leave. Do you want to continue?",
323
+ "unsaved.confirm": "Leave",
324
+ "unsaved.cancel": "Stay",
325
+ "whitelist.table.roles.default": "(Default)",
326
+ "auditlog.title": "Audit Logs",
327
+ "auditlog.export": "Download",
328
+ "auditlog.table.timestamp": "Timestamp",
329
+ "auditlog.table.action": "Action",
330
+ "auditlog.table.email": "Email",
331
+ "auditlog.table.ip": "IP",
332
+ "auditlog.table.details": "Details",
333
+ "auditlog.table.empty": "No audit log entries",
334
+ "auditlog.clear": "Clear Logs",
335
+ "auditlog.clear.title": "Clear All Logs",
336
+ "auditlog.clear.description": "This will permanently delete all {count, plural, one {# audit log entry} other {# audit log entries}}. This action cannot be undone.",
337
+ "auditlog.clear.success": "Audit logs cleared",
338
+ "auditlog.clear.error": "Failed to clear audit logs",
339
+ "auditlog.export.error": "Failed to export audit logs",
340
+ "auditlog.action.login_success": "User successfully authenticated via OIDC and was granted access.",
341
+ "auditlog.action.user_created": "A new Strapi admin account was created for this user on their first OIDC login.",
342
+ "auditlog.action.logout": "User logged out and their OIDC session was ended.",
343
+ "auditlog.action.session_expired": "The OIDC access token had expired by the time the user logged out, so the provider session could not be terminated remotely.",
344
+ "auditlog.action.login_failure": "An unexpected error occurred during the OIDC login flow.",
345
+ "auditlog.action.missing_code": "The OIDC callback was received without an authorisation code. This may indicate a misconfigured provider or a tampered request.",
346
+ "auditlog.action.state_mismatch": "The state parameter in the callback did not match the one stored in the session. This may indicate a CSRF attempt or an expired login session.",
347
+ "auditlog.action.nonce_mismatch": "The nonce in the ID token did not match the one generated at login. This may indicate a token replay attack.",
348
+ "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
349
+ "auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
350
+ "user.missing_code": "Authorisation code was not received from the OIDC provider.",
351
+ "user.invalid_state": "State parameter mismatch. Please restart the login flow.",
352
+ "user.signInError": "Authentication failed. Please try again."
353
+ };
354
+ const userFacingMessages = {
355
+ get missing_code() {
356
+ return en["user.missing_code"];
357
+ },
358
+ get invalid_state() {
359
+ return en["user.invalid_state"];
360
+ },
361
+ get signInError() {
362
+ return en["user.signInError"];
363
+ }
364
+ };
220
365
  const REQUIRED_CONFIG_KEYS = [
221
366
  "OIDC_CLIENT_ID",
222
367
  "OIDC_CLIENT_SECRET",
@@ -254,15 +399,16 @@ async function oidcSignIn(ctx) {
254
399
  ctx.cookies.set("oidc_code_verifier", codeVerifier, cookieOptions);
255
400
  ctx.cookies.set("oidc_state", state, cookieOptions);
256
401
  ctx.cookies.set("oidc_nonce", nonce, cookieOptions);
257
- const params = new URLSearchParams();
258
- params.append("response_type", "code");
259
- params.append("client_id", OIDC_CLIENT_ID);
260
- params.append("redirect_uri", OIDC_REDIRECT_URI);
261
- params.append("scope", OIDC_SCOPE);
262
- params.append("code_challenge", codeChallenge);
263
- params.append("code_challenge_method", "S256");
264
- params.append("state", state);
265
- params.append("nonce", nonce);
402
+ const params = new URLSearchParams({
403
+ response_type: "code",
404
+ client_id: OIDC_CLIENT_ID,
405
+ redirect_uri: OIDC_REDIRECT_URI,
406
+ scope: OIDC_SCOPE,
407
+ code_challenge: codeChallenge,
408
+ code_challenge_method: "S256",
409
+ state,
410
+ nonce
411
+ });
266
412
  const authorizationUrl = `${OIDC_AUTHORIZATION_ENDPOINT}?${params.toString()}`;
267
413
  ctx.set("Location", authorizationUrl);
268
414
  return ctx.send({}, 302);
@@ -297,16 +443,38 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
297
443
  if (!userResponse.ok) {
298
444
  throw new Error("Failed to fetch user info");
299
445
  }
300
- return userResponse.json();
446
+ const userInfo = await userResponse.json();
447
+ return { userInfo, accessToken: tokenData.access_token };
301
448
  }
302
- async function registerNewUser(userService, oauthService2, roleService2, email, userResponseData, whitelistUser, config2, ctx) {
303
- let roles2 = [];
304
- if (whitelistUser?.roles?.length > 0) {
305
- roles2 = whitelistUser.roles;
306
- } else {
307
- const oidcRoles = await roleService2.oidcRoles();
308
- roles2 = oidcRoles?.roles || [];
449
+ function resolveRolesFromGroups(userInfo, config2, availableRoles) {
450
+ const rawGroups = userInfo[config2.OIDC_GROUP_FIELD];
451
+ if (!Array.isArray(rawGroups) || rawGroups.length === 0) return [];
452
+ const groups = rawGroups.filter((g) => typeof g === "string");
453
+ const raw = config2.OIDC_GROUP_ROLE_MAP;
454
+ let groupRoleMap;
455
+ try {
456
+ groupRoleMap = typeof raw === "string" ? JSON.parse(raw) : raw;
457
+ } catch {
458
+ return [];
309
459
  }
460
+ const roleIdSet = /* @__PURE__ */ new Set();
461
+ for (const group of groups) {
462
+ const roleNames = groupRoleMap[group];
463
+ if (!roleNames) continue;
464
+ for (const name of roleNames) {
465
+ const match = availableRoles.find((r) => r.name === name);
466
+ if (match) roleIdSet.add(String(match.id));
467
+ }
468
+ }
469
+ return [...roleIdSet];
470
+ }
471
+ async function resolveRoles(userInfo, config2, roleService2, availableRoles) {
472
+ const groupRoles = resolveRolesFromGroups(userInfo, config2, availableRoles);
473
+ if (groupRoles.length > 0) return groupRoles;
474
+ const oidcRoles = await roleService2.oidcRoles();
475
+ return oidcRoles?.roles || [];
476
+ }
477
+ async function registerNewUser(oauthService2, email, userResponseData, config2, ctx, roles2) {
310
478
  const defaultLocale = oauthService2.localeFindByHeader(
311
479
  ctx.request.headers
312
480
  );
@@ -321,21 +489,106 @@ async function registerNewUser(userService, oauthService2, roleService2, email,
321
489
  return activateUser;
322
490
  }
323
491
  async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
324
- const email = String(userResponseData.email).toLowerCase();
325
- const whitelistUser = await whitelistService2.checkWhitelistForEmail(email);
326
- const activateUser = await userService.findOneByEmail(email) ?? await registerNewUser(
327
- userService,
328
- oauthService2,
329
- roleService2,
330
- email,
331
- userResponseData,
332
- whitelistUser,
333
- config2,
334
- ctx
335
- );
492
+ const rawEmail = String(userResponseData.email ?? "");
493
+ const email = rawEmail.toLowerCase();
494
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
495
+ throw new Error("Invalid email address received from OIDC provider");
496
+ }
497
+ await whitelistService2.checkWhitelistForEmail(email);
498
+ const allRoles = await strapi.db.query("admin::role").findMany();
499
+ const roles2 = await resolveRoles(userResponseData, config2, roleService2, allRoles);
500
+ const resolvedRoleNames = allRoles.filter((r) => roles2.includes(String(r.id))).map((r) => r.name);
501
+ let userCreated = false;
502
+ let rolesUpdated = false;
503
+ let activateUser = await userService.findOneByEmail(email);
504
+ if (!activateUser) {
505
+ activateUser = await registerNewUser(oauthService2, email, userResponseData, config2, ctx, roles2);
506
+ userCreated = true;
507
+ } else if (roles2.length > 0) {
508
+ const currentRoleIds = new Set((activateUser.roles ?? []).map((r) => String(r.id)));
509
+ const newRoleIds = new Set(roles2);
510
+ const rolesChanged = currentRoleIds.size !== newRoleIds.size || [...newRoleIds].some((id) => !currentRoleIds.has(id));
511
+ if (rolesChanged) {
512
+ try {
513
+ strapi.log.info(
514
+ `[OIDC] Roles updated for user ${activateUser.id}: [${[...currentRoleIds].join(",")}] -> [${roles2.join(",")}]`
515
+ );
516
+ await strapi.db.query("admin::user").update({
517
+ where: { id: activateUser.id },
518
+ data: { roles: roles2 }
519
+ });
520
+ rolesUpdated = true;
521
+ } catch (updateErr) {
522
+ strapi.log.error({
523
+ code: errorCodes.ROLE_UPDATE_FAILED,
524
+ userId: activateUser.id,
525
+ detail: getErrorDetail("role_update_failed", {
526
+ userId: activateUser.id,
527
+ error: updateErr.message
528
+ })
529
+ });
530
+ throw updateErr;
531
+ }
532
+ }
533
+ }
336
534
  const jwtToken = await oauthService2.generateToken(activateUser, ctx);
337
535
  oauthService2.triggerSignInSuccess(activateUser);
338
- return { activateUser, jwtToken };
536
+ return { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames };
537
+ }
538
+ function classifyOidcError(msg, userInfo) {
539
+ const errorMap = [
540
+ {
541
+ test: (m) => m.includes("whitelist"),
542
+ result: {
543
+ action: "whitelist_rejected",
544
+ code: errorCodes.WHITELIST_CHECK_FAILED,
545
+ key: "whitelist_rejected"
546
+ }
547
+ },
548
+ {
549
+ test: (m) => m === "Nonce mismatch",
550
+ result: { action: "nonce_mismatch", code: errorCodes.NONCE_MISMATCH }
551
+ },
552
+ {
553
+ test: (m) => m === "Token exchange failed",
554
+ result: { action: "token_exchange_failed", code: errorCodes.TOKEN_EXCHANGE_FAILED }
555
+ },
556
+ {
557
+ test: (m) => m === "Failed to fetch user info",
558
+ result: {
559
+ action: "login_failure",
560
+ code: errorCodes.USERINFO_FETCH_FAILED,
561
+ key: "userinfo_fetch_failed"
562
+ }
563
+ },
564
+ {
565
+ test: (m) => m === "Failed to parse ID token",
566
+ result: {
567
+ action: "login_failure",
568
+ code: errorCodes.ID_TOKEN_PARSE_FAILED,
569
+ key: "id_token_parse_failed",
570
+ params: { error: msg }
571
+ }
572
+ },
573
+ {
574
+ test: (m) => m === "User creation failed" || m.includes("createUser"),
575
+ result: {
576
+ action: "login_failure",
577
+ code: errorCodes.USER_CREATION_FAILED,
578
+ key: "user_creation_failed",
579
+ params: userInfo?.email ? { email: userInfo.email, error: msg } : void 0
580
+ }
581
+ }
582
+ ];
583
+ for (const { test, result } of errorMap) {
584
+ if (test(msg)) return result;
585
+ }
586
+ return {
587
+ action: "login_failure",
588
+ code: errorCodes.TOKEN_EXCHANGE_FAILED,
589
+ key: "sign_in_unknown",
590
+ params: { error: msg || "unknown" }
591
+ };
339
592
  }
340
593
  async function oidcSignInCallback(ctx) {
341
594
  const config2 = configValidation();
@@ -343,8 +596,10 @@ async function oidcSignInCallback(ctx) {
343
596
  const oauthService2 = strapi.plugin("strapi-plugin-oidc").service("oauth");
344
597
  const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
345
598
  const whitelistService2 = strapi.plugin("strapi-plugin-oidc").service("whitelist");
599
+ const auditLog2 = strapi.plugin("strapi-plugin-oidc").service("auditLog");
346
600
  if (!ctx.query.code) {
347
- return ctx.send(oauthService2.renderSignUpError("code Not Found"));
601
+ await auditLog2.log({ action: "missing_code", ip: ctx.ip });
602
+ return ctx.send(oauthService2.renderSignUpError(userFacingMessages.missing_code));
348
603
  }
349
604
  const oidcState = ctx.cookies.get("oidc_state");
350
605
  const codeVerifier = ctx.cookies.get("oidc_code_verifier");
@@ -353,53 +608,129 @@ async function oidcSignInCallback(ctx) {
353
608
  ctx.cookies.set("oidc_code_verifier", null);
354
609
  ctx.cookies.set("oidc_nonce", null);
355
610
  if (!ctx.query.state || ctx.query.state !== oidcState) {
356
- return ctx.send(oauthService2.renderSignUpError("Invalid state"));
611
+ await auditLog2.log({ action: "state_mismatch", ip: ctx.ip });
612
+ return ctx.send(oauthService2.renderSignUpError(userFacingMessages.invalid_state));
357
613
  }
358
- const params = new URLSearchParams();
359
- params.append("code", ctx.query.code);
360
- params.append("client_id", config2.OIDC_CLIENT_ID);
361
- params.append("client_secret", config2.OIDC_CLIENT_SECRET);
362
- params.append("redirect_uri", config2.OIDC_REDIRECT_URI);
363
- params.append("grant_type", config2.OIDC_GRANT_TYPE);
364
- params.append("code_verifier", codeVerifier ?? "");
614
+ const params = new URLSearchParams({
615
+ code: ctx.query.code,
616
+ client_id: config2.OIDC_CLIENT_ID,
617
+ client_secret: config2.OIDC_CLIENT_SECRET,
618
+ redirect_uri: config2.OIDC_REDIRECT_URI,
619
+ grant_type: config2.OIDC_GRANT_TYPE,
620
+ code_verifier: codeVerifier ?? ""
621
+ });
622
+ let userInfo;
365
623
  try {
366
- const userResponseData = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
367
- const { activateUser, jwtToken } = await handleUserAuthentication(
624
+ const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
625
+ userInfo = exchangeResult.userInfo;
626
+ const accessToken = exchangeResult.accessToken;
627
+ const isProduction = strapi.config.get("environment") === "production";
628
+ ctx.cookies.set("oidc_access_token", accessToken, {
629
+ httpOnly: true,
630
+ maxAge: 3e5,
631
+ // 5 minutes — matches typical provider access token lifetime
632
+ secure: isProduction && ctx.request.secure,
633
+ sameSite: "lax"
634
+ });
635
+ const { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames } = await handleUserAuthentication(
368
636
  userService,
369
637
  oauthService2,
370
638
  roleService2,
371
639
  whitelistService2,
372
- userResponseData,
640
+ userInfo,
373
641
  config2,
374
642
  ctx
375
643
  );
644
+ const identityCookieOptions = {
645
+ httpOnly: true,
646
+ path: "/",
647
+ secure: isProduction && ctx.request.secure,
648
+ sameSite: "lax"
649
+ };
650
+ ctx.cookies.set("oidc_user_email", activateUser.email, identityCookieOptions);
651
+ if (userCreated) {
652
+ await auditLog2.log({
653
+ action: "user_created",
654
+ email: activateUser.email,
655
+ ip: ctx.ip,
656
+ detailsKey: "user_created",
657
+ detailsParams: { roles: resolvedRoleNames.join(", ") }
658
+ });
659
+ }
660
+ await auditLog2.log({
661
+ action: "login_success",
662
+ email: activateUser.email,
663
+ ip: ctx.ip,
664
+ detailsKey: rolesUpdated ? "roles_updated" : void 0,
665
+ detailsParams: rolesUpdated ? { roles: resolvedRoleNames.join(", ") } : void 0
666
+ });
376
667
  const nonce = randomUUID();
377
668
  const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
378
669
  ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
379
670
  ctx.send(html);
380
671
  } catch (e) {
381
- console.error("ERROR CAUGHT IN OIDC SIGNIN:", e);
382
- ctx.send(oauthService2.renderSignUpError("Authentication failed. Please try again."));
672
+ const msg = e.message ?? "";
673
+ const errorInfo = classifyOidcError(msg, userInfo);
674
+ await auditLog2.log({
675
+ action: errorInfo.action,
676
+ email: userInfo?.email,
677
+ ip: ctx.ip,
678
+ detailsKey: errorInfo.action,
679
+ detailsParams: errorInfo.action === "login_failure" ? { message: msg } : void 0
680
+ });
681
+ strapi.log.error({
682
+ code: errorInfo.code,
683
+ phase: "oidc_callback",
684
+ message: msg || "Unknown sign-in error",
685
+ detail: errorInfo.key ? getErrorDetail(errorInfo.key, errorInfo.params) : void 0,
686
+ email: userInfo?.email
687
+ });
688
+ ctx.send(oauthService2.renderSignUpError(userFacingMessages.signInError));
383
689
  }
384
690
  }
385
691
  async function logout(ctx) {
386
692
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
693
+ const auditLog2 = strapi.plugin("strapi-plugin-oidc").service("auditLog");
387
694
  const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
695
+ const adminPanelUrl = strapi.config.get("admin.url", "/admin");
388
696
  const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
697
+ const accessToken = ctx.cookies.get("oidc_access_token");
698
+ const userEmail = ctx.cookies.get("oidc_user_email") ?? void 0;
389
699
  clearAuthCookies(strapi, ctx);
700
+ if (logoutUrl && isOidcSession && accessToken) {
701
+ try {
702
+ const response = await fetch(config2.OIDC_USERINFO_ENDPOINT, {
703
+ headers: { Authorization: `Bearer ${accessToken}` }
704
+ });
705
+ if (response.ok) {
706
+ if (userEmail)
707
+ auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip }).catch(() => {
708
+ });
709
+ return ctx.redirect(logoutUrl);
710
+ }
711
+ if (userEmail)
712
+ await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
713
+ return ctx.redirect(`${adminPanelUrl}/auth/login`);
714
+ } catch {
715
+ if (userEmail)
716
+ await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
717
+ return ctx.redirect(`${adminPanelUrl}/auth/login`);
718
+ }
719
+ }
720
+ if (isOidcSession && userEmail) {
721
+ await auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip });
722
+ }
390
723
  if (logoutUrl && isOidcSession) {
391
- ctx.redirect(logoutUrl);
392
- } else {
393
- const adminPanelUrl = strapi.config.get("admin.url", "/admin");
394
- ctx.redirect(`${adminPanelUrl}/auth/login`);
724
+ return ctx.redirect(logoutUrl);
395
725
  }
726
+ ctx.redirect(`${adminPanelUrl}/auth/login`);
396
727
  }
397
728
  const oidc = {
398
729
  oidcSignIn,
399
730
  oidcSignInCallback,
400
731
  logout
401
732
  };
402
- async function find(ctx) {
733
+ async function find$1(ctx) {
403
734
  const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
404
735
  const roles2 = await roleService2.find();
405
736
  const oidcConstants = roleService2.getOidcRoles();
@@ -418,14 +749,23 @@ async function update(ctx) {
418
749
  await roleService2.update(roles2);
419
750
  ctx.send({}, 204);
420
751
  } catch (e) {
421
- console.error(e);
752
+ strapi.log.error(e);
422
753
  ctx.send({}, 400);
423
754
  }
424
755
  }
425
756
  const role = {
426
- find,
757
+ find: find$1,
427
758
  update
428
759
  };
760
+ function formatDatetimeForFilename(date) {
761
+ const year = date.getFullYear();
762
+ const month = String(date.getMonth() + 1).padStart(2, "0");
763
+ const day = String(date.getDate()).padStart(2, "0");
764
+ const hours = String(date.getHours()).padStart(2, "0");
765
+ const minutes = String(date.getMinutes()).padStart(2, "0");
766
+ const seconds = String(date.getSeconds()).padStart(2, "0");
767
+ return `${year}${month}${day}_${hours}${minutes}${seconds}`;
768
+ }
429
769
  function getWhitelistService() {
430
770
  return strapi.plugin("strapi-plugin-oidc").service("whitelist");
431
771
  }
@@ -437,7 +777,8 @@ async function info(ctx) {
437
777
  useWhitelist: settings.useWhitelist,
438
778
  enforceOIDC: resolveEnforceOIDC(strapi, settings.enforceOIDC),
439
779
  enforceOIDCConfig: getEnforceOIDCConfig(strapi),
440
- whitelistUsers
780
+ whitelistUsers,
781
+ auditLogEnabled: isAuditLogEnabled()
441
782
  };
442
783
  }
443
784
  async function updateSettings(ctx) {
@@ -463,32 +804,25 @@ async function publicSettings(ctx) {
463
804
  };
464
805
  }
465
806
  async function register(ctx) {
466
- const { email, roles: roles2 } = ctx.request.body;
807
+ const { email } = ctx.request.body;
467
808
  if (!email) {
468
809
  ctx.body = { message: "Please enter a valid email address" };
469
810
  return;
470
811
  }
471
812
  const rawEmails = Array.isArray(email) ? email : email.split(",");
472
813
  const emailList = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
473
- const existingUsers = await strapi.query("admin::user").findMany({
474
- where: { email: { $in: emailList } },
475
- populate: ["roles"]
476
- });
477
- const existingUsersByEmail = new Map(existingUsers.map((u) => [u.email, u]));
478
814
  const whitelistService2 = getWhitelistService();
479
815
  let matchedExistingUsersCount = 0;
480
816
  for (const singleEmail of emailList) {
481
- const existingUser = existingUsersByEmail.get(singleEmail);
482
- let finalRoles = roles2;
483
- if (existingUser?.roles) {
484
- finalRoles = existingUser.roles.map((r) => String(r.id));
485
- matchedExistingUsersCount++;
486
- }
817
+ const existingUser = await strapi.query("admin::user").findOne({
818
+ where: { email: singleEmail }
819
+ });
820
+ if (existingUser) matchedExistingUsersCount++;
487
821
  const alreadyWhitelisted = await strapi.query("plugin::strapi-plugin-oidc.whitelists").findOne({
488
822
  where: { email: singleEmail }
489
823
  });
490
824
  if (!alreadyWhitelisted) {
491
- await whitelistService2.registerUser(singleEmail, finalRoles);
825
+ await whitelistService2.registerUser(singleEmail);
492
826
  }
493
827
  }
494
828
  ctx.body = { matchedExistingUsersCount };
@@ -503,81 +837,52 @@ async function deleteAll(ctx) {
503
837
  await strapi.query("plugin::strapi-plugin-oidc.whitelists").deleteMany({});
504
838
  ctx.body = {};
505
839
  }
840
+ async function exportWhitelist(ctx) {
841
+ const datetime = formatDatetimeForFilename(/* @__PURE__ */ new Date());
842
+ ctx.set("Content-Type", "application/json");
843
+ ctx.set("Content-Disposition", `attachment; filename="strapi-oidc-whitelist-${datetime}.json"`);
844
+ const whitelistService2 = getWhitelistService();
845
+ const users = await whitelistService2.getUsers();
846
+ ctx.body = users.map((u) => ({ email: u.email }));
847
+ }
506
848
  async function importUsers(ctx) {
507
849
  const { users } = ctx.request.body;
508
850
  if (!Array.isArray(users)) {
509
851
  ctx.status = 400;
510
- ctx.body = { error: "Expected { users: [{email, roles}] }" };
852
+ ctx.body = { error: "Expected { users: [{email}] }" };
511
853
  return;
512
854
  }
513
- const allRoles = await strapi.query("admin::role").findMany({});
514
- const roleNameToId = new Map(allRoles.map((r) => [r.name, String(r.id)]));
515
- const resolveRole = (nameOrId) => roleNameToId.get(nameOrId) ?? nameOrId;
516
- const normalized = users.filter((u) => u?.email).map((u) => ({
517
- email: String(u.email).trim().toLowerCase(),
518
- roles: (Array.isArray(u.roles) ? u.roles : []).map(resolveRole)
519
- }));
520
- const seen = /* @__PURE__ */ new Set();
521
- const deduped = normalized.filter((u) => {
522
- if (seen.has(u.email)) return false;
523
- seen.add(u.email);
524
- return true;
525
- });
526
- const strapiUsers = await strapi.query("admin::user").findMany({
527
- where: { email: { $in: deduped.map((u) => u.email) } },
528
- populate: ["roles"]
529
- });
530
- const strapiUserMap = new Map(strapiUsers.map((u) => [u.email, u]));
855
+ const normalized = users.filter((u) => u?.email).map((u) => String(u.email).trim().toLowerCase()).filter((email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
856
+ const deduped = [...new Set(normalized)];
531
857
  const whitelistService2 = getWhitelistService();
532
858
  const existing = await whitelistService2.getUsers();
533
859
  const existingEmails = new Set(existing.map((u) => u.email));
534
860
  let importedCount = 0;
535
- for (const user of deduped) {
536
- if (existingEmails.has(user.email)) continue;
537
- const strapiUser = strapiUserMap.get(user.email);
538
- const finalRoles = strapiUser?.roles?.length ? strapiUser.roles.map((r) => String(r.id)) : user.roles;
539
- await whitelistService2.registerUser(user.email, finalRoles);
861
+ for (const email of deduped) {
862
+ if (existingEmails.has(email)) continue;
863
+ await whitelistService2.registerUser(email);
540
864
  importedCount++;
541
865
  }
542
866
  ctx.body = { importedCount };
543
867
  }
544
868
  async function syncUsers(ctx) {
545
869
  const { users: rawUsers } = ctx.request.body;
546
- const users = rawUsers.map((u) => ({ ...u, email: String(u.email).toLowerCase() }));
870
+ const emails = rawUsers.map((u) => String(u.email).toLowerCase()).filter((e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e));
547
871
  const whitelistService2 = getWhitelistService();
548
872
  const currentUsers = await whitelistService2.getUsers();
549
- let matchedExistingUsersCount = 0;
550
- const emailsToSync = users.map((u) => u.email);
551
- const existingStrapiUsers = await strapi.query("admin::user").findMany({
552
- where: { email: { $in: emailsToSync } },
553
- populate: ["roles"]
554
- });
555
- const syncEmailSet = new Set(emailsToSync);
873
+ const syncEmailSet = new Set(emails);
556
874
  const currentUsersByEmail = new Map(currentUsers.map((u) => [u.email, u]));
557
- const strapiUsersByEmail = new Map(existingStrapiUsers.map((u) => [u.email, u]));
558
875
  for (const currUser of currentUsers) {
559
876
  if (!syncEmailSet.has(currUser.email)) {
560
877
  await whitelistService2.removeUser(currUser.id);
561
878
  }
562
879
  }
563
- for (const user of users) {
564
- const currUser = currentUsersByEmail.get(user.email);
565
- let finalRoles = user.roles;
566
- if (!currUser) {
567
- const existingStrapiUser = strapiUsersByEmail.get(user.email);
568
- if (existingStrapiUser?.roles) {
569
- finalRoles = existingStrapiUser.roles.map((r) => String(r.id));
570
- matchedExistingUsersCount++;
571
- }
572
- await whitelistService2.registerUser(user.email, finalRoles);
573
- } else {
574
- await strapi.query("plugin::strapi-plugin-oidc.whitelists").update({
575
- where: { id: currUser.id },
576
- data: { roles: finalRoles }
577
- });
880
+ for (const email of emails) {
881
+ if (!currentUsersByEmail.has(email)) {
882
+ await whitelistService2.registerUser(email);
578
883
  }
579
884
  }
580
- ctx.body = { matchedExistingUsersCount };
885
+ ctx.body = { matchedExistingUsersCount: 0 };
581
886
  }
582
887
  const whitelist = {
583
888
  info,
@@ -587,39 +892,99 @@ const whitelist = {
587
892
  removeEmail,
588
893
  deleteAll,
589
894
  syncUsers,
590
- importUsers
895
+ importUsers,
896
+ exportWhitelist
897
+ };
898
+ function getAuditLogService() {
899
+ return strapi.plugin("strapi-plugin-oidc").service("auditLog");
900
+ }
901
+ async function find(ctx) {
902
+ const page = Math.max(1, Number(ctx.query.page) || 1);
903
+ const pageSize = Math.min(100, Math.max(1, Number(ctx.query.pageSize) || 25));
904
+ ctx.body = await getAuditLogService().find({ page, pageSize });
905
+ }
906
+ async function exportLogs(ctx) {
907
+ const datetime = formatDatetimeForFilename(/* @__PURE__ */ new Date());
908
+ ctx.set("Content-Type", "application/json");
909
+ ctx.set("Content-Disposition", `attachment; filename="strapi-oidc-audit-log-${datetime}.json"`);
910
+ const service = getAuditLogService();
911
+ const PAGE_SIZE = 1e3;
912
+ const allRows = [];
913
+ let page = 1;
914
+ while (true) {
915
+ const { results } = await service.find({ page, pageSize: PAGE_SIZE });
916
+ for (const row of results) {
917
+ allRows.push({
918
+ id: row.id,
919
+ createdAt: row.createdAt,
920
+ action: row.action,
921
+ email: row.email ?? null,
922
+ ip: row.ip ?? null,
923
+ details: row.details
924
+ });
925
+ }
926
+ if (results.length < PAGE_SIZE) break;
927
+ page++;
928
+ }
929
+ ctx.body = allRows.map((row) => ({
930
+ datetime: row.createdAt,
931
+ action: row.action,
932
+ email: row.email,
933
+ ip: row.ip,
934
+ details: row.details
935
+ }));
936
+ }
937
+ async function clearAll(ctx) {
938
+ await getAuditLogService().clearAll();
939
+ ctx.status = 204;
940
+ }
941
+ const auditLog = {
942
+ find,
943
+ export: exportLogs,
944
+ clearAll
591
945
  };
592
946
  const controllers = {
593
947
  oidc,
594
948
  role,
595
- whitelist
949
+ whitelist,
950
+ auditLog
596
951
  };
597
952
  const rateLimitMap = /* @__PURE__ */ new Map();
598
953
  const RATE_LIMIT_WINDOW = 6e4;
599
- const MAX_REQUESTS = 20;
600
- const rateLimitMiddleware = async (ctx, next) => {
954
+ const MAX_REQUESTS = 1e3;
955
+ function getRateLimitKey(ctx) {
601
956
  const ip = ctx.request.ip;
957
+ const ua = ctx.request.header["user-agent"] ?? "";
958
+ const uaHash = createHash("sha256").update(ua).digest("hex").slice(0, 16);
959
+ return `${ip}:${uaHash}`;
960
+ }
961
+ function rateLimitMiddleware(ctx, next) {
962
+ const key = getRateLimitKey(ctx);
602
963
  const now = Date.now();
603
964
  const windowStart = now - RATE_LIMIT_WINDOW;
604
- const requestStamps = (rateLimitMap.get(ip) || []).filter((timestamp) => timestamp > windowStart);
965
+ const requestStamps = (rateLimitMap.get(key) || []).filter(
966
+ (timestamp) => timestamp > windowStart
967
+ );
605
968
  if (requestStamps.length >= MAX_REQUESTS) {
606
969
  ctx.status = 429;
607
970
  ctx.body = "Too Many Requests";
608
971
  return;
609
972
  }
610
973
  requestStamps.push(now);
611
- rateLimitMap.set(ip, requestStamps);
612
- await next();
613
- };
614
- const adminPolicies = (action) => ({
615
- policies: [
616
- "admin::isAuthenticatedAdmin",
617
- {
618
- name: "admin::hasPermissions",
619
- config: { actions: [`plugin::strapi-plugin-oidc.${action}`] }
620
- }
621
- ]
622
- });
974
+ rateLimitMap.set(key, requestStamps);
975
+ return next();
976
+ }
977
+ function adminPolicies(action) {
978
+ return {
979
+ policies: [
980
+ "admin::isAuthenticatedAdmin",
981
+ {
982
+ name: "admin::hasPermissions",
983
+ config: { actions: [`plugin::strapi-plugin-oidc.${action}`] }
984
+ }
985
+ ]
986
+ };
987
+ }
623
988
  const routes = {
624
989
  admin: {
625
990
  type: "admin",
@@ -701,6 +1066,30 @@ const routes = {
701
1066
  path: "/whitelist",
702
1067
  handler: "whitelist.deleteAll",
703
1068
  config: adminPolicies("update")
1069
+ },
1070
+ {
1071
+ method: "GET",
1072
+ path: "/whitelist/export",
1073
+ handler: "whitelist.exportWhitelist",
1074
+ config: adminPolicies("read")
1075
+ },
1076
+ {
1077
+ method: "GET",
1078
+ path: "/audit-logs",
1079
+ handler: "auditLog.find",
1080
+ config: { policies: ["admin::isAuthenticatedAdmin"] }
1081
+ },
1082
+ {
1083
+ method: "GET",
1084
+ path: "/audit-logs/export",
1085
+ handler: "auditLog.export",
1086
+ config: { policies: ["admin::isAuthenticatedAdmin"] }
1087
+ },
1088
+ {
1089
+ method: "DELETE",
1090
+ path: "/audit-logs",
1091
+ handler: "auditLog.clearAll",
1092
+ config: { policies: ["admin::isAuthenticatedAdmin"] }
704
1093
  }
705
1094
  ]
706
1095
  },
@@ -734,6 +1123,26 @@ const routes = {
734
1123
  method: "DELETE",
735
1124
  path: "/whitelist",
736
1125
  handler: "whitelist.deleteAll"
1126
+ },
1127
+ {
1128
+ method: "GET",
1129
+ path: "/whitelist/export",
1130
+ handler: "whitelist.exportWhitelist"
1131
+ },
1132
+ {
1133
+ method: "GET",
1134
+ path: "/audit-logs",
1135
+ handler: "auditLog.find"
1136
+ },
1137
+ {
1138
+ method: "GET",
1139
+ path: "/audit-logs/export",
1140
+ handler: "auditLog.export"
1141
+ },
1142
+ {
1143
+ method: "DELETE",
1144
+ path: "/audit-logs",
1145
+ handler: "auditLog.clearAll"
737
1146
  }
738
1147
  ]
739
1148
  }
@@ -1104,9 +1513,9 @@ function whitelistService({ strapi: strapi2 }) {
1104
1513
  async getUsers() {
1105
1514
  return getWhitelistQuery().findMany();
1106
1515
  },
1107
- async registerUser(email, roles2) {
1516
+ async registerUser(email) {
1108
1517
  await getWhitelistQuery().create({
1109
- data: { email, roles: roles2 }
1518
+ data: { email }
1110
1519
  });
1111
1520
  },
1112
1521
  async removeUser(id) {
@@ -1129,10 +1538,73 @@ function whitelistService({ strapi: strapi2 }) {
1129
1538
  }
1130
1539
  };
1131
1540
  }
1541
+ function interpolate(str, params) {
1542
+ if (!params) return str;
1543
+ return str.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
1544
+ }
1545
+ function translateDetails(key, params) {
1546
+ const translation = en[`audit.${key}`];
1547
+ if (!translation) return null;
1548
+ return interpolate(translation, params);
1549
+ }
1550
+ function auditLogService({ strapi: strapi2 }) {
1551
+ return {
1552
+ async log({ action, email, ip, detailsKey, detailsParams }) {
1553
+ if (!isAuditLogEnabled()) return;
1554
+ await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").create({
1555
+ data: {
1556
+ action,
1557
+ email: email ?? null,
1558
+ ip: ip ?? null,
1559
+ detailsKey: detailsKey ?? null,
1560
+ detailsParams: detailsParams ?? null
1561
+ }
1562
+ });
1563
+ const eventHub = strapi2.serviceMap?.get?.("eventHub") ?? strapi2.eventHub;
1564
+ if (eventHub) {
1565
+ eventHub.emit(`strapi-plugin-oidc::auth.${action}`, {
1566
+ email,
1567
+ ip,
1568
+ provider: "strapi-plugin-oidc"
1569
+ });
1570
+ }
1571
+ },
1572
+ async find({ page = 1, pageSize = 25 } = {}) {
1573
+ const result = await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").findPage({
1574
+ sort: { createdAt: "desc" },
1575
+ page,
1576
+ pageSize
1577
+ });
1578
+ const results = result.results.map((row) => ({
1579
+ ...row,
1580
+ details: row.detailsKey ? translateDetails(row.detailsKey, row.detailsParams) : null
1581
+ }));
1582
+ return {
1583
+ results,
1584
+ pagination: result.pagination
1585
+ };
1586
+ },
1587
+ async clearAll() {
1588
+ const BATCH_SIZE = 1e3;
1589
+ let deletedCount;
1590
+ do {
1591
+ const result = await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").deleteMany({ limit: BATCH_SIZE });
1592
+ deletedCount = result.count;
1593
+ } while (deletedCount === BATCH_SIZE);
1594
+ },
1595
+ async cleanup(retentionDays) {
1596
+ const cutoff = new Date(Date.now() - retentionDays * 864e5);
1597
+ await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").deleteMany({
1598
+ where: { createdAt: { $lt: cutoff } }
1599
+ });
1600
+ }
1601
+ };
1602
+ }
1132
1603
  const services = {
1133
1604
  oauth: oauthService,
1134
1605
  role: roleService,
1135
- whitelist: whitelistService
1606
+ whitelist: whitelistService,
1607
+ auditLog: auditLogService
1136
1608
  };
1137
1609
  const index = {
1138
1610
  register: register$1,