strapi-plugin-oidc 1.6.6 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -284,6 +284,7 @@ const en = {
284
284
  "page.save.error": "Update failed.",
285
285
  "page.add": "Add",
286
286
  "page.cancel": "Cancel",
287
+ "common.remove": "Remove {label}",
287
288
  "page.ok": "OK",
288
289
  "roles.title": "Default Role(s)",
289
290
  "roles.placeholder": "Select default role(s)",
@@ -315,7 +316,7 @@ const en = {
315
316
  "enforce.config.info": "Enforcement is controlled by the OIDC_ENFORCE config variable and cannot be changed here.",
316
317
  "login.settings.title": "Login Settings",
317
318
  "login.sso": "Login via SSO",
318
- "whitelist.count": "{count, plural, one {# entry} other {# entries}}",
319
+ "pagination.total": "{count, plural, one {# entry} other {# entries}}",
319
320
  "whitelist.import": "Import",
320
321
  "whitelist.export": "Export",
321
322
  "whitelist.delete.all.label": "Delete All",
@@ -343,6 +344,20 @@ const en = {
343
344
  "auditlog.clear.success": "Audit logs cleared",
344
345
  "auditlog.clear.error": "Failed to clear audit logs",
345
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}",
346
361
  "auditlog.action.login_success": "User successfully authenticated via OIDC and was granted access.",
347
362
  "auditlog.action.user_created": "A new Strapi admin account was created for this user on their first OIDC login.",
348
363
  "auditlog.action.logout": "User logged out and their OIDC session was ended.",
@@ -353,23 +368,74 @@ const en = {
353
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.",
354
369
  "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
355
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",
356
376
  "user.missing_code": "Authorisation code was not received from the OIDC provider.",
357
377
  "user.invalid_state": "State parameter mismatch. Please restart the login flow.",
358
378
  "user.signInError": "Authentication failed. Please try again.",
359
379
  "settings.section": "OIDC",
360
380
  "settings.configuration": "Configuration"
361
381
  };
362
- const userFacingMessages = {
363
- get missing_code() {
364
- return en["user.missing_code"];
365
- },
366
- get invalid_state() {
367
- return en["user.invalid_state"];
368
- },
369
- get signInError() {
370
- return en["user.signInError"];
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;
371
409
  }
372
- };
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
+ });
373
439
  class OidcError extends Error {
374
440
  kind;
375
441
  cause;
@@ -417,6 +483,21 @@ const OIDC_ERROR_DISPATCH = {
417
483
  key: "sign_in_unknown"
418
484
  }
419
485
  };
486
+ function getClientIp(ctx) {
487
+ const cfConnectingIp = ctx.get("CF-Connecting-IP");
488
+ if (cfConnectingIp) {
489
+ return cfConnectingIp.split(",")[0].trim();
490
+ }
491
+ const forwardedFor = ctx.get("X-Forwarded-For");
492
+ if (forwardedFor) {
493
+ return forwardedFor.split(",")[0].trim();
494
+ }
495
+ const realIp = ctx.get("X-Real-IP");
496
+ if (realIp) {
497
+ return realIp.trim();
498
+ }
499
+ return ctx.ip;
500
+ }
420
501
  const REQUIRED_CONFIG_KEYS = [
421
502
  "OIDC_CLIENT_ID",
422
503
  "OIDC_CLIENT_SECRET",
@@ -560,62 +641,99 @@ async function updateUserRoles(user, currentRoleIds, newRoleIds) {
560
641
  throw updateErr;
561
642
  }
562
643
  }
563
- async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
564
- const rawEmail = String(userResponseData.email ?? "");
565
- const email = rawEmail.toLowerCase();
566
- if (!email || !isValidEmail(email)) {
567
- throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
644
+ async function resolveRolesFromGroups(candidateNames) {
645
+ const matchedRoles = await strapi.db.query("admin::role").findMany({
646
+ where: { name: { $in: candidateNames } },
647
+ select: ["id", "name"]
648
+ });
649
+ const nameToId = new Map(matchedRoles.map((r) => [r.name, String(r.id)]));
650
+ const roles2 = [];
651
+ for (const name of candidateNames) {
652
+ const id = nameToId.get(name);
653
+ if (id) roles2.push(id);
568
654
  }
569
- await whitelistService2.checkWhitelistForEmail(email);
655
+ return {
656
+ roles: roles2,
657
+ fromGroupMapping: true,
658
+ resolvedRoleNames: matchedRoles.map((r) => r.name)
659
+ };
660
+ }
661
+ async function resolveRolesFromDefaults(roleService2) {
662
+ const oidcRolesResult = await roleService2.oidcRoles();
663
+ const roles2 = oidcRolesResult?.roles || [];
664
+ if (roles2.length === 0) {
665
+ return { roles: roles2, fromGroupMapping: false, resolvedRoleNames: [] };
666
+ }
667
+ const records = await strapi.db.query("admin::role").findMany({
668
+ where: { id: { $in: roles2.map(Number) } },
669
+ select: ["id", "name"]
670
+ });
671
+ return {
672
+ roles: roles2,
673
+ fromGroupMapping: false,
674
+ resolvedRoleNames: records.map((r) => r.name)
675
+ };
676
+ }
677
+ async function resolveRoles(userResponseData, config2, roleService2) {
570
678
  const candidateNames = collectGroupMapRoleNames(userResponseData, config2);
571
- let roles2 = [];
572
- let fromGroupMapping = false;
573
- let resolvedRoleNames = [];
574
679
  if (candidateNames.length > 0) {
575
- const matchedRoles = await strapi.db.query("admin::role").findMany({
576
- where: { name: { $in: candidateNames } },
577
- select: ["id", "name"]
578
- });
579
- const nameToId = new Map(matchedRoles.map((r) => [r.name, String(r.id)]));
580
- for (const name of candidateNames) {
581
- const id = nameToId.get(name);
582
- if (id) roles2.push(id);
583
- }
584
- resolvedRoleNames = matchedRoles.map((r) => r.name);
585
- fromGroupMapping = true;
586
- } else {
587
- const oidcRolesResult = await roleService2.oidcRoles();
588
- roles2 = oidcRolesResult?.roles || [];
589
- if (roles2.length > 0) {
590
- const oidcRoleRecords = await strapi.db.query("admin::role").findMany({
591
- where: { id: { $in: roles2.map(Number) } },
592
- select: ["id", "name"]
593
- });
594
- resolvedRoleNames = oidcRoleRecords.map((r) => r.name);
595
- }
680
+ return resolveRolesFromGroups(candidateNames);
596
681
  }
597
- let userCreated = false;
598
- let rolesUpdated = false;
599
- let user = await userService.findOneByEmail(email, ["roles"]);
600
- if (!user) {
682
+ return resolveRolesFromDefaults(roleService2);
683
+ }
684
+ async function ensureUser(userService, oauthService2, email, userResponseData, config2, ctx, resolved) {
685
+ const existing = await userService.findOneByEmail(email, ["roles"]);
686
+ if (!existing) {
601
687
  try {
602
- user = await registerNewUser(oauthService2, email, userResponseData, config2, ctx, roles2);
688
+ const user = await registerNewUser(
689
+ oauthService2,
690
+ email,
691
+ userResponseData,
692
+ config2,
693
+ ctx,
694
+ resolved.roles
695
+ );
696
+ return { user, userCreated: true, rolesUpdated: true };
603
697
  } catch (e) {
604
698
  const msg = e instanceof Error ? e.message : String(e);
605
699
  throw new OidcError("user_creation_failed", msg, e);
606
700
  }
607
- userCreated = true;
608
- rolesUpdated = true;
609
- } else if (fromGroupMapping && roles2.length > 0) {
610
- const currentRoleIds = new Set((user.roles ?? []).map((r) => String(r.id)));
611
- if (rolesChanged(currentRoleIds, new Set(roles2))) {
612
- await updateUserRoles(user, currentRoleIds, roles2);
613
- rolesUpdated = true;
614
- }
615
701
  }
702
+ if (!resolved.fromGroupMapping || resolved.roles.length === 0) {
703
+ return { user: existing, userCreated: false, rolesUpdated: false };
704
+ }
705
+ const currentRoleIds = new Set((existing.roles ?? []).map((r) => String(r.id)));
706
+ if (!rolesChanged(currentRoleIds, new Set(resolved.roles))) {
707
+ return { user: existing, userCreated: false, rolesUpdated: false };
708
+ }
709
+ await updateUserRoles(existing, currentRoleIds, resolved.roles);
710
+ return { user: existing, userCreated: false, rolesUpdated: true };
711
+ }
712
+ async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
713
+ const email = String(userResponseData.email ?? "").toLowerCase();
714
+ if (!email || !isValidEmail(email)) {
715
+ throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
716
+ }
717
+ await whitelistService2.checkWhitelistForEmail(email);
718
+ const resolved = await resolveRoles(userResponseData, config2, roleService2);
719
+ const { user, userCreated, rolesUpdated } = await ensureUser(
720
+ userService,
721
+ oauthService2,
722
+ email,
723
+ userResponseData,
724
+ config2,
725
+ ctx,
726
+ resolved
727
+ );
616
728
  const jwtToken = await oauthService2.generateToken(user, ctx);
617
729
  oauthService2.triggerSignInSuccess(user);
618
- return { activateUser: user, jwtToken, userCreated, rolesUpdated, resolvedRoleNames };
730
+ return {
731
+ activateUser: user,
732
+ jwtToken,
733
+ userCreated,
734
+ rolesUpdated,
735
+ resolvedRoleNames: resolved.resolvedRoleNames
736
+ };
619
737
  }
620
738
  function classifyOidcError(e, userInfo) {
621
739
  const kind = e instanceof OidcError ? e.kind : "unknown";
@@ -634,26 +752,76 @@ function classifyOidcError(e, userInfo) {
634
752
  params
635
753
  };
636
754
  }
637
- async function oidcSignInCallback(ctx) {
638
- const config2 = configValidation();
639
- const userService = getAdminUserService();
640
- const oauthService2 = getOauthService();
641
- const roleService2 = getRoleService();
642
- const whitelistService2 = getWhitelistService();
643
- const auditLog2 = getAuditLogService();
644
- if (!ctx.query.code) {
645
- await auditLog2.log({ action: "missing_code", ip: ctx.ip });
646
- return ctx.send(oauthService2.renderSignUpError(userFacingMessages.missing_code));
647
- }
755
+ function readAndClearPkceCookies(ctx) {
648
756
  const oidcState = ctx.cookies.get("oidc_state");
649
757
  const codeVerifier = ctx.cookies.get("oidc_code_verifier");
650
758
  const oidcNonce = ctx.cookies.get("oidc_nonce");
651
759
  ctx.cookies.set("oidc_state", null);
652
760
  ctx.cookies.set("oidc_code_verifier", null);
653
761
  ctx.cookies.set("oidc_nonce", null);
762
+ return { oidcState, codeVerifier, oidcNonce };
763
+ }
764
+ async function logSuccessfulAuth(auditLog2, ctx, user, userCreated, rolesUpdated, resolvedRoleNames) {
765
+ const roles2 = resolvedRoleNames.join(", ");
766
+ const entries = [
767
+ auditLog2.log({
768
+ action: "login_success",
769
+ email: user.email,
770
+ ip: getClientIp(ctx),
771
+ detailsKey: rolesUpdated ? "roles_updated" : void 0,
772
+ detailsParams: rolesUpdated ? { roles: roles2 } : void 0
773
+ })
774
+ ];
775
+ if (userCreated) {
776
+ entries.push(
777
+ auditLog2.log({
778
+ action: "user_created",
779
+ email: user.email,
780
+ ip: getClientIp(ctx),
781
+ detailsKey: "user_created",
782
+ detailsParams: { roles: roles2 }
783
+ })
784
+ );
785
+ }
786
+ await Promise.all(entries);
787
+ }
788
+ async function handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx) {
789
+ const errorInfo = classifyOidcError(e, userInfo);
790
+ const message = e instanceof Error ? e.message : String(e);
791
+ await auditLog2.log({
792
+ action: errorInfo.action,
793
+ email: userInfo?.email,
794
+ ip: getClientIp(ctx),
795
+ detailsKey: errorInfo.action,
796
+ detailsParams: errorInfo.action === "login_failure" ? { message } : void 0
797
+ });
798
+ strapi.log.error({
799
+ code: errorInfo.code,
800
+ phase: "oidc_callback",
801
+ message: e instanceof Error ? e.message : "Unknown sign-in error",
802
+ detail: errorInfo.key ? getErrorDetail(errorInfo.key, errorInfo.params) : void 0,
803
+ email: userInfo?.email
804
+ });
805
+ const locale = negotiateLocale(ctx.request.headers["accept-language"]);
806
+ ctx.send(oauthService2.renderSignUpError(userFacingMessages(locale).signInError, locale));
807
+ }
808
+ async function oidcSignInCallback(ctx) {
809
+ const config2 = configValidation();
810
+ const oauthService2 = getOauthService();
811
+ const auditLog2 = getAuditLogService();
812
+ const locale = negotiateLocale(ctx.request.headers["accept-language"]);
813
+ if (!ctx.query.code) {
814
+ await auditLog2.log({ action: "missing_code", ip: getClientIp(ctx) });
815
+ return ctx.send(
816
+ oauthService2.renderSignUpError(userFacingMessages(locale).missing_code, locale)
817
+ );
818
+ }
819
+ const { oidcState, codeVerifier, oidcNonce } = readAndClearPkceCookies(ctx);
654
820
  if (!ctx.query.state || ctx.query.state !== oidcState) {
655
- await auditLog2.log({ action: "state_mismatch", ip: ctx.ip });
656
- return ctx.send(oauthService2.renderSignUpError(userFacingMessages.invalid_state));
821
+ await auditLog2.log({ action: "state_mismatch", ip: getClientIp(ctx) });
822
+ return ctx.send(
823
+ oauthService2.renderSignUpError(userFacingMessages(locale).invalid_state, locale)
824
+ );
657
825
  }
658
826
  const params = new URLSearchParams({
659
827
  code: ctx.query.code,
@@ -667,67 +835,53 @@ async function oidcSignInCallback(ctx) {
667
835
  try {
668
836
  const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
669
837
  userInfo = exchangeResult.userInfo;
670
- const accessToken = exchangeResult.accessToken;
671
838
  const isProduction = strapi.config.get("environment") === "production";
672
- ctx.cookies.set("oidc_access_token", accessToken, {
839
+ const secureFlag = isProduction && ctx.request.secure;
840
+ ctx.cookies.set("oidc_access_token", exchangeResult.accessToken, {
673
841
  httpOnly: true,
674
842
  maxAge: 3e5,
675
- secure: isProduction && ctx.request.secure,
843
+ secure: secureFlag,
676
844
  sameSite: "lax"
677
845
  });
678
846
  const { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames } = await handleUserAuthentication(
679
- userService,
847
+ getAdminUserService(),
680
848
  oauthService2,
681
- roleService2,
682
- whitelistService2,
849
+ getRoleService(),
850
+ getWhitelistService(),
683
851
  userInfo,
684
852
  config2,
685
853
  ctx
686
854
  );
687
- const identityCookieOptions = {
855
+ ctx.cookies.set("oidc_user_email", activateUser.email, {
688
856
  httpOnly: true,
689
857
  path: "/",
690
- secure: isProduction && ctx.request.secure,
858
+ secure: secureFlag,
691
859
  sameSite: "lax"
692
- };
693
- ctx.cookies.set("oidc_user_email", activateUser.email, identityCookieOptions);
694
- if (userCreated) {
695
- await auditLog2.log({
696
- action: "user_created",
697
- email: activateUser.email,
698
- ip: ctx.ip,
699
- detailsKey: "user_created",
700
- detailsParams: { roles: resolvedRoleNames.join(", ") }
701
- });
702
- }
703
- await auditLog2.log({
704
- action: "login_success",
705
- email: activateUser.email,
706
- ip: ctx.ip,
707
- detailsKey: rolesUpdated ? "roles_updated" : void 0,
708
- detailsParams: rolesUpdated ? { roles: resolvedRoleNames.join(", ") } : void 0
709
860
  });
861
+ await logSuccessfulAuth(
862
+ auditLog2,
863
+ ctx,
864
+ activateUser,
865
+ userCreated,
866
+ rolesUpdated,
867
+ resolvedRoleNames
868
+ );
710
869
  const nonce = node_crypto.randomUUID();
711
- const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
712
870
  ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
713
- ctx.send(html);
871
+ ctx.send(oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce, locale));
714
872
  } catch (e) {
715
- const errorInfo = classifyOidcError(e, userInfo);
716
- await auditLog2.log({
717
- action: errorInfo.action,
718
- email: userInfo?.email,
719
- ip: ctx.ip,
720
- detailsKey: errorInfo.action,
721
- detailsParams: errorInfo.action === "login_failure" ? { message: e instanceof Error ? e.message : String(e) } : void 0
722
- });
723
- strapi.log.error({
724
- code: errorInfo.code,
725
- phase: "oidc_callback",
726
- message: e instanceof Error ? e.message : "Unknown sign-in error",
727
- detail: errorInfo.key ? getErrorDetail(errorInfo.key, errorInfo.params) : void 0,
728
- email: userInfo?.email
873
+ await handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx);
874
+ }
875
+ }
876
+ async function isProviderSessionActive(userinfoEndpoint, accessToken) {
877
+ try {
878
+ const response = await fetch(userinfoEndpoint, {
879
+ headers: { Authorization: `Bearer ${accessToken}` },
880
+ signal: AbortSignal.timeout(LOGOUT_USERINFO_TIMEOUT_MS)
729
881
  });
730
- ctx.send(oauthService2.renderSignUpError(userFacingMessages.signInError));
882
+ return response.ok;
883
+ } catch {
884
+ return false;
731
885
  }
732
886
  }
733
887
  async function logout(ctx) {
@@ -735,38 +889,27 @@ async function logout(ctx) {
735
889
  const auditLog2 = getAuditLogService();
736
890
  const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
737
891
  const adminPanelUrl = strapi.config.get("admin.url", "/admin");
892
+ const loginUrl = `${adminPanelUrl}/auth/login`;
738
893
  const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
739
894
  const accessToken = ctx.cookies.get("oidc_access_token");
740
895
  const userEmail = ctx.cookies.get("oidc_user_email") ?? void 0;
741
896
  clearAuthCookies(strapi, ctx);
742
- if (logoutUrl && isOidcSession && accessToken) {
743
- try {
744
- const response = await fetch(config2.OIDC_USERINFO_ENDPOINT, {
745
- headers: { Authorization: `Bearer ${accessToken}` },
746
- signal: AbortSignal.timeout(LOGOUT_USERINFO_TIMEOUT_MS)
897
+ if (!isOidcSession) {
898
+ return ctx.redirect(loginUrl);
899
+ }
900
+ const logAudit = (action) => userEmail ? auditLog2.log({ action, email: userEmail, ip: getClientIp(ctx) }) : Promise.resolve();
901
+ if (logoutUrl && accessToken) {
902
+ const active = await isProviderSessionActive(config2.OIDC_USERINFO_ENDPOINT, accessToken);
903
+ if (active) {
904
+ logAudit("logout").catch(() => {
747
905
  });
748
- if (response.ok) {
749
- if (userEmail)
750
- auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip }).catch(() => {
751
- });
752
- return ctx.redirect(logoutUrl);
753
- }
754
- if (userEmail)
755
- await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
756
- return ctx.redirect(`${adminPanelUrl}/auth/login`);
757
- } catch {
758
- if (userEmail)
759
- await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
760
- return ctx.redirect(`${adminPanelUrl}/auth/login`);
906
+ return ctx.redirect(logoutUrl);
761
907
  }
908
+ await logAudit("session_expired");
909
+ return ctx.redirect(loginUrl);
762
910
  }
763
- if (isOidcSession && userEmail) {
764
- await auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip });
765
- }
766
- if (logoutUrl && isOidcSession) {
767
- return ctx.redirect(logoutUrl);
768
- }
769
- ctx.redirect(`${adminPanelUrl}/auth/login`);
911
+ await logAudit("logout");
912
+ ctx.redirect(logoutUrl || loginUrl);
770
913
  }
771
914
  const oidc = {
772
915
  oidcSignIn,
@@ -941,11 +1084,149 @@ const whitelist = {
941
1084
  importUsers,
942
1085
  exportWhitelist
943
1086
  };
1087
+ const AUDIT_ACTIONS = [
1088
+ "login_success",
1089
+ "login_failure",
1090
+ "missing_code",
1091
+ "state_mismatch",
1092
+ "nonce_mismatch",
1093
+ "token_exchange_failed",
1094
+ "whitelist_rejected",
1095
+ "logout",
1096
+ "session_expired",
1097
+ "user_created"
1098
+ ];
1099
+ const ISO_UTC_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
1100
+ function isIsoUtcDatetime(value) {
1101
+ return typeof value === "string" && ISO_UTC_DATETIME.test(value);
1102
+ }
1103
+ const ALLOWED_FIELDS = /* @__PURE__ */ new Set(["action", "email", "ip", "createdAt"]);
1104
+ const STRING_OPERATORS = /* @__PURE__ */ new Set([
1105
+ "$eq",
1106
+ "$contains",
1107
+ "$endsWith",
1108
+ "$null",
1109
+ "$notNull"
1110
+ ]);
1111
+ const DATE_OPERATORS = /* @__PURE__ */ new Set(["$gte", "$lt", "$lte", "$between", "$in"]);
1112
+ const ENUM_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$in"]);
1113
+ function isPlainObject(value) {
1114
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1115
+ const proto = Object.getPrototypeOf(value);
1116
+ return proto === Object.prototype || proto === null;
1117
+ }
1118
+ function isStringOperator(op) {
1119
+ return STRING_OPERATORS.has(op);
1120
+ }
1121
+ function isDateOperator(op) {
1122
+ return DATE_OPERATORS.has(op);
1123
+ }
1124
+ function isEnumOperator(op) {
1125
+ return ENUM_OPERATORS.has(op);
1126
+ }
1127
+ function isAuditAction(value) {
1128
+ return AUDIT_ACTIONS.includes(value);
1129
+ }
1130
+ class ValidationError extends Error {
1131
+ constructor(message) {
1132
+ super(message);
1133
+ this.name = "ValidationError";
1134
+ }
1135
+ }
1136
+ function requireType(field, op, value, check, expected) {
1137
+ if (!check) {
1138
+ throw new ValidationError(`Operator "${op}" for field "${field}" requires ${expected}`);
1139
+ }
1140
+ return value;
1141
+ }
1142
+ function parseActionOperator(op, opValue) {
1143
+ if (!isEnumOperator(op)) {
1144
+ throw new ValidationError(`Unknown operator "${op}" for field "action"`);
1145
+ }
1146
+ if (op === "$in") {
1147
+ requireType("action", op, opValue, Array.isArray(opValue), "an array value");
1148
+ for (const v of opValue) {
1149
+ if (!isAuditAction(v)) {
1150
+ throw new ValidationError(
1151
+ `Invalid action value "${v}" — must be one of: ${AUDIT_ACTIONS.join(", ")}`
1152
+ );
1153
+ }
1154
+ }
1155
+ return opValue;
1156
+ }
1157
+ if (!isAuditAction(opValue)) {
1158
+ throw new ValidationError(
1159
+ `Invalid action value "${opValue}" — must be one of: ${AUDIT_ACTIONS.join(", ")}`
1160
+ );
1161
+ }
1162
+ return opValue;
1163
+ }
1164
+ function parseCreatedAtOperator(op, opValue) {
1165
+ if (!isDateOperator(op)) {
1166
+ throw new ValidationError(`Unknown operator "${op}" for field "createdAt"`);
1167
+ }
1168
+ const expected = 'an ISO-8601 UTC datetime string (e.g. "2024-01-15T00:00:00.000Z")';
1169
+ if (op === "$between") {
1170
+ const isTuple = Array.isArray(opValue) && opValue.length === 2;
1171
+ requireType("createdAt", op, opValue, isTuple, "a tuple [start, end]");
1172
+ const [a, b] = opValue;
1173
+ requireType("createdAt", op, opValue, isIsoUtcDatetime(a) && isIsoUtcDatetime(b), expected);
1174
+ return opValue;
1175
+ }
1176
+ if (op === "$in") {
1177
+ requireType("createdAt", op, opValue, Array.isArray(opValue), "an array value");
1178
+ for (const v of opValue) {
1179
+ requireType("createdAt", op, v, isIsoUtcDatetime(v), expected);
1180
+ }
1181
+ return opValue;
1182
+ }
1183
+ return requireType("createdAt", op, opValue, isIsoUtcDatetime(opValue), expected);
1184
+ }
1185
+ function parseStringFieldOperator(field, op, opValue) {
1186
+ if (!isStringOperator(op)) {
1187
+ throw new ValidationError(`Unknown operator "${op}" for field "${field}"`);
1188
+ }
1189
+ if (op === "$null" || op === "$notNull") {
1190
+ return requireType(field, op, opValue, typeof opValue === "boolean", "a boolean value");
1191
+ }
1192
+ return requireType(field, op, opValue, typeof opValue === "string", "a string value");
1193
+ }
1194
+ function parseFieldOperators(field, fieldValue) {
1195
+ if (!isPlainObject(fieldValue)) {
1196
+ throw new ValidationError(
1197
+ `Filter field "${field}" must be an object of operators, got ${typeof fieldValue}`
1198
+ );
1199
+ }
1200
+ const parsed = {};
1201
+ for (const [op, opValue] of Object.entries(fieldValue)) {
1202
+ if (field === "action") parsed[op] = parseActionOperator(op, opValue);
1203
+ else if (field === "createdAt") parsed[op] = parseCreatedAtOperator(op, opValue);
1204
+ else parsed[op] = parseStringFieldOperator(field, op, opValue);
1205
+ }
1206
+ return Object.keys(parsed).length > 0 ? parsed : null;
1207
+ }
1208
+ function parseAuditLogFilters(query) {
1209
+ if (!isPlainObject(query)) return {};
1210
+ const result = {};
1211
+ const filters = query.filters;
1212
+ if (filters === void 0) return result;
1213
+ if (!isPlainObject(filters)) {
1214
+ throw new ValidationError(`"filters" must be an object, got ${typeof filters}`);
1215
+ }
1216
+ for (const [field, fieldValue] of Object.entries(filters)) {
1217
+ if (!ALLOWED_FIELDS.has(field)) {
1218
+ throw new ValidationError(`Unknown filter field: "${field}"`);
1219
+ }
1220
+ const parsed = parseFieldOperators(field, fieldValue);
1221
+ if (parsed) result[field] = parsed;
1222
+ }
1223
+ return result;
1224
+ }
944
1225
  const EXPORT_PAGE_SIZE = 500;
945
- async function* ndjsonRowStream(service) {
1226
+ async function* ndjsonRowStream(service, filters) {
946
1227
  let page = 1;
947
1228
  while (true) {
948
- const { results } = await service.find({ page, pageSize: EXPORT_PAGE_SIZE });
1229
+ const { results } = await service.find({ page, pageSize: EXPORT_PAGE_SIZE, filters });
949
1230
  if (results.length === 0) return;
950
1231
  let chunk = "";
951
1232
  for (const row of results) {
@@ -962,28 +1243,35 @@ async function* ndjsonRowStream(service) {
962
1243
  page++;
963
1244
  }
964
1245
  }
965
- function errorAwareNdjsonStream(service) {
966
- const gen = ndjsonRowStream(service);
1246
+ function errorAwareNdjsonStream(strapi2, service, filters) {
1247
+ const gen = ndjsonRowStream(service, filters);
967
1248
  const readable = node_stream.Readable.from(gen);
968
1249
  readable.on("error", (err) => {
969
- strapi$1.log.error({ phase: "audit_log_export", err }, "NDJSON export stream failed");
1250
+ strapi2.log.error({ phase: "audit_log_export", err }, "NDJSON export stream failed");
970
1251
  });
971
1252
  return readable;
972
1253
  }
973
- let strapi$1;
974
- function find(ctx) {
975
- strapi$1 = ctx.strapi;
1254
+ function parseFiltersOr400(ctx) {
1255
+ try {
1256
+ return parseAuditLogFilters(ctx.query);
1257
+ } catch (err) {
1258
+ ctx.status = 400;
1259
+ ctx.body = { message: err instanceof ValidationError ? err.message : "Invalid filters" };
1260
+ return null;
1261
+ }
1262
+ }
1263
+ async function find(ctx) {
1264
+ const filters = parseFiltersOr400(ctx);
1265
+ if (!filters) return;
976
1266
  const page = Math.max(1, Number(ctx.query.page) || 1);
977
1267
  const pageSize = Math.min(100, Math.max(1, Number(ctx.query.pageSize) || 25));
978
- return getAuditLogService().find({ page, pageSize }).then((result) => {
979
- ctx.body = result;
980
- });
1268
+ ctx.body = await getAuditLogService().find({ page, pageSize, filters });
981
1269
  }
982
1270
  async function exportLogs(ctx) {
983
- strapi$1 = ctx.strapi;
1271
+ const filters = parseFiltersOr400(ctx);
1272
+ if (!filters) return;
984
1273
  setNdjsonAttachmentHeaders(ctx, "strapi-oidc-audit-log");
985
- const service = getAuditLogService();
986
- ctx.body = errorAwareNdjsonStream(service);
1274
+ ctx.body = errorAwareNdjsonStream(ctx.strapi, getAuditLogService(), filters);
987
1275
  }
988
1276
  async function clearAll(ctx) {
989
1277
  await getAuditLogService().clearAll();
@@ -1004,7 +1292,7 @@ const rateLimitMap = /* @__PURE__ */ new Map();
1004
1292
  const RATE_LIMIT_WINDOW = 6e4;
1005
1293
  const MAX_REQUESTS = 1e3;
1006
1294
  function getRateLimitKey(ctx) {
1007
- const ip = ctx.request.ip;
1295
+ const ip = getClientIp(ctx);
1008
1296
  const ua = ctx.request.header["user-agent"] ?? "";
1009
1297
  const uaHash = node_crypto.createHash("sha256").update(ua).digest("hex").slice(0, 16);
1010
1298
  return `${ip}:${uaHash}`;
@@ -1197,10 +1485,10 @@ const routes = {
1197
1485
  }
1198
1486
  };
1199
1487
  const policies = {};
1200
- function renderHtmlTemplate(title, content) {
1488
+ function renderHtmlTemplate(title, content, locale = "en") {
1201
1489
  return `
1202
1490
  <!doctype html>
1203
- <html lang="en">
1491
+ <html lang="${locale}">
1204
1492
  <head>
1205
1493
  <meta charset="utf-8">
1206
1494
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -1379,9 +1667,10 @@ function oauthService({ strapi: strapi2 }) {
1379
1667
  provider: "strapi-plugin-oidc"
1380
1668
  });
1381
1669
  },
1382
- renderSignUpSuccess(jwtToken, user, nonce) {
1670
+ renderSignUpSuccess(jwtToken, user, nonce, locale = "en") {
1383
1671
  const config2 = strapi2.config.get("plugin::strapi-plugin-oidc");
1384
1672
  const isRememberMe = !!config2?.REMEMBER_ME;
1673
+ const messages = authPageMessages(locale);
1385
1674
  const content = `
1386
1675
  <noscript>
1387
1676
  <div class="card">
@@ -1390,8 +1679,8 @@ function oauthService({ strapi: strapi2 }) {
1390
1679
  <path d="M20 6 9 17l-5-5"/>
1391
1680
  </svg>
1392
1681
  </div>
1393
- <h1>JavaScript Required</h1>
1394
- <p>JavaScript must be enabled for authentication to complete.</p>
1682
+ <h1>${messages.noscriptHeading}</h1>
1683
+ <p>${messages.noscriptBody}</p>
1395
1684
  </div>
1396
1685
  </noscript>
1397
1686
  <script nonce="${nonce}">
@@ -1405,9 +1694,10 @@ function oauthService({ strapi: strapi2 }) {
1405
1694
  location.href = '${strapi2.config.admin.url}'
1406
1695
  })
1407
1696
  <\/script>`;
1408
- return renderHtmlTemplate("Authenticating...", content);
1697
+ return renderHtmlTemplate(messages.authenticatingTitle, content, locale);
1409
1698
  },
1410
- renderSignUpError(message) {
1699
+ renderSignUpError(message, locale = "en") {
1700
+ const messages = authPageMessages(locale);
1411
1701
  const safeMessage = String(message).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1412
1702
  const content = `
1413
1703
  <div class="card">
@@ -1418,11 +1708,11 @@ function oauthService({ strapi: strapi2 }) {
1418
1708
  <path d="M12 17h.01"/>
1419
1709
  </svg>
1420
1710
  </div>
1421
- <h1>Authentication Failed</h1>
1711
+ <h1>${messages.errorTitle}</h1>
1422
1712
  <p>${safeMessage}</p>
1423
- <a href="${strapi2.config.admin.url}" class="btn">Return to Login</a>
1713
+ <a href="${strapi2.config.admin.url}" class="btn">${messages.returnToLogin}</a>
1424
1714
  </div>`;
1425
- return renderHtmlTemplate("Authentication Failed", content);
1715
+ return renderHtmlTemplate(messages.errorTitle, content, locale);
1426
1716
  },
1427
1717
  async generateToken(user, ctx) {
1428
1718
  const sessionManager = strapi2.sessionManager;
@@ -1586,6 +1876,58 @@ function translateDetails(key, params) {
1586
1876
  if (!translation) return null;
1587
1877
  return interpolate(translation, params);
1588
1878
  }
1879
+ const STRING_OP_MAP = {
1880
+ $eq: (v) => v,
1881
+ $contains: (v) => ({ $containsi: v }),
1882
+ $endsWith: (v) => ({ $endsWith: v }),
1883
+ $null: (v) => v === true ? null : void 0,
1884
+ $notNull: (v) => v === true ? { $notNull: true } : void 0
1885
+ };
1886
+ const DATE_OP_MAP = {
1887
+ $gte: (v) => ({ $gte: v }),
1888
+ $lt: (v) => ({ $lt: v }),
1889
+ $lte: (v) => ({ $lte: v }),
1890
+ $between: (v) => ({ $between: v })
1891
+ // $in is handled separately: each ISO day-start is expanded to a [day, day+1) range.
1892
+ };
1893
+ const DAY_MS = 864e5;
1894
+ function nextDayIso(iso) {
1895
+ return new Date(new Date(iso).getTime() + DAY_MS).toISOString();
1896
+ }
1897
+ function expandCreatedAtInToDayRanges(days) {
1898
+ const ranges = days.map((d) => ({ createdAt: { $gte: d, $lt: nextDayIso(d) } }));
1899
+ return ranges.length === 1 ? ranges[0] : { $or: ranges };
1900
+ }
1901
+ const ACTION_OP_MAP = {
1902
+ $eq: (v) => v,
1903
+ $in: (v) => ({ $in: v })
1904
+ };
1905
+ function mapFieldFilter(conditions, field, filter, opMap) {
1906
+ for (const [op, value] of Object.entries(filter)) {
1907
+ const transform = opMap[op];
1908
+ if (!transform) continue;
1909
+ const result = transform(value);
1910
+ if (result !== void 0) conditions.push({ [field]: result });
1911
+ }
1912
+ }
1913
+ function buildWhereClause(filters) {
1914
+ const conditions = [];
1915
+ if (filters.action) mapFieldFilter(conditions, "action", filters.action, ACTION_OP_MAP);
1916
+ if (filters.email) mapFieldFilter(conditions, "email", filters.email, STRING_OP_MAP);
1917
+ if (filters.ip) mapFieldFilter(conditions, "ip", filters.ip, STRING_OP_MAP);
1918
+ if (filters.createdAt) {
1919
+ const { $in: inDays, ...rest } = filters.createdAt;
1920
+ if (Array.isArray(inDays) && inDays.length > 0) {
1921
+ conditions.push(expandCreatedAtInToDayRanges(inDays));
1922
+ }
1923
+ if (Object.keys(rest).length > 0) {
1924
+ mapFieldFilter(conditions, "createdAt", rest, DATE_OP_MAP);
1925
+ }
1926
+ }
1927
+ if (conditions.length === 0) return {};
1928
+ if (conditions.length === 1) return conditions[0];
1929
+ return { $and: conditions };
1930
+ }
1589
1931
  function auditLogService({ strapi: strapi2 }) {
1590
1932
  return {
1591
1933
  async log({ action, email, ip, detailsKey, detailsParams }) {
@@ -1610,20 +1952,31 @@ function auditLogService({ strapi: strapi2 }) {
1610
1952
  },
1611
1953
  async find({
1612
1954
  page = 1,
1613
- pageSize = 25
1955
+ pageSize = 25,
1956
+ filters
1614
1957
  } = {}) {
1615
- const result = await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").findPage({
1616
- sort: { createdAt: "desc" },
1617
- page,
1618
- pageSize
1619
- });
1620
- const results = result.results.map((row) => ({
1621
- ...row,
1622
- details: row.detailsKey ? translateDetails(row.detailsKey, row.detailsParams) : null
1623
- }));
1958
+ const where = filters ? buildWhereClause(filters) : {};
1959
+ const dbQuery = strapi2.db.query("plugin::strapi-plugin-oidc.audit-log");
1960
+ const [rows, total] = await Promise.all([
1961
+ dbQuery.findMany({
1962
+ where,
1963
+ orderBy: [{ createdAt: "desc" }],
1964
+ limit: pageSize,
1965
+ offset: (page - 1) * pageSize
1966
+ }),
1967
+ dbQuery.count({ where })
1968
+ ]);
1624
1969
  return {
1625
- results,
1626
- pagination: result.pagination
1970
+ results: rows.map((row) => ({
1971
+ ...row,
1972
+ details: row.detailsKey ? translateDetails(row.detailsKey, row.detailsParams) : null
1973
+ })),
1974
+ pagination: {
1975
+ page,
1976
+ pageSize,
1977
+ total,
1978
+ pageCount: Math.ceil(total / pageSize)
1979
+ }
1627
1980
  };
1628
1981
  },
1629
1982
  async clearAll() {
@@ -1635,7 +1988,7 @@ function auditLogService({ strapi: strapi2 }) {
1635
1988
  } while (deletedCount === BATCH_SIZE);
1636
1989
  },
1637
1990
  async cleanup(retentionDays) {
1638
- const cutoff = new Date(Date.now() - retentionDays * 864e5);
1991
+ const cutoff = new Date(Date.now() - retentionDays * DAY_MS);
1639
1992
  await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").deleteMany({ where: { createdAt: { $lt: cutoff } } });
1640
1993
  }
1641
1994
  };