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