strapi-plugin-oidc 1.6.5 → 1.7.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.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
3
  const node_crypto = require("node:crypto");
4
4
  const pkceChallenge = require("pkce-challenge");
5
+ const node_stream = require("node:stream");
5
6
  const strapiUtils = require("@strapi/utils");
6
7
  const generator = require("generate-password");
7
8
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
@@ -36,6 +37,12 @@ function getRetentionDays() {
36
37
  function isAuditLogEnabled() {
37
38
  return getRetentionDays() !== 0;
38
39
  }
40
+ const PLUGIN_NAME = "strapi-plugin-oidc";
41
+ const getOauthService = () => strapi.plugin(PLUGIN_NAME).service("oauth");
42
+ const getRoleService = () => strapi.plugin(PLUGIN_NAME).service("role");
43
+ const getWhitelistService = () => strapi.plugin(PLUGIN_NAME).service("whitelist");
44
+ const getAuditLogService = () => strapi.plugin(PLUGIN_NAME).service("auditLog");
45
+ const getAdminUserService = () => strapi.service("admin::user");
39
46
  const AUTH_ROUTES = ["login", "register", "register-admin", "forgot-password", "reset-password"];
40
47
  async function bootstrap({ strapi: strapi2 }) {
41
48
  const adminUrl = strapi2.config.get("admin.url", "/admin");
@@ -47,7 +54,7 @@ async function bootstrap({ strapi: strapi2 }) {
47
54
  const isTokenRefresh = path === tokenRefreshPath;
48
55
  if (isAuthRoute && isPost || isTokenRefresh) {
49
56
  try {
50
- const whitelistService2 = strapi2.plugin("strapi-plugin-oidc").service("whitelist");
57
+ const whitelistService2 = getWhitelistService();
51
58
  const settings = await whitelistService2.getSettings();
52
59
  const enforceOIDC = resolveEnforceOIDC(strapi2, settings?.enforceOIDC);
53
60
  if (enforceOIDC && isAuthRoute && isPost) {
@@ -95,7 +102,7 @@ async function bootstrap({ strapi: strapi2 }) {
95
102
  const enforceOIDCConfig = getEnforceOIDCConfig(strapi2);
96
103
  if (enforceOIDCConfig !== null) {
97
104
  try {
98
- const whitelistService2 = strapi2.plugin("strapi-plugin-oidc").service("whitelist");
105
+ const whitelistService2 = getWhitelistService();
99
106
  const settings = await whitelistService2.getSettings();
100
107
  if (settings.enforceOIDC !== enforceOIDCConfig) {
101
108
  await whitelistService2.setSettings({ ...settings, enforceOIDC: enforceOIDCConfig });
@@ -125,7 +132,7 @@ async function bootstrap({ strapi: strapi2 }) {
125
132
  task: async () => {
126
133
  try {
127
134
  const retentionDays = getRetentionDays();
128
- await strapi2.plugin("strapi-plugin-oidc").service("auditLog").cleanup(retentionDays);
135
+ await getAuditLogService().cleanup(retentionDays);
129
136
  } catch (err) {
130
137
  strapi2.log.warn("[strapi-plugin-oidc] Audit log cleanup failed:", err.message);
131
138
  }
@@ -219,9 +226,14 @@ function getExpiredCookieOptions(strapi2, ctx) {
219
226
  function clearAuthCookies(strapi2, ctx) {
220
227
  const options2 = getExpiredCookieOptions(strapi2, ctx);
221
228
  ctx.cookies.set("strapi_admin_refresh", "", options2);
222
- ctx.cookies.set("oidc_authenticated", "", { ...options2, path: "/" });
223
- ctx.cookies.set("oidc_access_token", "", { ...options2, path: "/" });
224
- ctx.cookies.set("oidc_user_email", "", { ...options2, path: "/" });
229
+ const rootPathOptions = { ...options2, path: "/" };
230
+ for (const name of ["oidc_authenticated", "oidc_access_token", "oidc_user_email"]) {
231
+ ctx.cookies.set(name, "", rootPathOptions);
232
+ }
233
+ }
234
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
235
+ function isValidEmail(email) {
236
+ return EMAIL_REGEX.test(email);
225
237
  }
226
238
  const errorCodes = {
227
239
  TOKEN_EXCHANGE_FAILED: "TOKEN_EXCHANGE_FAILED",
@@ -272,6 +284,7 @@ const en = {
272
284
  "page.save.error": "Update failed.",
273
285
  "page.add": "Add",
274
286
  "page.cancel": "Cancel",
287
+ "common.remove": "Remove {label}",
275
288
  "page.ok": "OK",
276
289
  "roles.title": "Default Role(s)",
277
290
  "roles.placeholder": "Select default role(s)",
@@ -303,7 +316,7 @@ const en = {
303
316
  "enforce.config.info": "Enforcement is controlled by the OIDC_ENFORCE config variable and cannot be changed here.",
304
317
  "login.settings.title": "Login Settings",
305
318
  "login.sso": "Login via SSO",
306
- "whitelist.count": "{count, plural, one {# entry} other {# entries}}",
319
+ "pagination.total": "{count, plural, one {# entry} other {# entries}}",
307
320
  "whitelist.import": "Import",
308
321
  "whitelist.export": "Export",
309
322
  "whitelist.delete.all.label": "Delete All",
@@ -331,6 +344,20 @@ const en = {
331
344
  "auditlog.clear.success": "Audit logs cleared",
332
345
  "auditlog.clear.error": "Failed to clear audit logs",
333
346
  "auditlog.export.error": "Failed to export audit logs",
347
+ "auditlog.filters": "Filters",
348
+ "auditlog.filters.action": "Action",
349
+ "auditlog.filters.email": "Email",
350
+ "auditlog.filters.ip": "IP address",
351
+ "auditlog.filters.createdAt": "Date",
352
+ "auditlog.filters.clear": "Clear filters",
353
+ "auditlog.filters.empty": "No entries match the current filters",
354
+ "auditlog.calendar.prevMonth": "Previous month",
355
+ "auditlog.calendar.nextMonth": "Next month",
356
+ "auditlog.calendar.state.today": "today",
357
+ "auditlog.calendar.state.selected": "selected",
358
+ "auditlog.calendar.state.alreadyAdded": "already added",
359
+ "auditlog.calendar.state.future": "unavailable, future date",
360
+ "auditlog.calendar.dayWithState": "{date}, {state}",
334
361
  "auditlog.action.login_success": "User successfully authenticated via OIDC and was granted access.",
335
362
  "auditlog.action.user_created": "A new Strapi admin account was created for this user on their first OIDC login.",
336
363
  "auditlog.action.logout": "User logged out and their OIDC session was ended.",
@@ -341,21 +368,119 @@ const en = {
341
368
  "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.",
342
369
  "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
343
370
  "auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
371
+ "auth.page.authenticating.title": "Authenticating...",
372
+ "auth.page.authenticating.noscript.heading": "JavaScript Required",
373
+ "auth.page.authenticating.noscript.body": "JavaScript must be enabled for authentication to complete.",
374
+ "auth.page.error.title": "Authentication Failed",
375
+ "auth.page.error.returnToLogin": "Return to Login",
344
376
  "user.missing_code": "Authorisation code was not received from the OIDC provider.",
345
377
  "user.invalid_state": "State parameter mismatch. Please restart the login flow.",
346
378
  "user.signInError": "Authentication failed. Please try again.",
347
379
  "settings.section": "OIDC",
348
380
  "settings.configuration": "Configuration"
349
381
  };
350
- const userFacingMessages = {
351
- get missing_code() {
352
- return en["user.missing_code"];
382
+ const __vite_glob_0_0 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
383
+ __proto__: null,
384
+ default: en
385
+ }, Symbol.toStringTag, { value: "Module" }));
386
+ const modules = /* @__PURE__ */ Object.assign({ "../translations/locales/en.json": __vite_glob_0_0 });
387
+ const locales = Object.fromEntries(
388
+ Object.entries(modules).map(([path, mod]) => {
389
+ const code = path.match(/\/([^/]+)\.json$/)?.[1];
390
+ return [code ?? "", mod.default];
391
+ })
392
+ );
393
+ Object.keys(locales).filter(Boolean);
394
+ const DEFAULT_LOCALE = "en";
395
+ function parseAcceptLanguage(header) {
396
+ return header.split(",").map((part) => {
397
+ const [tag, ...params] = part.trim().split(";");
398
+ const qParam = params.find((p) => p.trim().startsWith("q="));
399
+ const q = qParam ? parseFloat(qParam.trim().slice(2)) : 1;
400
+ return { tag: tag.toLowerCase(), q: Number.isFinite(q) ? q : 1 };
401
+ }).filter((entry) => entry.tag).sort((a, b) => b.q - a.q);
402
+ }
403
+ function negotiateLocale(acceptLanguage) {
404
+ if (!acceptLanguage) return DEFAULT_LOCALE;
405
+ for (const { tag } of parseAcceptLanguage(acceptLanguage)) {
406
+ if (locales[tag]) return tag;
407
+ const base = tag.split("-")[0];
408
+ if (locales[base]) return base;
409
+ }
410
+ return DEFAULT_LOCALE;
411
+ }
412
+ function t(locale, key, fallback) {
413
+ return locales[locale]?.[key] ?? locales[DEFAULT_LOCALE]?.[key] ?? fallback ?? key;
414
+ }
415
+ const userFacingMessages = (locale) => ({
416
+ missing_code: t(
417
+ locale,
418
+ "user.missing_code",
419
+ "Authorisation code was not received from the OIDC provider."
420
+ ),
421
+ invalid_state: t(
422
+ locale,
423
+ "user.invalid_state",
424
+ "State parameter mismatch. Please restart the login flow."
425
+ ),
426
+ signInError: t(locale, "user.signInError", "Authentication failed. Please try again.")
427
+ });
428
+ const authPageMessages = (locale) => ({
429
+ authenticatingTitle: t(locale, "auth.page.authenticating.title", "Authenticating..."),
430
+ noscriptHeading: t(locale, "auth.page.authenticating.noscript.heading", "JavaScript Required"),
431
+ noscriptBody: t(
432
+ locale,
433
+ "auth.page.authenticating.noscript.body",
434
+ "JavaScript must be enabled for authentication to complete."
435
+ ),
436
+ errorTitle: t(locale, "auth.page.error.title", "Authentication Failed"),
437
+ returnToLogin: t(locale, "auth.page.error.returnToLogin", "Return to Login")
438
+ });
439
+ class OidcError extends Error {
440
+ kind;
441
+ cause;
442
+ constructor(kind, message, cause) {
443
+ super(message);
444
+ this.name = "OidcError";
445
+ this.kind = kind;
446
+ this.cause = cause;
447
+ }
448
+ }
449
+ const OIDC_ERROR_DISPATCH = {
450
+ nonce_mismatch: { action: "nonce_mismatch", code: errorCodes.NONCE_MISMATCH },
451
+ token_exchange_failed: {
452
+ action: "token_exchange_failed",
453
+ code: errorCodes.TOKEN_EXCHANGE_FAILED
454
+ },
455
+ id_token_parse_failed: {
456
+ action: "login_failure",
457
+ code: errorCodes.ID_TOKEN_PARSE_FAILED,
458
+ key: "id_token_parse_failed"
353
459
  },
354
- get invalid_state() {
355
- return en["user.invalid_state"];
460
+ userinfo_fetch_failed: {
461
+ action: "login_failure",
462
+ code: errorCodes.USERINFO_FETCH_FAILED,
463
+ key: "userinfo_fetch_failed"
356
464
  },
357
- get signInError() {
358
- return en["user.signInError"];
465
+ user_creation_failed: {
466
+ action: "login_failure",
467
+ code: errorCodes.USER_CREATION_FAILED,
468
+ key: "user_creation_failed"
469
+ },
470
+ whitelist_rejected: {
471
+ action: "whitelist_rejected",
472
+ code: errorCodes.WHITELIST_CHECK_FAILED,
473
+ key: "whitelist_rejected"
474
+ },
475
+ invalid_email: {
476
+ action: "login_failure",
477
+ code: errorCodes.TOKEN_EXCHANGE_FAILED,
478
+ key: "sign_in_unknown"
479
+ },
480
+ unknown: {
481
+ action: "login_failure",
482
+ code: errorCodes.TOKEN_EXCHANGE_FAILED,
483
+ key: "sign_in_unknown"
359
484
  }
360
485
  };
361
486
  const REQUIRED_CONFIG_KEYS = [
@@ -370,6 +495,7 @@ const REQUIRED_CONFIG_KEYS = [
370
495
  "OIDC_GIVEN_NAME_FIELD",
371
496
  "OIDC_AUTHORIZATION_ENDPOINT"
372
497
  ];
498
+ const LOGOUT_USERINFO_TIMEOUT_MS = 3e3;
373
499
  function configValidation() {
374
500
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
375
501
  const missing = REQUIRED_CONFIG_KEYS.filter((key) => !config2[key]);
@@ -387,7 +513,6 @@ async function oidcSignIn(ctx) {
387
513
  const cookieOptions = {
388
514
  httpOnly: true,
389
515
  maxAge: 6e5,
390
- // 10 minutes
391
516
  secure: isProduction && ctx.request.secure,
392
517
  sameSite: "lax"
393
518
  };
@@ -417,7 +542,7 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
417
542
  }
418
543
  });
419
544
  if (!response.ok) {
420
- throw new Error(errorMessages.TOKEN_EXCHANGE_FAILED);
545
+ throw new OidcError("token_exchange_failed", errorMessages.TOKEN_EXCHANGE_FAILED);
421
546
  }
422
547
  const tokenData = await response.json();
423
548
  if (tokenData.id_token) {
@@ -425,23 +550,23 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
425
550
  const payloadB64 = tokenData.id_token.split(".")[1];
426
551
  const idTokenPayload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
427
552
  if (idTokenPayload.nonce !== expectedNonce) {
428
- throw new Error(errorMessages.NONCE_MISMATCH);
553
+ throw new OidcError("nonce_mismatch", errorMessages.NONCE_MISMATCH);
429
554
  }
430
555
  } catch (e) {
431
- if (e.message === "Nonce mismatch") throw e;
432
- throw new Error(errorMessages.ID_TOKEN_PARSE_FAILED);
556
+ if (e instanceof OidcError && e.kind === "nonce_mismatch") throw e;
557
+ throw new OidcError("id_token_parse_failed", errorMessages.ID_TOKEN_PARSE_FAILED, e);
433
558
  }
434
559
  }
435
560
  const userResponse = await fetch(config2.OIDC_USERINFO_ENDPOINT, {
436
561
  headers: { Authorization: `Bearer ${tokenData.access_token}` }
437
562
  });
438
563
  if (!userResponse.ok) {
439
- throw new Error(errorMessages.USERINFO_FETCH_FAILED);
564
+ throw new OidcError("userinfo_fetch_failed", errorMessages.USERINFO_FETCH_FAILED);
440
565
  }
441
566
  const userInfo = await userResponse.json();
442
567
  return { userInfo, accessToken: tokenData.access_token };
443
568
  }
444
- function resolveRolesFromGroups(userInfo, config2, availableRoles) {
569
+ function collectGroupMapRoleNames(userInfo, config2) {
445
570
  const rawGroups = userInfo[config2.OIDC_GROUP_FIELD];
446
571
  if (!Array.isArray(rawGroups) || rawGroups.length === 0) return [];
447
572
  const groups = rawGroups.filter((g) => typeof g === "string");
@@ -452,22 +577,15 @@ function resolveRolesFromGroups(userInfo, config2, availableRoles) {
452
577
  } catch {
453
578
  return [];
454
579
  }
455
- const roleIdSet = /* @__PURE__ */ new Set();
580
+ const roleNameSet = /* @__PURE__ */ new Set();
456
581
  for (const group of groups) {
457
582
  const roleNames = groupRoleMap[group];
458
583
  if (!roleNames) continue;
459
584
  for (const name of roleNames) {
460
- const match = availableRoles.find((r) => r.name === name);
461
- if (match) roleIdSet.add(String(match.id));
585
+ roleNameSet.add(name);
462
586
  }
463
587
  }
464
- return [...roleIdSet];
465
- }
466
- async function resolveRoles(userInfo, config2, roleService2, availableRoles) {
467
- const groupRoles = resolveRolesFromGroups(userInfo, config2, availableRoles);
468
- if (groupRoles.length > 0) return { roles: groupRoles, fromGroupMapping: true };
469
- const oidcRoles = await roleService2.oidcRoles();
470
- return { roles: oidcRoles?.roles || [], fromGroupMapping: false };
588
+ return [...roleNameSet];
471
589
  }
472
590
  async function registerNewUser(oauthService2, email, userResponseData, config2, ctx, roles2) {
473
591
  const defaultLocale = oauthService2.localeFindByHeader(
@@ -485,10 +603,7 @@ async function registerNewUser(oauthService2, email, userResponseData, config2,
485
603
  }
486
604
  function rolesChanged(current, next) {
487
605
  if (current.size !== next.size) return true;
488
- for (const id of next) {
489
- if (!current.has(id)) return true;
490
- }
491
- return false;
606
+ return [...next].some((id) => !current.has(id));
492
607
  }
493
608
  async function updateUserRoles(user, currentRoleIds, newRoleIds) {
494
609
  try {
@@ -511,101 +626,187 @@ async function updateUserRoles(user, currentRoleIds, newRoleIds) {
511
626
  throw updateErr;
512
627
  }
513
628
  }
629
+ async function resolveRolesFromGroups(candidateNames) {
630
+ const matchedRoles = await strapi.db.query("admin::role").findMany({
631
+ where: { name: { $in: candidateNames } },
632
+ select: ["id", "name"]
633
+ });
634
+ const nameToId = new Map(matchedRoles.map((r) => [r.name, String(r.id)]));
635
+ const roles2 = [];
636
+ for (const name of candidateNames) {
637
+ const id = nameToId.get(name);
638
+ if (id) roles2.push(id);
639
+ }
640
+ return {
641
+ roles: roles2,
642
+ fromGroupMapping: true,
643
+ resolvedRoleNames: matchedRoles.map((r) => r.name)
644
+ };
645
+ }
646
+ async function resolveRolesFromDefaults(roleService2) {
647
+ const oidcRolesResult = await roleService2.oidcRoles();
648
+ const roles2 = oidcRolesResult?.roles || [];
649
+ if (roles2.length === 0) {
650
+ return { roles: roles2, fromGroupMapping: false, resolvedRoleNames: [] };
651
+ }
652
+ const records = await strapi.db.query("admin::role").findMany({
653
+ where: { id: { $in: roles2.map(Number) } },
654
+ select: ["id", "name"]
655
+ });
656
+ return {
657
+ roles: roles2,
658
+ fromGroupMapping: false,
659
+ resolvedRoleNames: records.map((r) => r.name)
660
+ };
661
+ }
662
+ async function resolveRoles(userResponseData, config2, roleService2) {
663
+ const candidateNames = collectGroupMapRoleNames(userResponseData, config2);
664
+ if (candidateNames.length > 0) {
665
+ return resolveRolesFromGroups(candidateNames);
666
+ }
667
+ return resolveRolesFromDefaults(roleService2);
668
+ }
669
+ async function ensureUser(userService, oauthService2, email, userResponseData, config2, ctx, resolved) {
670
+ const existing = await userService.findOneByEmail(email, ["roles"]);
671
+ if (!existing) {
672
+ try {
673
+ const user = await registerNewUser(
674
+ oauthService2,
675
+ email,
676
+ userResponseData,
677
+ config2,
678
+ ctx,
679
+ resolved.roles
680
+ );
681
+ return { user, userCreated: true, rolesUpdated: true };
682
+ } catch (e) {
683
+ const msg = e instanceof Error ? e.message : String(e);
684
+ throw new OidcError("user_creation_failed", msg, e);
685
+ }
686
+ }
687
+ if (!resolved.fromGroupMapping || resolved.roles.length === 0) {
688
+ return { user: existing, userCreated: false, rolesUpdated: false };
689
+ }
690
+ const currentRoleIds = new Set((existing.roles ?? []).map((r) => String(r.id)));
691
+ if (!rolesChanged(currentRoleIds, new Set(resolved.roles))) {
692
+ return { user: existing, userCreated: false, rolesUpdated: false };
693
+ }
694
+ await updateUserRoles(existing, currentRoleIds, resolved.roles);
695
+ return { user: existing, userCreated: false, rolesUpdated: true };
696
+ }
514
697
  async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
515
- const rawEmail = String(userResponseData.email ?? "");
516
- const email = rawEmail.toLowerCase();
517
- if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
518
- throw new Error(errorMessages.INVALID_EMAIL);
698
+ const email = String(userResponseData.email ?? "").toLowerCase();
699
+ if (!email || !isValidEmail(email)) {
700
+ throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
519
701
  }
520
702
  await whitelistService2.checkWhitelistForEmail(email);
521
- const allRoles = await strapi.db.query("admin::role").findMany();
522
- const { roles: roles2, fromGroupMapping } = await resolveRoles(
703
+ const resolved = await resolveRoles(userResponseData, config2, roleService2);
704
+ const { user, userCreated, rolesUpdated } = await ensureUser(
705
+ userService,
706
+ oauthService2,
707
+ email,
523
708
  userResponseData,
524
709
  config2,
525
- roleService2,
526
- allRoles
710
+ ctx,
711
+ resolved
527
712
  );
528
- const resolvedRoleNames = allRoles.filter((r) => roles2.includes(String(r.id))).map((r) => r.name);
529
- let userCreated = false;
530
- let rolesUpdated = false;
531
- let user = await userService.findOneByEmail(email, ["roles"]);
532
- if (!user) {
533
- user = await registerNewUser(oauthService2, email, userResponseData, config2, ctx, roles2);
534
- userCreated = true;
535
- rolesUpdated = true;
536
- } else if (fromGroupMapping && roles2.length > 0) {
537
- const currentRoleIds = new Set(user.roles.map((r) => String(r.id)));
538
- if (rolesChanged(currentRoleIds, new Set(roles2))) {
539
- await updateUserRoles(user, currentRoleIds, roles2);
540
- rolesUpdated = true;
541
- }
542
- }
543
713
  const jwtToken = await oauthService2.generateToken(user, ctx);
544
714
  oauthService2.triggerSignInSuccess(user);
545
- return { activateUser: user, jwtToken, userCreated, rolesUpdated, resolvedRoleNames };
546
- }
547
- function classifyOidcError(msg, userInfo) {
548
- if (msg.includes("whitelist")) {
549
- return {
550
- action: "whitelist_rejected",
551
- code: errorCodes.WHITELIST_CHECK_FAILED,
552
- key: "whitelist_rejected"
553
- };
554
- }
555
- if (msg === "Nonce mismatch")
556
- return { action: "nonce_mismatch", code: errorCodes.NONCE_MISMATCH };
557
- if (msg === "Token exchange failed")
558
- return { action: "token_exchange_failed", code: errorCodes.TOKEN_EXCHANGE_FAILED };
559
- if (msg === "Failed to fetch user info") {
560
- return {
561
- action: "login_failure",
562
- code: errorCodes.USERINFO_FETCH_FAILED,
563
- key: "userinfo_fetch_failed"
564
- };
565
- }
566
- if (msg === "Failed to parse ID token") {
567
- return {
568
- action: "login_failure",
569
- code: errorCodes.ID_TOKEN_PARSE_FAILED,
570
- key: "id_token_parse_failed",
571
- params: { error: msg }
572
- };
573
- }
574
- if (msg === "User creation failed" || msg.includes("createUser")) {
575
- return {
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
715
  return {
583
- action: "login_failure",
584
- code: errorCodes.TOKEN_EXCHANGE_FAILED,
585
- key: "sign_in_unknown",
586
- params: { error: msg || "unknown" }
716
+ activateUser: user,
717
+ jwtToken,
718
+ userCreated,
719
+ rolesUpdated,
720
+ resolvedRoleNames: resolved.resolvedRoleNames
587
721
  };
588
722
  }
589
- async function oidcSignInCallback(ctx) {
590
- const config2 = configValidation();
591
- const userService = strapi.service("admin::user");
592
- const oauthService2 = strapi.plugin("strapi-plugin-oidc").service("oauth");
593
- const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
594
- const whitelistService2 = strapi.plugin("strapi-plugin-oidc").service("whitelist");
595
- const auditLog2 = strapi.plugin("strapi-plugin-oidc").service("auditLog");
596
- if (!ctx.query.code) {
597
- await auditLog2.log({ action: "missing_code", ip: ctx.ip });
598
- return ctx.send(oauthService2.renderSignUpError(userFacingMessages.missing_code));
723
+ function classifyOidcError(e, userInfo) {
724
+ const kind = e instanceof OidcError ? e.kind : "unknown";
725
+ const dispatch = OIDC_ERROR_DISPATCH[kind];
726
+ const msg = e instanceof Error ? e.message : String(e);
727
+ let params;
728
+ if (kind === "id_token_parse_failed" || kind === "unknown") {
729
+ params = { error: msg };
730
+ } else if (kind === "user_creation_failed" && userInfo?.email) {
731
+ params = { email: userInfo.email, error: msg };
599
732
  }
733
+ return {
734
+ action: dispatch.action,
735
+ code: dispatch.code,
736
+ key: dispatch.key,
737
+ params
738
+ };
739
+ }
740
+ function readAndClearPkceCookies(ctx) {
600
741
  const oidcState = ctx.cookies.get("oidc_state");
601
742
  const codeVerifier = ctx.cookies.get("oidc_code_verifier");
602
743
  const oidcNonce = ctx.cookies.get("oidc_nonce");
603
744
  ctx.cookies.set("oidc_state", null);
604
745
  ctx.cookies.set("oidc_code_verifier", null);
605
746
  ctx.cookies.set("oidc_nonce", null);
747
+ return { oidcState, codeVerifier, oidcNonce };
748
+ }
749
+ async function logSuccessfulAuth(auditLog2, ctx, user, userCreated, rolesUpdated, resolvedRoleNames) {
750
+ const roles2 = resolvedRoleNames.join(", ");
751
+ const entries = [
752
+ auditLog2.log({
753
+ action: "login_success",
754
+ email: user.email,
755
+ ip: ctx.ip,
756
+ detailsKey: rolesUpdated ? "roles_updated" : void 0,
757
+ detailsParams: rolesUpdated ? { roles: roles2 } : void 0
758
+ })
759
+ ];
760
+ if (userCreated) {
761
+ entries.push(
762
+ auditLog2.log({
763
+ action: "user_created",
764
+ email: user.email,
765
+ ip: ctx.ip,
766
+ detailsKey: "user_created",
767
+ detailsParams: { roles: roles2 }
768
+ })
769
+ );
770
+ }
771
+ await Promise.all(entries);
772
+ }
773
+ async function handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx) {
774
+ const errorInfo = classifyOidcError(e, userInfo);
775
+ const message = e instanceof Error ? e.message : String(e);
776
+ await auditLog2.log({
777
+ action: errorInfo.action,
778
+ email: userInfo?.email,
779
+ ip: ctx.ip,
780
+ detailsKey: errorInfo.action,
781
+ detailsParams: errorInfo.action === "login_failure" ? { message } : void 0
782
+ });
783
+ strapi.log.error({
784
+ code: errorInfo.code,
785
+ phase: "oidc_callback",
786
+ message: e instanceof Error ? e.message : "Unknown sign-in error",
787
+ detail: errorInfo.key ? getErrorDetail(errorInfo.key, errorInfo.params) : void 0,
788
+ email: userInfo?.email
789
+ });
790
+ const locale = negotiateLocale(ctx.request.headers["accept-language"]);
791
+ ctx.send(oauthService2.renderSignUpError(userFacingMessages(locale).signInError, locale));
792
+ }
793
+ async function oidcSignInCallback(ctx) {
794
+ const config2 = configValidation();
795
+ const oauthService2 = getOauthService();
796
+ const auditLog2 = getAuditLogService();
797
+ const locale = negotiateLocale(ctx.request.headers["accept-language"]);
798
+ if (!ctx.query.code) {
799
+ await auditLog2.log({ action: "missing_code", ip: ctx.ip });
800
+ return ctx.send(
801
+ oauthService2.renderSignUpError(userFacingMessages(locale).missing_code, locale)
802
+ );
803
+ }
804
+ const { oidcState, codeVerifier, oidcNonce } = readAndClearPkceCookies(ctx);
606
805
  if (!ctx.query.state || ctx.query.state !== oidcState) {
607
806
  await auditLog2.log({ action: "state_mismatch", ip: ctx.ip });
608
- return ctx.send(oauthService2.renderSignUpError(userFacingMessages.invalid_state));
807
+ return ctx.send(
808
+ oauthService2.renderSignUpError(userFacingMessages(locale).invalid_state, locale)
809
+ );
609
810
  }
610
811
  const params = new URLSearchParams({
611
812
  code: ctx.query.code,
@@ -619,107 +820,81 @@ async function oidcSignInCallback(ctx) {
619
820
  try {
620
821
  const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
621
822
  userInfo = exchangeResult.userInfo;
622
- const accessToken = exchangeResult.accessToken;
623
823
  const isProduction = strapi.config.get("environment") === "production";
624
- ctx.cookies.set("oidc_access_token", accessToken, {
824
+ const secureFlag = isProduction && ctx.request.secure;
825
+ ctx.cookies.set("oidc_access_token", exchangeResult.accessToken, {
625
826
  httpOnly: true,
626
827
  maxAge: 3e5,
627
- // 5 minutes — matches typical provider access token lifetime
628
- secure: isProduction && ctx.request.secure,
828
+ secure: secureFlag,
629
829
  sameSite: "lax"
630
830
  });
631
831
  const { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames } = await handleUserAuthentication(
632
- userService,
832
+ getAdminUserService(),
633
833
  oauthService2,
634
- roleService2,
635
- whitelistService2,
834
+ getRoleService(),
835
+ getWhitelistService(),
636
836
  userInfo,
637
837
  config2,
638
838
  ctx
639
839
  );
640
- const identityCookieOptions = {
840
+ ctx.cookies.set("oidc_user_email", activateUser.email, {
641
841
  httpOnly: true,
642
842
  path: "/",
643
- secure: isProduction && ctx.request.secure,
843
+ secure: secureFlag,
644
844
  sameSite: "lax"
645
- };
646
- ctx.cookies.set("oidc_user_email", activateUser.email, identityCookieOptions);
647
- if (userCreated) {
648
- await auditLog2.log({
649
- action: "user_created",
650
- email: activateUser.email,
651
- ip: ctx.ip,
652
- detailsKey: "user_created",
653
- detailsParams: { roles: resolvedRoleNames.join(", ") }
654
- });
655
- }
656
- await auditLog2.log({
657
- action: "login_success",
658
- email: activateUser.email,
659
- ip: ctx.ip,
660
- detailsKey: rolesUpdated ? "roles_updated" : void 0,
661
- detailsParams: rolesUpdated ? { roles: resolvedRoleNames.join(", ") } : void 0
662
845
  });
846
+ await logSuccessfulAuth(
847
+ auditLog2,
848
+ ctx,
849
+ activateUser,
850
+ userCreated,
851
+ rolesUpdated,
852
+ resolvedRoleNames
853
+ );
663
854
  const nonce = node_crypto.randomUUID();
664
- const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
665
855
  ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
666
- ctx.send(html);
856
+ ctx.send(oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce, locale));
667
857
  } catch (e) {
668
- const msg = e.message ?? "";
669
- const errorInfo = classifyOidcError(msg, userInfo);
670
- await auditLog2.log({
671
- action: errorInfo.action,
672
- email: userInfo?.email,
673
- ip: ctx.ip,
674
- detailsKey: errorInfo.action,
675
- detailsParams: errorInfo.action === "login_failure" ? { message: msg } : void 0
676
- });
677
- strapi.log.error({
678
- code: errorInfo.code,
679
- phase: "oidc_callback",
680
- message: msg || "Unknown sign-in error",
681
- detail: errorInfo.key ? getErrorDetail(errorInfo.key, errorInfo.params) : void 0,
682
- email: userInfo?.email
858
+ await handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx);
859
+ }
860
+ }
861
+ async function isProviderSessionActive(userinfoEndpoint, accessToken) {
862
+ try {
863
+ const response = await fetch(userinfoEndpoint, {
864
+ headers: { Authorization: `Bearer ${accessToken}` },
865
+ signal: AbortSignal.timeout(LOGOUT_USERINFO_TIMEOUT_MS)
683
866
  });
684
- ctx.send(oauthService2.renderSignUpError(userFacingMessages.signInError));
867
+ return response.ok;
868
+ } catch {
869
+ return false;
685
870
  }
686
871
  }
687
872
  async function logout(ctx) {
688
873
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
689
- const auditLog2 = strapi.plugin("strapi-plugin-oidc").service("auditLog");
874
+ const auditLog2 = getAuditLogService();
690
875
  const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
691
876
  const adminPanelUrl = strapi.config.get("admin.url", "/admin");
877
+ const loginUrl = `${adminPanelUrl}/auth/login`;
692
878
  const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
693
879
  const accessToken = ctx.cookies.get("oidc_access_token");
694
880
  const userEmail = ctx.cookies.get("oidc_user_email") ?? void 0;
695
881
  clearAuthCookies(strapi, ctx);
696
- if (logoutUrl && isOidcSession && accessToken) {
697
- try {
698
- const response = await fetch(config2.OIDC_USERINFO_ENDPOINT, {
699
- headers: { Authorization: `Bearer ${accessToken}` }
882
+ if (!isOidcSession) {
883
+ return ctx.redirect(loginUrl);
884
+ }
885
+ const logAudit = (action) => userEmail ? auditLog2.log({ action, email: userEmail, ip: ctx.ip }) : Promise.resolve();
886
+ if (logoutUrl && accessToken) {
887
+ const active = await isProviderSessionActive(config2.OIDC_USERINFO_ENDPOINT, accessToken);
888
+ if (active) {
889
+ logAudit("logout").catch(() => {
700
890
  });
701
- if (response.ok) {
702
- if (userEmail)
703
- auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip }).catch(() => {
704
- });
705
- return ctx.redirect(logoutUrl);
706
- }
707
- if (userEmail)
708
- await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
709
- return ctx.redirect(`${adminPanelUrl}/auth/login`);
710
- } catch {
711
- if (userEmail)
712
- await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
713
- return ctx.redirect(`${adminPanelUrl}/auth/login`);
891
+ return ctx.redirect(logoutUrl);
714
892
  }
893
+ await logAudit("session_expired");
894
+ return ctx.redirect(loginUrl);
715
895
  }
716
- if (isOidcSession && userEmail) {
717
- await auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip });
718
- }
719
- if (logoutUrl && isOidcSession) {
720
- return ctx.redirect(logoutUrl);
721
- }
722
- ctx.redirect(`${adminPanelUrl}/auth/login`);
896
+ await logAudit("logout");
897
+ ctx.redirect(logoutUrl || loginUrl);
723
898
  }
724
899
  const oidc = {
725
900
  oidcSignIn,
@@ -727,7 +902,7 @@ const oidc = {
727
902
  logout
728
903
  };
729
904
  async function find$1(ctx) {
730
- const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
905
+ const roleService2 = getRoleService();
731
906
  const roles2 = await roleService2.find();
732
907
  const oidcConstants = roleService2.getOidcRoles();
733
908
  for (const oidc2 of oidcConstants) {
@@ -741,7 +916,7 @@ async function find$1(ctx) {
741
916
  async function update(ctx) {
742
917
  try {
743
918
  const { roles: roles2 } = ctx.request.body;
744
- const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
919
+ const roleService2 = getRoleService();
745
920
  await roleService2.update(roles2);
746
921
  ctx.send({}, 204);
747
922
  } catch (e) {
@@ -762,8 +937,17 @@ function formatDatetimeForFilename(date) {
762
937
  const seconds = String(date.getSeconds()).padStart(2, "0");
763
938
  return `${year}${month}${day}_${hours}${minutes}${seconds}`;
764
939
  }
765
- function getWhitelistService() {
766
- return strapi.plugin("strapi-plugin-oidc").service("whitelist");
940
+ function setJsonAttachmentHeaders(ctx, basename) {
941
+ const datetime = formatDatetimeForFilename(/* @__PURE__ */ new Date());
942
+ ctx.set("Content-Type", "application/json");
943
+ ctx.set("Content-Disposition", `attachment; filename="${basename}-${datetime}.json"`);
944
+ }
945
+ function setNdjsonAttachmentHeaders(ctx, basename) {
946
+ const datetime = formatDatetimeForFilename(/* @__PURE__ */ new Date());
947
+ ctx.set("Content-Type", "application/x-ndjson; charset=utf-8");
948
+ ctx.set("Content-Disposition", `attachment; filename="${basename}-${datetime}.ndjson"`);
949
+ ctx.set("Cache-Control", "no-store");
950
+ ctx.set("X-Content-Type-Options", "nosniff");
767
951
  }
768
952
  async function info(ctx) {
769
953
  const whitelistService2 = getWhitelistService();
@@ -778,8 +962,9 @@ async function info(ctx) {
778
962
  };
779
963
  }
780
964
  async function updateSettings(ctx) {
781
- const { useWhitelist } = ctx.request.body;
782
- let { enforceOIDC } = ctx.request.body;
965
+ const body = ctx.request.body;
966
+ const { useWhitelist } = body;
967
+ let { enforceOIDC } = body;
783
968
  const whitelistService2 = getWhitelistService();
784
969
  if (useWhitelist && enforceOIDC) {
785
970
  const users = await whitelistService2.getUsers();
@@ -808,11 +993,9 @@ async function register(ctx) {
808
993
  const rawEmails = Array.isArray(email) ? email : email.split(",");
809
994
  const emailList = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
810
995
  const whitelistService2 = getWhitelistService();
811
- let matchedExistingUsersCount = 0;
996
+ const matchedExistingUsersCount = await whitelistService2.countAdminUsersByEmails(emailList);
812
997
  for (const singleEmail of emailList) {
813
- const existingUser = await strapi.query("admin::user").findOne({ where: { email: singleEmail } });
814
- if (existingUser) matchedExistingUsersCount++;
815
- const alreadyWhitelisted = await strapi.query("plugin::strapi-plugin-oidc.whitelists").findOne({ where: { email: singleEmail } });
998
+ const alreadyWhitelisted = await whitelistService2.hasUser(singleEmail);
816
999
  if (!alreadyWhitelisted) {
817
1000
  await whitelistService2.registerUser(singleEmail);
818
1001
  }
@@ -826,13 +1009,12 @@ async function removeEmail(ctx) {
826
1009
  ctx.body = {};
827
1010
  }
828
1011
  async function deleteAll(ctx) {
829
- await strapi.query("plugin::strapi-plugin-oidc.whitelists").deleteMany({});
1012
+ const whitelistService2 = getWhitelistService();
1013
+ await whitelistService2.deleteAllUsers();
830
1014
  ctx.body = {};
831
1015
  }
832
1016
  async function exportWhitelist(ctx) {
833
- const datetime = formatDatetimeForFilename(/* @__PURE__ */ new Date());
834
- ctx.set("Content-Type", "application/json");
835
- ctx.set("Content-Disposition", `attachment; filename="strapi-oidc-whitelist-${datetime}.json"`);
1017
+ setJsonAttachmentHeaders(ctx, "strapi-oidc-whitelist");
836
1018
  const whitelistService2 = getWhitelistService();
837
1019
  const users = await whitelistService2.getUsers();
838
1020
  ctx.body = users.map((u) => ({ email: u.email }));
@@ -844,7 +1026,7 @@ async function importUsers(ctx) {
844
1026
  ctx.body = { error: "Expected { users: [{email}] }" };
845
1027
  return;
846
1028
  }
847
- const normalized = users.filter((u) => u?.email).map((u) => String(u.email).trim().toLowerCase()).filter((email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
1029
+ const normalized = users.filter((u) => u?.email).map((u) => String(u.email).trim().toLowerCase()).filter(isValidEmail);
848
1030
  const deduped = [...new Set(normalized)];
849
1031
  const whitelistService2 = getWhitelistService();
850
1032
  const existing = await whitelistService2.getUsers();
@@ -859,7 +1041,7 @@ async function importUsers(ctx) {
859
1041
  }
860
1042
  async function syncUsers(ctx) {
861
1043
  const { users: rawUsers } = ctx.request.body;
862
- const emails = rawUsers.map((u) => String(u.email).toLowerCase()).filter((e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e));
1044
+ const emails = rawUsers.map((u) => String(u.email).toLowerCase()).filter(isValidEmail);
863
1045
  const whitelistService2 = getWhitelistService();
864
1046
  const currentUsers = await whitelistService2.getUsers();
865
1047
  const syncEmailSet = new Set(emails);
@@ -887,44 +1069,194 @@ const whitelist = {
887
1069
  importUsers,
888
1070
  exportWhitelist
889
1071
  };
890
- function getAuditLogService() {
891
- return strapi.plugin("strapi-plugin-oidc").service("auditLog");
1072
+ const AUDIT_ACTIONS = [
1073
+ "login_success",
1074
+ "login_failure",
1075
+ "missing_code",
1076
+ "state_mismatch",
1077
+ "nonce_mismatch",
1078
+ "token_exchange_failed",
1079
+ "whitelist_rejected",
1080
+ "logout",
1081
+ "session_expired",
1082
+ "user_created"
1083
+ ];
1084
+ const ISO_UTC_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
1085
+ function isIsoUtcDatetime(value) {
1086
+ return typeof value === "string" && ISO_UTC_DATETIME.test(value);
892
1087
  }
893
- async function find(ctx) {
894
- const page = Math.max(1, Number(ctx.query.page) || 1);
895
- const pageSize = Math.min(100, Math.max(1, Number(ctx.query.pageSize) || 25));
896
- ctx.body = await getAuditLogService().find({ page, pageSize });
1088
+ const ALLOWED_FIELDS = /* @__PURE__ */ new Set(["action", "email", "ip", "createdAt"]);
1089
+ const STRING_OPERATORS = /* @__PURE__ */ new Set([
1090
+ "$eq",
1091
+ "$contains",
1092
+ "$endsWith",
1093
+ "$null",
1094
+ "$notNull"
1095
+ ]);
1096
+ const DATE_OPERATORS = /* @__PURE__ */ new Set(["$gte", "$lt", "$lte", "$between", "$in"]);
1097
+ const ENUM_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$in"]);
1098
+ function isPlainObject(value) {
1099
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1100
+ const proto = Object.getPrototypeOf(value);
1101
+ return proto === Object.prototype || proto === null;
897
1102
  }
898
- async function exportLogs(ctx) {
899
- const datetime = formatDatetimeForFilename(/* @__PURE__ */ new Date());
900
- ctx.set("Content-Type", "application/json");
901
- ctx.set("Content-Disposition", `attachment; filename="strapi-oidc-audit-log-${datetime}.json"`);
902
- const service = getAuditLogService();
903
- const PAGE_SIZE = 1e3;
904
- const allRows = [];
1103
+ function isStringOperator(op) {
1104
+ return STRING_OPERATORS.has(op);
1105
+ }
1106
+ function isDateOperator(op) {
1107
+ return DATE_OPERATORS.has(op);
1108
+ }
1109
+ function isEnumOperator(op) {
1110
+ return ENUM_OPERATORS.has(op);
1111
+ }
1112
+ function isAuditAction(value) {
1113
+ return AUDIT_ACTIONS.includes(value);
1114
+ }
1115
+ class ValidationError extends Error {
1116
+ constructor(message) {
1117
+ super(message);
1118
+ this.name = "ValidationError";
1119
+ }
1120
+ }
1121
+ function requireType(field, op, value, check, expected) {
1122
+ if (!check) {
1123
+ throw new ValidationError(`Operator "${op}" for field "${field}" requires ${expected}`);
1124
+ }
1125
+ return value;
1126
+ }
1127
+ function parseActionOperator(op, opValue) {
1128
+ if (!isEnumOperator(op)) {
1129
+ throw new ValidationError(`Unknown operator "${op}" for field "action"`);
1130
+ }
1131
+ if (op === "$in") {
1132
+ requireType("action", op, opValue, Array.isArray(opValue), "an array value");
1133
+ for (const v of opValue) {
1134
+ if (!isAuditAction(v)) {
1135
+ throw new ValidationError(
1136
+ `Invalid action value "${v}" — must be one of: ${AUDIT_ACTIONS.join(", ")}`
1137
+ );
1138
+ }
1139
+ }
1140
+ return opValue;
1141
+ }
1142
+ if (!isAuditAction(opValue)) {
1143
+ throw new ValidationError(
1144
+ `Invalid action value "${opValue}" — must be one of: ${AUDIT_ACTIONS.join(", ")}`
1145
+ );
1146
+ }
1147
+ return opValue;
1148
+ }
1149
+ function parseCreatedAtOperator(op, opValue) {
1150
+ if (!isDateOperator(op)) {
1151
+ throw new ValidationError(`Unknown operator "${op}" for field "createdAt"`);
1152
+ }
1153
+ const expected = 'an ISO-8601 UTC datetime string (e.g. "2024-01-15T00:00:00.000Z")';
1154
+ if (op === "$between") {
1155
+ const isTuple = Array.isArray(opValue) && opValue.length === 2;
1156
+ requireType("createdAt", op, opValue, isTuple, "a tuple [start, end]");
1157
+ const [a, b] = opValue;
1158
+ requireType("createdAt", op, opValue, isIsoUtcDatetime(a) && isIsoUtcDatetime(b), expected);
1159
+ return opValue;
1160
+ }
1161
+ if (op === "$in") {
1162
+ requireType("createdAt", op, opValue, Array.isArray(opValue), "an array value");
1163
+ for (const v of opValue) {
1164
+ requireType("createdAt", op, v, isIsoUtcDatetime(v), expected);
1165
+ }
1166
+ return opValue;
1167
+ }
1168
+ return requireType("createdAt", op, opValue, isIsoUtcDatetime(opValue), expected);
1169
+ }
1170
+ function parseStringFieldOperator(field, op, opValue) {
1171
+ if (!isStringOperator(op)) {
1172
+ throw new ValidationError(`Unknown operator "${op}" for field "${field}"`);
1173
+ }
1174
+ if (op === "$null" || op === "$notNull") {
1175
+ return requireType(field, op, opValue, typeof opValue === "boolean", "a boolean value");
1176
+ }
1177
+ return requireType(field, op, opValue, typeof opValue === "string", "a string value");
1178
+ }
1179
+ function parseFieldOperators(field, fieldValue) {
1180
+ if (!isPlainObject(fieldValue)) {
1181
+ throw new ValidationError(
1182
+ `Filter field "${field}" must be an object of operators, got ${typeof fieldValue}`
1183
+ );
1184
+ }
1185
+ const parsed = {};
1186
+ for (const [op, opValue] of Object.entries(fieldValue)) {
1187
+ if (field === "action") parsed[op] = parseActionOperator(op, opValue);
1188
+ else if (field === "createdAt") parsed[op] = parseCreatedAtOperator(op, opValue);
1189
+ else parsed[op] = parseStringFieldOperator(field, op, opValue);
1190
+ }
1191
+ return Object.keys(parsed).length > 0 ? parsed : null;
1192
+ }
1193
+ function parseAuditLogFilters(query) {
1194
+ if (!isPlainObject(query)) return {};
1195
+ const result = {};
1196
+ const filters = query.filters;
1197
+ if (filters === void 0) return result;
1198
+ if (!isPlainObject(filters)) {
1199
+ throw new ValidationError(`"filters" must be an object, got ${typeof filters}`);
1200
+ }
1201
+ for (const [field, fieldValue] of Object.entries(filters)) {
1202
+ if (!ALLOWED_FIELDS.has(field)) {
1203
+ throw new ValidationError(`Unknown filter field: "${field}"`);
1204
+ }
1205
+ const parsed = parseFieldOperators(field, fieldValue);
1206
+ if (parsed) result[field] = parsed;
1207
+ }
1208
+ return result;
1209
+ }
1210
+ const EXPORT_PAGE_SIZE = 500;
1211
+ async function* ndjsonRowStream(service, filters) {
905
1212
  let page = 1;
906
1213
  while (true) {
907
- const { results } = await service.find({ page, pageSize: PAGE_SIZE });
1214
+ const { results } = await service.find({ page, pageSize: EXPORT_PAGE_SIZE, filters });
1215
+ if (results.length === 0) return;
1216
+ let chunk = "";
908
1217
  for (const row of results) {
909
- allRows.push({
910
- id: row.id,
911
- createdAt: row.createdAt,
1218
+ chunk += JSON.stringify({
1219
+ datetime: row.createdAt,
912
1220
  action: row.action,
913
1221
  email: row.email ?? null,
914
1222
  ip: row.ip ?? null,
915
1223
  details: row.details
916
- });
1224
+ }) + "\n";
917
1225
  }
918
- if (results.length < PAGE_SIZE) break;
1226
+ yield Buffer.from(chunk, "utf8");
1227
+ if (results.length < EXPORT_PAGE_SIZE) return;
919
1228
  page++;
920
1229
  }
921
- ctx.body = allRows.map((row) => ({
922
- datetime: row.createdAt,
923
- action: row.action,
924
- email: row.email,
925
- ip: row.ip,
926
- details: row.details
927
- }));
1230
+ }
1231
+ function errorAwareNdjsonStream(strapi2, service, filters) {
1232
+ const gen = ndjsonRowStream(service, filters);
1233
+ const readable = node_stream.Readable.from(gen);
1234
+ readable.on("error", (err) => {
1235
+ strapi2.log.error({ phase: "audit_log_export", err }, "NDJSON export stream failed");
1236
+ });
1237
+ return readable;
1238
+ }
1239
+ function parseFiltersOr400(ctx) {
1240
+ try {
1241
+ return parseAuditLogFilters(ctx.query);
1242
+ } catch (err) {
1243
+ ctx.status = 400;
1244
+ ctx.body = { message: err instanceof ValidationError ? err.message : "Invalid filters" };
1245
+ return null;
1246
+ }
1247
+ }
1248
+ async function find(ctx) {
1249
+ const filters = parseFiltersOr400(ctx);
1250
+ if (!filters) return;
1251
+ const page = Math.max(1, Number(ctx.query.page) || 1);
1252
+ const pageSize = Math.min(100, Math.max(1, Number(ctx.query.pageSize) || 25));
1253
+ ctx.body = await getAuditLogService().find({ page, pageSize, filters });
1254
+ }
1255
+ async function exportLogs(ctx) {
1256
+ const filters = parseFiltersOr400(ctx);
1257
+ if (!filters) return;
1258
+ setNdjsonAttachmentHeaders(ctx, "strapi-oidc-audit-log");
1259
+ ctx.body = errorAwareNdjsonStream(ctx.strapi, getAuditLogService(), filters);
928
1260
  }
929
1261
  async function clearAll(ctx) {
930
1262
  await getAuditLogService().clearAll();
@@ -1138,10 +1470,10 @@ const routes = {
1138
1470
  }
1139
1471
  };
1140
1472
  const policies = {};
1141
- function renderHtmlTemplate(title, content) {
1473
+ function renderHtmlTemplate(title, content, locale = "en") {
1142
1474
  return `
1143
1475
  <!doctype html>
1144
- <html lang="en">
1476
+ <html lang="${locale}">
1145
1477
  <head>
1146
1478
  <meta charset="utf-8">
1147
1479
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -1159,7 +1491,7 @@ function renderHtmlTemplate(title, content) {
1159
1491
  --icon-color: #d02b20;
1160
1492
  --success-bg: #eafbe7;
1161
1493
  --success-color: #328048;
1162
- --shadow: 0 1px 4px rgba(33, 33, 52, 0.1);
1494
+ --shadow: 0 1px 4 rgba(33, 33, 52, 0.1);
1163
1495
  }
1164
1496
  @media (prefers-color-scheme: dark) {
1165
1497
  :root {
@@ -1174,7 +1506,7 @@ function renderHtmlTemplate(title, content) {
1174
1506
  --icon-color: #f23628;
1175
1507
  --success-bg: #1c3523;
1176
1508
  --success-color: #55ca76;
1177
- --shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
1509
+ --shadow: 0 1px 4 rgba(0, 0, 0, 0.5);
1178
1510
  }
1179
1511
  }
1180
1512
  body {
@@ -1259,14 +1591,11 @@ function oauthService({ strapi: strapi2 }) {
1259
1591
  return {
1260
1592
  async createUser(email, lastname, firstname, locale, roles2 = []) {
1261
1593
  const userService = strapi2.service("admin::user");
1262
- if (/[A-Z]/.test(email)) {
1263
- const dbUser = await userService.findOneByEmail(email.toLocaleLowerCase());
1264
- if (dbUser) return dbUser;
1265
- }
1594
+ const normalizedEmail = email.toLowerCase();
1266
1595
  const createdUser = await userService.create({
1267
1596
  firstname: firstname || "unset",
1268
1597
  lastname: lastname || "",
1269
- email: email.toLocaleLowerCase(),
1598
+ email: normalizedEmail,
1270
1599
  roles: roles2,
1271
1600
  preferedLanguage: locale
1272
1601
  });
@@ -1297,35 +1626,36 @@ function oauthService({ strapi: strapi2 }) {
1297
1626
  },
1298
1627
  async triggerWebHook(user) {
1299
1628
  let ENTRY_CREATE;
1300
- const webhookStore = strapi2.serviceMap.get("webhookStore");
1301
- const eventHub = strapi2.serviceMap.get("eventHub");
1629
+ const webhookStore = strapi2.serviceMap?.get("webhookStore");
1630
+ const eventHub = strapi2.serviceMap?.get("eventHub");
1302
1631
  if (webhookStore) {
1303
1632
  ENTRY_CREATE = webhookStore.allowedEvents.get("ENTRY_CREATE");
1304
1633
  }
1305
1634
  const modelDef = strapi2.getModel("admin::user");
1306
1635
  const sanitizedEntity = await strapiUtils__default.default.sanitize.sanitizers.defaultSanitizeOutput(
1307
- {
1308
- schema: modelDef,
1309
- getModel: (uid2) => strapi2.getModel(uid2)
1310
- },
1636
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1637
+ { schema: modelDef, getModel: (uid2) => strapi2.getModel(uid2) },
1638
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1311
1639
  user
1312
1640
  );
1313
- eventHub.emit(ENTRY_CREATE, {
1641
+ eventHub?.emit(ENTRY_CREATE ?? "entry.create", {
1314
1642
  model: modelDef.modelName,
1315
1643
  entry: sanitizedEntity
1316
1644
  });
1317
1645
  },
1318
1646
  triggerSignInSuccess(user) {
1319
- delete user.password;
1320
- const eventHub = strapi2.serviceMap.get("eventHub");
1321
- eventHub.emit("admin.auth.success", {
1322
- user,
1647
+ const userCopy = { ...user };
1648
+ delete userCopy.password;
1649
+ const eventHub = strapi2.serviceMap?.get("eventHub");
1650
+ eventHub?.emit("admin.auth.success", {
1651
+ user: userCopy,
1323
1652
  provider: "strapi-plugin-oidc"
1324
1653
  });
1325
1654
  },
1326
- renderSignUpSuccess(jwtToken, user, nonce) {
1655
+ renderSignUpSuccess(jwtToken, user, nonce, locale = "en") {
1327
1656
  const config2 = strapi2.config.get("plugin::strapi-plugin-oidc");
1328
- const isRememberMe = !!config2["REMEMBER_ME"];
1657
+ const isRememberMe = !!config2?.REMEMBER_ME;
1658
+ const messages = authPageMessages(locale);
1329
1659
  const content = `
1330
1660
  <noscript>
1331
1661
  <div class="card">
@@ -1334,8 +1664,8 @@ function oauthService({ strapi: strapi2 }) {
1334
1664
  <path d="M20 6 9 17l-5-5"/>
1335
1665
  </svg>
1336
1666
  </div>
1337
- <h1>JavaScript Required</h1>
1338
- <p>JavaScript must be enabled for authentication to complete.</p>
1667
+ <h1>${messages.noscriptHeading}</h1>
1668
+ <p>${messages.noscriptBody}</p>
1339
1669
  </div>
1340
1670
  </noscript>
1341
1671
  <script nonce="${nonce}">
@@ -1349,9 +1679,10 @@ function oauthService({ strapi: strapi2 }) {
1349
1679
  location.href = '${strapi2.config.admin.url}'
1350
1680
  })
1351
1681
  <\/script>`;
1352
- return renderHtmlTemplate("Authenticating...", content);
1682
+ return renderHtmlTemplate(messages.authenticatingTitle, content, locale);
1353
1683
  },
1354
- renderSignUpError(message) {
1684
+ renderSignUpError(message, locale = "en") {
1685
+ const messages = authPageMessages(locale);
1355
1686
  const safeMessage = String(message).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1356
1687
  const content = `
1357
1688
  <div class="card">
@@ -1362,11 +1693,11 @@ function oauthService({ strapi: strapi2 }) {
1362
1693
  <path d="M12 17h.01"/>
1363
1694
  </svg>
1364
1695
  </div>
1365
- <h1>Authentication Failed</h1>
1696
+ <h1>${messages.errorTitle}</h1>
1366
1697
  <p>${safeMessage}</p>
1367
- <a href="${strapi2.config.admin.url}" class="btn">Return to Login</a>
1698
+ <a href="${strapi2.config.admin.url}" class="btn">${messages.returnToLogin}</a>
1368
1699
  </div>`;
1369
- return renderHtmlTemplate("Authentication Failed", content);
1700
+ return renderHtmlTemplate(messages.errorTitle, content, locale);
1370
1701
  },
1371
1702
  async generateToken(user, ctx) {
1372
1703
  const sessionManager = strapi2.sessionManager;
@@ -1376,12 +1707,15 @@ function oauthService({ strapi: strapi2 }) {
1376
1707
  const userId = String(user.id);
1377
1708
  const deviceId = node_crypto.randomUUID();
1378
1709
  const config2 = strapi2.config.get("plugin::strapi-plugin-oidc");
1379
- const rememberMe = !!config2["REMEMBER_ME"];
1380
- const { token: refreshToken, absoluteExpiresAt } = await sessionManager(
1381
- "admin"
1382
- ).generateRefreshToken(userId, deviceId, {
1383
- type: rememberMe ? "refresh" : "session"
1384
- });
1710
+ const rememberMe = !!config2?.REMEMBER_ME;
1711
+ const smAdmin = sessionManager("admin");
1712
+ const { token: refreshToken, absoluteExpiresAt } = await smAdmin.generateRefreshToken(
1713
+ userId,
1714
+ deviceId,
1715
+ {
1716
+ type: rememberMe ? "refresh" : "session"
1717
+ }
1718
+ );
1385
1719
  const isProduction = strapi2.config.get("environment") === "production";
1386
1720
  const domain = strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain");
1387
1721
  const path = strapi2.config.get("admin.auth.cookie.path", "/admin");
@@ -1398,7 +1732,6 @@ function oauthService({ strapi: strapi2 }) {
1398
1732
  const idleLifespanSec = strapi2.config.get(
1399
1733
  "admin.auth.sessions.idleRefreshTokenLifespan",
1400
1734
  1209600
1401
- // 14 days — Strapi default
1402
1735
  );
1403
1736
  const idleMs = idleLifespanSec * 1e3;
1404
1737
  const absoluteMs = new Date(absoluteExpiresAt).getTime() - Date.now();
@@ -1408,7 +1741,7 @@ function oauthService({ strapi: strapi2 }) {
1408
1741
  }
1409
1742
  ctx.cookies.set("strapi_admin_refresh", refreshToken, cookieOptions);
1410
1743
  ctx.cookies.set("oidc_authenticated", "1", { ...cookieOptions, path: "/" });
1411
- const accessResult = await sessionManager("admin").generateAccessToken(refreshToken);
1744
+ const accessResult = await smAdmin.generateAccessToken(refreshToken);
1412
1745
  if ("error" in accessResult) {
1413
1746
  throw new Error(accessResult.error);
1414
1747
  }
@@ -1499,8 +1832,23 @@ function whitelistService({ strapi: strapi2 }) {
1499
1832
  const result = await getWhitelistQuery().findOne({
1500
1833
  where: { email }
1501
1834
  });
1502
- if (!result) throw new Error(errorMessages.WHITELIST_NOT_PRESENT);
1835
+ if (!result) throw new OidcError("whitelist_rejected", errorMessages.WHITELIST_NOT_PRESENT);
1503
1836
  return result;
1837
+ },
1838
+ async hasUser(email) {
1839
+ const row = await getWhitelistQuery().findOne({ where: { email }, select: ["id"] });
1840
+ return !!row;
1841
+ },
1842
+ async deleteAllUsers() {
1843
+ await getWhitelistQuery().deleteMany({});
1844
+ },
1845
+ async countAdminUsersByEmails(emails) {
1846
+ if (emails.length === 0) return 0;
1847
+ const rows = await strapi2.query("admin::user").findMany({
1848
+ where: { email: { $in: emails } },
1849
+ select: ["id"]
1850
+ });
1851
+ return rows.length;
1504
1852
  }
1505
1853
  };
1506
1854
  }
@@ -1513,6 +1861,58 @@ function translateDetails(key, params) {
1513
1861
  if (!translation) return null;
1514
1862
  return interpolate(translation, params);
1515
1863
  }
1864
+ const STRING_OP_MAP = {
1865
+ $eq: (v) => v,
1866
+ $contains: (v) => ({ $containsi: v }),
1867
+ $endsWith: (v) => ({ $endsWith: v }),
1868
+ $null: (v) => v === true ? null : void 0,
1869
+ $notNull: (v) => v === true ? { $notNull: true } : void 0
1870
+ };
1871
+ const DATE_OP_MAP = {
1872
+ $gte: (v) => ({ $gte: v }),
1873
+ $lt: (v) => ({ $lt: v }),
1874
+ $lte: (v) => ({ $lte: v }),
1875
+ $between: (v) => ({ $between: v })
1876
+ // $in is handled separately: each ISO day-start is expanded to a [day, day+1) range.
1877
+ };
1878
+ const DAY_MS = 864e5;
1879
+ function nextDayIso(iso) {
1880
+ return new Date(new Date(iso).getTime() + DAY_MS).toISOString();
1881
+ }
1882
+ function expandCreatedAtInToDayRanges(days) {
1883
+ const ranges = days.map((d) => ({ createdAt: { $gte: d, $lt: nextDayIso(d) } }));
1884
+ return ranges.length === 1 ? ranges[0] : { $or: ranges };
1885
+ }
1886
+ const ACTION_OP_MAP = {
1887
+ $eq: (v) => v,
1888
+ $in: (v) => ({ $in: v })
1889
+ };
1890
+ function mapFieldFilter(conditions, field, filter, opMap) {
1891
+ for (const [op, value] of Object.entries(filter)) {
1892
+ const transform = opMap[op];
1893
+ if (!transform) continue;
1894
+ const result = transform(value);
1895
+ if (result !== void 0) conditions.push({ [field]: result });
1896
+ }
1897
+ }
1898
+ function buildWhereClause(filters) {
1899
+ const conditions = [];
1900
+ if (filters.action) mapFieldFilter(conditions, "action", filters.action, ACTION_OP_MAP);
1901
+ if (filters.email) mapFieldFilter(conditions, "email", filters.email, STRING_OP_MAP);
1902
+ if (filters.ip) mapFieldFilter(conditions, "ip", filters.ip, STRING_OP_MAP);
1903
+ if (filters.createdAt) {
1904
+ const { $in: inDays, ...rest } = filters.createdAt;
1905
+ if (Array.isArray(inDays) && inDays.length > 0) {
1906
+ conditions.push(expandCreatedAtInToDayRanges(inDays));
1907
+ }
1908
+ if (Object.keys(rest).length > 0) {
1909
+ mapFieldFilter(conditions, "createdAt", rest, DATE_OP_MAP);
1910
+ }
1911
+ }
1912
+ if (conditions.length === 0) return {};
1913
+ if (conditions.length === 1) return conditions[0];
1914
+ return { $and: conditions };
1915
+ }
1516
1916
  function auditLogService({ strapi: strapi2 }) {
1517
1917
  return {
1518
1918
  async log({ action, email, ip, detailsKey, detailsParams }) {
@@ -1535,19 +1935,33 @@ function auditLogService({ strapi: strapi2 }) {
1535
1935
  });
1536
1936
  }
1537
1937
  },
1538
- async find({ page = 1, pageSize = 25 } = {}) {
1539
- const result = await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").findPage({
1540
- sort: { createdAt: "desc" },
1541
- page,
1542
- pageSize
1543
- });
1544
- const results = result.results.map((row) => ({
1545
- ...row,
1546
- details: row.detailsKey ? translateDetails(row.detailsKey, row.detailsParams) : null
1547
- }));
1938
+ async find({
1939
+ page = 1,
1940
+ pageSize = 25,
1941
+ filters
1942
+ } = {}) {
1943
+ const where = filters ? buildWhereClause(filters) : {};
1944
+ const dbQuery = strapi2.db.query("plugin::strapi-plugin-oidc.audit-log");
1945
+ const [rows, total] = await Promise.all([
1946
+ dbQuery.findMany({
1947
+ where,
1948
+ orderBy: [{ createdAt: "desc" }],
1949
+ limit: pageSize,
1950
+ offset: (page - 1) * pageSize
1951
+ }),
1952
+ dbQuery.count({ where })
1953
+ ]);
1548
1954
  return {
1549
- results,
1550
- pagination: result.pagination
1955
+ results: rows.map((row) => ({
1956
+ ...row,
1957
+ details: row.detailsKey ? translateDetails(row.detailsKey, row.detailsParams) : null
1958
+ })),
1959
+ pagination: {
1960
+ page,
1961
+ pageSize,
1962
+ total,
1963
+ pageCount: Math.ceil(total / pageSize)
1964
+ }
1551
1965
  };
1552
1966
  },
1553
1967
  async clearAll() {
@@ -1559,7 +1973,7 @@ function auditLogService({ strapi: strapi2 }) {
1559
1973
  } while (deletedCount === BATCH_SIZE);
1560
1974
  },
1561
1975
  async cleanup(retentionDays) {
1562
- const cutoff = new Date(Date.now() - retentionDays * 864e5);
1976
+ const cutoff = new Date(Date.now() - retentionDays * DAY_MS);
1563
1977
  await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").deleteMany({ where: { createdAt: { $lt: cutoff } } });
1564
1978
  }
1565
1979
  };