strapi-plugin-oidc 1.5.3 → 1.6.1

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";
@@ -217,7 +238,130 @@ function clearAuthCookies(strapi2, ctx) {
217
238
  ctx.cookies.set("strapi_admin_refresh", "", options2);
218
239
  ctx.cookies.set("oidc_authenticated", "", { ...options2, path: "/" });
219
240
  ctx.cookies.set("oidc_access_token", "", { ...options2, path: "/" });
241
+ ctx.cookies.set("oidc_user_email", "", { ...options2, path: "/" });
220
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
+ };
221
365
  const REQUIRED_CONFIG_KEYS = [
222
366
  "OIDC_CLIENT_ID",
223
367
  "OIDC_CLIENT_SECRET",
@@ -255,15 +399,16 @@ async function oidcSignIn(ctx) {
255
399
  ctx.cookies.set("oidc_code_verifier", codeVerifier, cookieOptions);
256
400
  ctx.cookies.set("oidc_state", state, cookieOptions);
257
401
  ctx.cookies.set("oidc_nonce", nonce, cookieOptions);
258
- const params = new URLSearchParams();
259
- params.append("response_type", "code");
260
- params.append("client_id", OIDC_CLIENT_ID);
261
- params.append("redirect_uri", OIDC_REDIRECT_URI);
262
- params.append("scope", OIDC_SCOPE);
263
- params.append("code_challenge", codeChallenge);
264
- params.append("code_challenge_method", "S256");
265
- params.append("state", state);
266
- 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
+ });
267
412
  const authorizationUrl = `${OIDC_AUTHORIZATION_ENDPOINT}?${params.toString()}`;
268
413
  ctx.set("Location", authorizationUrl);
269
414
  return ctx.send({}, 302);
@@ -301,14 +446,35 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
301
446
  const userInfo = await userResponse.json();
302
447
  return { userInfo, accessToken: tokenData.access_token };
303
448
  }
304
- async function registerNewUser(userService, oauthService2, roleService2, email, userResponseData, whitelistUser, config2, ctx) {
305
- let roles2 = [];
306
- if (whitelistUser?.roles?.length > 0) {
307
- roles2 = whitelistUser.roles;
308
- } else {
309
- const oidcRoles = await roleService2.oidcRoles();
310
- 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 [];
311
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) {
312
478
  const defaultLocale = oauthService2.localeFindByHeader(
313
479
  ctx.request.headers
314
480
  );
@@ -323,21 +489,106 @@ async function registerNewUser(userService, oauthService2, roleService2, email,
323
489
  return activateUser;
324
490
  }
325
491
  async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
326
- const email = String(userResponseData.email).toLowerCase();
327
- const whitelistUser = await whitelistService2.checkWhitelistForEmail(email);
328
- const activateUser = await userService.findOneByEmail(email) ?? await registerNewUser(
329
- userService,
330
- oauthService2,
331
- roleService2,
332
- email,
333
- userResponseData,
334
- whitelistUser,
335
- config2,
336
- ctx
337
- );
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
+ }
338
534
  const jwtToken = await oauthService2.generateToken(activateUser, ctx);
339
535
  oauthService2.triggerSignInSuccess(activateUser);
340
- 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
+ };
341
592
  }
342
593
  async function oidcSignInCallback(ctx) {
343
594
  const config2 = configValidation();
@@ -345,8 +596,10 @@ async function oidcSignInCallback(ctx) {
345
596
  const oauthService2 = strapi.plugin("strapi-plugin-oidc").service("oauth");
346
597
  const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
347
598
  const whitelistService2 = strapi.plugin("strapi-plugin-oidc").service("whitelist");
599
+ const auditLog2 = strapi.plugin("strapi-plugin-oidc").service("auditLog");
348
600
  if (!ctx.query.code) {
349
- 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));
350
603
  }
351
604
  const oidcState = ctx.cookies.get("oidc_state");
352
605
  const codeVerifier = ctx.cookies.get("oidc_code_verifier");
@@ -355,21 +608,22 @@ async function oidcSignInCallback(ctx) {
355
608
  ctx.cookies.set("oidc_code_verifier", null);
356
609
  ctx.cookies.set("oidc_nonce", null);
357
610
  if (!ctx.query.state || ctx.query.state !== oidcState) {
358
- 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));
359
613
  }
360
- const params = new URLSearchParams();
361
- params.append("code", ctx.query.code);
362
- params.append("client_id", config2.OIDC_CLIENT_ID);
363
- params.append("client_secret", config2.OIDC_CLIENT_SECRET);
364
- params.append("redirect_uri", config2.OIDC_REDIRECT_URI);
365
- params.append("grant_type", config2.OIDC_GRANT_TYPE);
366
- 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;
367
623
  try {
368
- const { userInfo, accessToken } = await exchangeTokenAndFetchUserInfo(
369
- config2,
370
- params,
371
- oidcNonce ?? ""
372
- );
624
+ const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
625
+ userInfo = exchangeResult.userInfo;
626
+ const accessToken = exchangeResult.accessToken;
373
627
  const isProduction = strapi.config.get("environment") === "production";
374
628
  ctx.cookies.set("oidc_access_token", accessToken, {
375
629
  httpOnly: true,
@@ -378,7 +632,7 @@ async function oidcSignInCallback(ctx) {
378
632
  secure: isProduction && ctx.request.secure,
379
633
  sameSite: "lax"
380
634
  });
381
- const { activateUser, jwtToken } = await handleUserAuthentication(
635
+ const { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames } = await handleUserAuthentication(
382
636
  userService,
383
637
  oauthService2,
384
638
  roleService2,
@@ -387,21 +641,61 @@ async function oidcSignInCallback(ctx) {
387
641
  config2,
388
642
  ctx
389
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
+ });
390
667
  const nonce = randomUUID();
391
668
  const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
392
669
  ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
393
670
  ctx.send(html);
394
671
  } catch (e) {
395
- console.error("ERROR CAUGHT IN OIDC SIGNIN:", e);
396
- 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));
397
689
  }
398
690
  }
399
691
  async function logout(ctx) {
400
692
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
693
+ const auditLog2 = strapi.plugin("strapi-plugin-oidc").service("auditLog");
401
694
  const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
402
695
  const adminPanelUrl = strapi.config.get("admin.url", "/admin");
403
696
  const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
404
697
  const accessToken = ctx.cookies.get("oidc_access_token");
698
+ const userEmail = ctx.cookies.get("oidc_user_email") ?? void 0;
405
699
  clearAuthCookies(strapi, ctx);
406
700
  if (logoutUrl && isOidcSession && accessToken) {
407
701
  try {
@@ -409,11 +703,22 @@ async function logout(ctx) {
409
703
  headers: { Authorization: `Bearer ${accessToken}` }
410
704
  });
411
705
  if (response.ok) {
706
+ if (userEmail)
707
+ auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip }).catch(() => {
708
+ });
412
709
  return ctx.redirect(logoutUrl);
413
710
  }
711
+ if (userEmail)
712
+ await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
713
+ return ctx.redirect(`${adminPanelUrl}/auth/login`);
414
714
  } catch {
715
+ if (userEmail)
716
+ await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
717
+ return ctx.redirect(`${adminPanelUrl}/auth/login`);
415
718
  }
416
- return ctx.redirect(`${adminPanelUrl}/auth/login`);
719
+ }
720
+ if (isOidcSession && userEmail) {
721
+ await auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip });
417
722
  }
418
723
  if (logoutUrl && isOidcSession) {
419
724
  return ctx.redirect(logoutUrl);
@@ -425,7 +730,7 @@ const oidc = {
425
730
  oidcSignInCallback,
426
731
  logout
427
732
  };
428
- async function find(ctx) {
733
+ async function find$1(ctx) {
429
734
  const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
430
735
  const roles2 = await roleService2.find();
431
736
  const oidcConstants = roleService2.getOidcRoles();
@@ -444,14 +749,23 @@ async function update(ctx) {
444
749
  await roleService2.update(roles2);
445
750
  ctx.send({}, 204);
446
751
  } catch (e) {
447
- console.error(e);
752
+ strapi.log.error(e);
448
753
  ctx.send({}, 400);
449
754
  }
450
755
  }
451
756
  const role = {
452
- find,
757
+ find: find$1,
453
758
  update
454
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
+ }
455
769
  function getWhitelistService() {
456
770
  return strapi.plugin("strapi-plugin-oidc").service("whitelist");
457
771
  }
@@ -463,7 +777,8 @@ async function info(ctx) {
463
777
  useWhitelist: settings.useWhitelist,
464
778
  enforceOIDC: resolveEnforceOIDC(strapi, settings.enforceOIDC),
465
779
  enforceOIDCConfig: getEnforceOIDCConfig(strapi),
466
- whitelistUsers
780
+ whitelistUsers,
781
+ auditLogEnabled: isAuditLogEnabled()
467
782
  };
468
783
  }
469
784
  async function updateSettings(ctx) {
@@ -489,121 +804,85 @@ async function publicSettings(ctx) {
489
804
  };
490
805
  }
491
806
  async function register(ctx) {
492
- const { email, roles: roles2 } = ctx.request.body;
807
+ const { email } = ctx.request.body;
493
808
  if (!email) {
494
809
  ctx.body = { message: "Please enter a valid email address" };
495
810
  return;
496
811
  }
497
812
  const rawEmails = Array.isArray(email) ? email : email.split(",");
498
813
  const emailList = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
499
- const existingUsers = await strapi.query("admin::user").findMany({
500
- where: { email: { $in: emailList } },
501
- populate: ["roles"]
502
- });
503
- const existingUsersByEmail = new Map(existingUsers.map((u) => [u.email, u]));
504
814
  const whitelistService2 = getWhitelistService();
505
815
  let matchedExistingUsersCount = 0;
506
816
  for (const singleEmail of emailList) {
507
- const existingUser = existingUsersByEmail.get(singleEmail);
508
- let finalRoles = roles2;
509
- if (existingUser?.roles) {
510
- finalRoles = existingUser.roles.map((r) => String(r.id));
511
- matchedExistingUsersCount++;
512
- }
817
+ const existingUser = await strapi.query("admin::user").findOne({
818
+ where: { email: singleEmail }
819
+ });
820
+ if (existingUser) matchedExistingUsersCount++;
513
821
  const alreadyWhitelisted = await strapi.query("plugin::strapi-plugin-oidc.whitelists").findOne({
514
822
  where: { email: singleEmail }
515
823
  });
516
824
  if (!alreadyWhitelisted) {
517
- await whitelistService2.registerUser(singleEmail, finalRoles);
825
+ await whitelistService2.registerUser(singleEmail);
518
826
  }
519
827
  }
520
828
  ctx.body = { matchedExistingUsersCount };
521
829
  }
522
830
  async function removeEmail(ctx) {
523
- const { id } = ctx.params;
831
+ const { email } = ctx.params;
524
832
  const whitelistService2 = getWhitelistService();
525
- await whitelistService2.removeUser(id);
833
+ await whitelistService2.removeUser(email);
526
834
  ctx.body = {};
527
835
  }
528
836
  async function deleteAll(ctx) {
529
837
  await strapi.query("plugin::strapi-plugin-oidc.whitelists").deleteMany({});
530
838
  ctx.body = {};
531
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
+ }
532
848
  async function importUsers(ctx) {
533
849
  const { users } = ctx.request.body;
534
850
  if (!Array.isArray(users)) {
535
851
  ctx.status = 400;
536
- ctx.body = { error: "Expected { users: [{email, roles}] }" };
852
+ ctx.body = { error: "Expected { users: [{email}] }" };
537
853
  return;
538
854
  }
539
- const allRoles = await strapi.query("admin::role").findMany({});
540
- const roleNameToId = new Map(allRoles.map((r) => [r.name, String(r.id)]));
541
- const resolveRole = (nameOrId) => roleNameToId.get(nameOrId) ?? nameOrId;
542
- const normalized = users.filter((u) => u?.email).map((u) => ({
543
- email: String(u.email).trim().toLowerCase(),
544
- roles: (Array.isArray(u.roles) ? u.roles : []).map(resolveRole)
545
- }));
546
- const seen = /* @__PURE__ */ new Set();
547
- const deduped = normalized.filter((u) => {
548
- if (seen.has(u.email)) return false;
549
- seen.add(u.email);
550
- return true;
551
- });
552
- const strapiUsers = await strapi.query("admin::user").findMany({
553
- where: { email: { $in: deduped.map((u) => u.email) } },
554
- populate: ["roles"]
555
- });
556
- 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)];
557
857
  const whitelistService2 = getWhitelistService();
558
858
  const existing = await whitelistService2.getUsers();
559
859
  const existingEmails = new Set(existing.map((u) => u.email));
560
860
  let importedCount = 0;
561
- for (const user of deduped) {
562
- if (existingEmails.has(user.email)) continue;
563
- const strapiUser = strapiUserMap.get(user.email);
564
- const finalRoles = strapiUser?.roles?.length ? strapiUser.roles.map((r) => String(r.id)) : user.roles;
565
- await whitelistService2.registerUser(user.email, finalRoles);
861
+ for (const email of deduped) {
862
+ if (existingEmails.has(email)) continue;
863
+ await whitelistService2.registerUser(email);
566
864
  importedCount++;
567
865
  }
568
866
  ctx.body = { importedCount };
569
867
  }
570
868
  async function syncUsers(ctx) {
571
869
  const { users: rawUsers } = ctx.request.body;
572
- 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));
573
871
  const whitelistService2 = getWhitelistService();
574
872
  const currentUsers = await whitelistService2.getUsers();
575
- let matchedExistingUsersCount = 0;
576
- const emailsToSync = users.map((u) => u.email);
577
- const existingStrapiUsers = await strapi.query("admin::user").findMany({
578
- where: { email: { $in: emailsToSync } },
579
- populate: ["roles"]
580
- });
581
- const syncEmailSet = new Set(emailsToSync);
873
+ const syncEmailSet = new Set(emails);
582
874
  const currentUsersByEmail = new Map(currentUsers.map((u) => [u.email, u]));
583
- const strapiUsersByEmail = new Map(existingStrapiUsers.map((u) => [u.email, u]));
584
875
  for (const currUser of currentUsers) {
585
876
  if (!syncEmailSet.has(currUser.email)) {
586
- await whitelistService2.removeUser(currUser.id);
877
+ await whitelistService2.removeUser(currUser.email);
587
878
  }
588
879
  }
589
- for (const user of users) {
590
- const currUser = currentUsersByEmail.get(user.email);
591
- let finalRoles = user.roles;
592
- if (!currUser) {
593
- const existingStrapiUser = strapiUsersByEmail.get(user.email);
594
- if (existingStrapiUser?.roles) {
595
- finalRoles = existingStrapiUser.roles.map((r) => String(r.id));
596
- matchedExistingUsersCount++;
597
- }
598
- await whitelistService2.registerUser(user.email, finalRoles);
599
- } else {
600
- await strapi.query("plugin::strapi-plugin-oidc.whitelists").update({
601
- where: { id: currUser.id },
602
- data: { roles: finalRoles }
603
- });
880
+ for (const email of emails) {
881
+ if (!currentUsersByEmail.has(email)) {
882
+ await whitelistService2.registerUser(email);
604
883
  }
605
884
  }
606
- ctx.body = { matchedExistingUsersCount };
885
+ ctx.body = { matchedExistingUsersCount: 0 };
607
886
  }
608
887
  const whitelist = {
609
888
  info,
@@ -613,39 +892,99 @@ const whitelist = {
613
892
  removeEmail,
614
893
  deleteAll,
615
894
  syncUsers,
616
- 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
617
945
  };
618
946
  const controllers = {
619
947
  oidc,
620
948
  role,
621
- whitelist
949
+ whitelist,
950
+ auditLog
622
951
  };
623
952
  const rateLimitMap = /* @__PURE__ */ new Map();
624
953
  const RATE_LIMIT_WINDOW = 6e4;
625
- const MAX_REQUESTS = 20;
626
- const rateLimitMiddleware = async (ctx, next) => {
954
+ const MAX_REQUESTS = 1e3;
955
+ function getRateLimitKey(ctx) {
627
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);
628
963
  const now = Date.now();
629
964
  const windowStart = now - RATE_LIMIT_WINDOW;
630
- const requestStamps = (rateLimitMap.get(ip) || []).filter((timestamp) => timestamp > windowStart);
965
+ const requestStamps = (rateLimitMap.get(key) || []).filter(
966
+ (timestamp) => timestamp > windowStart
967
+ );
631
968
  if (requestStamps.length >= MAX_REQUESTS) {
632
969
  ctx.status = 429;
633
970
  ctx.body = "Too Many Requests";
634
971
  return;
635
972
  }
636
973
  requestStamps.push(now);
637
- rateLimitMap.set(ip, requestStamps);
638
- await next();
639
- };
640
- const adminPolicies = (action) => ({
641
- policies: [
642
- "admin::isAuthenticatedAdmin",
643
- {
644
- name: "admin::hasPermissions",
645
- config: { actions: [`plugin::strapi-plugin-oidc.${action}`] }
646
- }
647
- ]
648
- });
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
+ }
649
988
  const routes = {
650
989
  admin: {
651
990
  type: "admin",
@@ -718,7 +1057,7 @@ const routes = {
718
1057
  },
719
1058
  {
720
1059
  method: "DELETE",
721
- path: "/whitelist/:id",
1060
+ path: "/whitelist/:email",
722
1061
  handler: "whitelist.removeEmail",
723
1062
  config: adminPolicies("update")
724
1063
  },
@@ -727,6 +1066,30 @@ const routes = {
727
1066
  path: "/whitelist",
728
1067
  handler: "whitelist.deleteAll",
729
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"] }
730
1093
  }
731
1094
  ]
732
1095
  },
@@ -753,13 +1116,33 @@ const routes = {
753
1116
  },
754
1117
  {
755
1118
  method: "DELETE",
756
- path: "/whitelist/:id",
1119
+ path: "/whitelist/:email",
757
1120
  handler: "whitelist.removeEmail"
758
1121
  },
759
1122
  {
760
1123
  method: "DELETE",
761
1124
  path: "/whitelist",
762
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"
763
1146
  }
764
1147
  ]
765
1148
  }
@@ -1130,14 +1513,14 @@ function whitelistService({ strapi: strapi2 }) {
1130
1513
  async getUsers() {
1131
1514
  return getWhitelistQuery().findMany();
1132
1515
  },
1133
- async registerUser(email, roles2) {
1516
+ async registerUser(email) {
1134
1517
  await getWhitelistQuery().create({
1135
- data: { email, roles: roles2 }
1518
+ data: { email }
1136
1519
  });
1137
1520
  },
1138
- async removeUser(id) {
1139
- await getWhitelistQuery().delete({
1140
- where: { id }
1521
+ async removeUser(email) {
1522
+ await strapi2.db.query("plugin::strapi-plugin-oidc.whitelists").deleteMany({
1523
+ where: { email }
1141
1524
  });
1142
1525
  },
1143
1526
  async checkWhitelistForEmail(email) {
@@ -1155,10 +1538,73 @@ function whitelistService({ strapi: strapi2 }) {
1155
1538
  }
1156
1539
  };
1157
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
+ }
1158
1603
  const services = {
1159
1604
  oauth: oauthService,
1160
1605
  role: roleService,
1161
- whitelist: whitelistService
1606
+ whitelist: whitelistService,
1607
+ auditLog: auditLogService
1162
1608
  };
1163
1609
  const index = {
1164
1610
  register: register$1,