strapi-plugin-oidc 1.6.6 → 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.
@@ -278,6 +278,7 @@ const en = {
278
278
  "page.save.error": "Update failed.",
279
279
  "page.add": "Add",
280
280
  "page.cancel": "Cancel",
281
+ "common.remove": "Remove {label}",
281
282
  "page.ok": "OK",
282
283
  "roles.title": "Default Role(s)",
283
284
  "roles.placeholder": "Select default role(s)",
@@ -309,7 +310,7 @@ const en = {
309
310
  "enforce.config.info": "Enforcement is controlled by the OIDC_ENFORCE config variable and cannot be changed here.",
310
311
  "login.settings.title": "Login Settings",
311
312
  "login.sso": "Login via SSO",
312
- "whitelist.count": "{count, plural, one {# entry} other {# entries}}",
313
+ "pagination.total": "{count, plural, one {# entry} other {# entries}}",
313
314
  "whitelist.import": "Import",
314
315
  "whitelist.export": "Export",
315
316
  "whitelist.delete.all.label": "Delete All",
@@ -337,6 +338,20 @@ const en = {
337
338
  "auditlog.clear.success": "Audit logs cleared",
338
339
  "auditlog.clear.error": "Failed to clear audit logs",
339
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}",
340
355
  "auditlog.action.login_success": "User successfully authenticated via OIDC and was granted access.",
341
356
  "auditlog.action.user_created": "A new Strapi admin account was created for this user on their first OIDC login.",
342
357
  "auditlog.action.logout": "User logged out and their OIDC session was ended.",
@@ -347,23 +362,74 @@ const en = {
347
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.",
348
363
  "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
349
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",
350
370
  "user.missing_code": "Authorisation code was not received from the OIDC provider.",
351
371
  "user.invalid_state": "State parameter mismatch. Please restart the login flow.",
352
372
  "user.signInError": "Authentication failed. Please try again.",
353
373
  "settings.section": "OIDC",
354
374
  "settings.configuration": "Configuration"
355
375
  };
356
- const userFacingMessages = {
357
- get missing_code() {
358
- return en["user.missing_code"];
359
- },
360
- get invalid_state() {
361
- return en["user.invalid_state"];
362
- },
363
- get signInError() {
364
- return en["user.signInError"];
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;
365
403
  }
366
- };
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
+ });
367
433
  class OidcError extends Error {
368
434
  kind;
369
435
  cause;
@@ -554,62 +620,99 @@ async function updateUserRoles(user, currentRoleIds, newRoleIds) {
554
620
  throw updateErr;
555
621
  }
556
622
  }
557
- async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
558
- const rawEmail = String(userResponseData.email ?? "");
559
- const email = rawEmail.toLowerCase();
560
- if (!email || !isValidEmail(email)) {
561
- throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
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);
562
633
  }
563
- await whitelistService2.checkWhitelistForEmail(email);
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) {
564
657
  const candidateNames = collectGroupMapRoleNames(userResponseData, config2);
565
- let roles2 = [];
566
- let fromGroupMapping = false;
567
- let resolvedRoleNames = [];
568
658
  if (candidateNames.length > 0) {
569
- const matchedRoles = await strapi.db.query("admin::role").findMany({
570
- where: { name: { $in: candidateNames } },
571
- select: ["id", "name"]
572
- });
573
- const nameToId = new Map(matchedRoles.map((r) => [r.name, String(r.id)]));
574
- for (const name of candidateNames) {
575
- const id = nameToId.get(name);
576
- if (id) roles2.push(id);
577
- }
578
- resolvedRoleNames = matchedRoles.map((r) => r.name);
579
- fromGroupMapping = true;
580
- } else {
581
- const oidcRolesResult = await roleService2.oidcRoles();
582
- roles2 = oidcRolesResult?.roles || [];
583
- if (roles2.length > 0) {
584
- const oidcRoleRecords = await strapi.db.query("admin::role").findMany({
585
- where: { id: { $in: roles2.map(Number) } },
586
- select: ["id", "name"]
587
- });
588
- resolvedRoleNames = oidcRoleRecords.map((r) => r.name);
589
- }
659
+ return resolveRolesFromGroups(candidateNames);
590
660
  }
591
- let userCreated = false;
592
- let rolesUpdated = false;
593
- let user = await userService.findOneByEmail(email, ["roles"]);
594
- if (!user) {
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) {
595
666
  try {
596
- user = await registerNewUser(oauthService2, email, userResponseData, config2, ctx, roles2);
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 };
597
676
  } catch (e) {
598
677
  const msg = e instanceof Error ? e.message : String(e);
599
678
  throw new OidcError("user_creation_failed", msg, e);
600
679
  }
601
- userCreated = true;
602
- rolesUpdated = true;
603
- } else if (fromGroupMapping && roles2.length > 0) {
604
- const currentRoleIds = new Set((user.roles ?? []).map((r) => String(r.id)));
605
- if (rolesChanged(currentRoleIds, new Set(roles2))) {
606
- await updateUserRoles(user, currentRoleIds, roles2);
607
- rolesUpdated = true;
608
- }
609
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
+ }
691
+ async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
692
+ const email = String(userResponseData.email ?? "").toLowerCase();
693
+ if (!email || !isValidEmail(email)) {
694
+ throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
695
+ }
696
+ await whitelistService2.checkWhitelistForEmail(email);
697
+ const resolved = await resolveRoles(userResponseData, config2, roleService2);
698
+ const { user, userCreated, rolesUpdated } = await ensureUser(
699
+ userService,
700
+ oauthService2,
701
+ email,
702
+ userResponseData,
703
+ config2,
704
+ ctx,
705
+ resolved
706
+ );
610
707
  const jwtToken = await oauthService2.generateToken(user, ctx);
611
708
  oauthService2.triggerSignInSuccess(user);
612
- return { activateUser: user, jwtToken, userCreated, rolesUpdated, resolvedRoleNames };
709
+ return {
710
+ activateUser: user,
711
+ jwtToken,
712
+ userCreated,
713
+ rolesUpdated,
714
+ resolvedRoleNames: resolved.resolvedRoleNames
715
+ };
613
716
  }
614
717
  function classifyOidcError(e, userInfo) {
615
718
  const kind = e instanceof OidcError ? e.kind : "unknown";
@@ -628,26 +731,76 @@ function classifyOidcError(e, userInfo) {
628
731
  params
629
732
  };
630
733
  }
734
+ function readAndClearPkceCookies(ctx) {
735
+ const oidcState = ctx.cookies.get("oidc_state");
736
+ const codeVerifier = ctx.cookies.get("oidc_code_verifier");
737
+ const oidcNonce = ctx.cookies.get("oidc_nonce");
738
+ ctx.cookies.set("oidc_state", null);
739
+ ctx.cookies.set("oidc_code_verifier", null);
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
+ }
631
787
  async function oidcSignInCallback(ctx) {
632
788
  const config2 = configValidation();
633
- const userService = getAdminUserService();
634
789
  const oauthService2 = getOauthService();
635
- const roleService2 = getRoleService();
636
- const whitelistService2 = getWhitelistService();
637
790
  const auditLog2 = getAuditLogService();
791
+ const locale = negotiateLocale(ctx.request.headers["accept-language"]);
638
792
  if (!ctx.query.code) {
639
793
  await auditLog2.log({ action: "missing_code", ip: ctx.ip });
640
- return ctx.send(oauthService2.renderSignUpError(userFacingMessages.missing_code));
794
+ return ctx.send(
795
+ oauthService2.renderSignUpError(userFacingMessages(locale).missing_code, locale)
796
+ );
641
797
  }
642
- const oidcState = ctx.cookies.get("oidc_state");
643
- const codeVerifier = ctx.cookies.get("oidc_code_verifier");
644
- const oidcNonce = ctx.cookies.get("oidc_nonce");
645
- ctx.cookies.set("oidc_state", null);
646
- ctx.cookies.set("oidc_code_verifier", null);
647
- ctx.cookies.set("oidc_nonce", null);
798
+ const { oidcState, codeVerifier, oidcNonce } = readAndClearPkceCookies(ctx);
648
799
  if (!ctx.query.state || ctx.query.state !== oidcState) {
649
800
  await auditLog2.log({ action: "state_mismatch", ip: ctx.ip });
650
- return ctx.send(oauthService2.renderSignUpError(userFacingMessages.invalid_state));
801
+ return ctx.send(
802
+ oauthService2.renderSignUpError(userFacingMessages(locale).invalid_state, locale)
803
+ );
651
804
  }
652
805
  const params = new URLSearchParams({
653
806
  code: ctx.query.code,
@@ -661,67 +814,53 @@ async function oidcSignInCallback(ctx) {
661
814
  try {
662
815
  const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
663
816
  userInfo = exchangeResult.userInfo;
664
- const accessToken = exchangeResult.accessToken;
665
817
  const isProduction = strapi.config.get("environment") === "production";
666
- ctx.cookies.set("oidc_access_token", accessToken, {
818
+ const secureFlag = isProduction && ctx.request.secure;
819
+ ctx.cookies.set("oidc_access_token", exchangeResult.accessToken, {
667
820
  httpOnly: true,
668
821
  maxAge: 3e5,
669
- secure: isProduction && ctx.request.secure,
822
+ secure: secureFlag,
670
823
  sameSite: "lax"
671
824
  });
672
825
  const { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames } = await handleUserAuthentication(
673
- userService,
826
+ getAdminUserService(),
674
827
  oauthService2,
675
- roleService2,
676
- whitelistService2,
828
+ getRoleService(),
829
+ getWhitelistService(),
677
830
  userInfo,
678
831
  config2,
679
832
  ctx
680
833
  );
681
- const identityCookieOptions = {
834
+ ctx.cookies.set("oidc_user_email", activateUser.email, {
682
835
  httpOnly: true,
683
836
  path: "/",
684
- secure: isProduction && ctx.request.secure,
837
+ secure: secureFlag,
685
838
  sameSite: "lax"
686
- };
687
- ctx.cookies.set("oidc_user_email", activateUser.email, identityCookieOptions);
688
- if (userCreated) {
689
- await auditLog2.log({
690
- action: "user_created",
691
- email: activateUser.email,
692
- ip: ctx.ip,
693
- detailsKey: "user_created",
694
- detailsParams: { roles: resolvedRoleNames.join(", ") }
695
- });
696
- }
697
- await auditLog2.log({
698
- action: "login_success",
699
- email: activateUser.email,
700
- ip: ctx.ip,
701
- detailsKey: rolesUpdated ? "roles_updated" : void 0,
702
- detailsParams: rolesUpdated ? { roles: resolvedRoleNames.join(", ") } : void 0
703
839
  });
840
+ await logSuccessfulAuth(
841
+ auditLog2,
842
+ ctx,
843
+ activateUser,
844
+ userCreated,
845
+ rolesUpdated,
846
+ resolvedRoleNames
847
+ );
704
848
  const nonce = randomUUID();
705
- const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
706
849
  ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
707
- ctx.send(html);
850
+ ctx.send(oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce, locale));
708
851
  } catch (e) {
709
- const errorInfo = classifyOidcError(e, userInfo);
710
- await auditLog2.log({
711
- action: errorInfo.action,
712
- email: userInfo?.email,
713
- ip: ctx.ip,
714
- detailsKey: errorInfo.action,
715
- detailsParams: errorInfo.action === "login_failure" ? { message: e instanceof Error ? e.message : String(e) } : void 0
716
- });
717
- strapi.log.error({
718
- code: errorInfo.code,
719
- phase: "oidc_callback",
720
- message: e instanceof Error ? e.message : "Unknown sign-in error",
721
- detail: errorInfo.key ? getErrorDetail(errorInfo.key, errorInfo.params) : void 0,
722
- 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)
723
860
  });
724
- ctx.send(oauthService2.renderSignUpError(userFacingMessages.signInError));
861
+ return response.ok;
862
+ } catch {
863
+ return false;
725
864
  }
726
865
  }
727
866
  async function logout(ctx) {
@@ -729,38 +868,27 @@ async function logout(ctx) {
729
868
  const auditLog2 = getAuditLogService();
730
869
  const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
731
870
  const adminPanelUrl = strapi.config.get("admin.url", "/admin");
871
+ const loginUrl = `${adminPanelUrl}/auth/login`;
732
872
  const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
733
873
  const accessToken = ctx.cookies.get("oidc_access_token");
734
874
  const userEmail = ctx.cookies.get("oidc_user_email") ?? void 0;
735
875
  clearAuthCookies(strapi, ctx);
736
- if (logoutUrl && isOidcSession && accessToken) {
737
- try {
738
- const response = await fetch(config2.OIDC_USERINFO_ENDPOINT, {
739
- headers: { Authorization: `Bearer ${accessToken}` },
740
- signal: AbortSignal.timeout(LOGOUT_USERINFO_TIMEOUT_MS)
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(() => {
741
884
  });
742
- if (response.ok) {
743
- if (userEmail)
744
- auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip }).catch(() => {
745
- });
746
- return ctx.redirect(logoutUrl);
747
- }
748
- if (userEmail)
749
- await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
750
- return ctx.redirect(`${adminPanelUrl}/auth/login`);
751
- } catch {
752
- if (userEmail)
753
- await auditLog2.log({ action: "session_expired", email: userEmail, ip: ctx.ip });
754
- return ctx.redirect(`${adminPanelUrl}/auth/login`);
885
+ return ctx.redirect(logoutUrl);
755
886
  }
887
+ await logAudit("session_expired");
888
+ return ctx.redirect(loginUrl);
756
889
  }
757
- if (isOidcSession && userEmail) {
758
- await auditLog2.log({ action: "logout", email: userEmail, ip: ctx.ip });
759
- }
760
- if (logoutUrl && isOidcSession) {
761
- return ctx.redirect(logoutUrl);
762
- }
763
- ctx.redirect(`${adminPanelUrl}/auth/login`);
890
+ await logAudit("logout");
891
+ ctx.redirect(logoutUrl || loginUrl);
764
892
  }
765
893
  const oidc = {
766
894
  oidcSignIn,
@@ -935,11 +1063,149 @@ const whitelist = {
935
1063
  importUsers,
936
1064
  exportWhitelist
937
1065
  };
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);
1081
+ }
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;
1096
+ }
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
+ }
938
1204
  const EXPORT_PAGE_SIZE = 500;
939
- async function* ndjsonRowStream(service) {
1205
+ async function* ndjsonRowStream(service, filters) {
940
1206
  let page = 1;
941
1207
  while (true) {
942
- const { results } = await service.find({ page, pageSize: EXPORT_PAGE_SIZE });
1208
+ const { results } = await service.find({ page, pageSize: EXPORT_PAGE_SIZE, filters });
943
1209
  if (results.length === 0) return;
944
1210
  let chunk = "";
945
1211
  for (const row of results) {
@@ -956,28 +1222,35 @@ async function* ndjsonRowStream(service) {
956
1222
  page++;
957
1223
  }
958
1224
  }
959
- function errorAwareNdjsonStream(service) {
960
- const gen = ndjsonRowStream(service);
1225
+ function errorAwareNdjsonStream(strapi2, service, filters) {
1226
+ const gen = ndjsonRowStream(service, filters);
961
1227
  const readable = Readable.from(gen);
962
1228
  readable.on("error", (err) => {
963
- strapi$1.log.error({ phase: "audit_log_export", err }, "NDJSON export stream failed");
1229
+ strapi2.log.error({ phase: "audit_log_export", err }, "NDJSON export stream failed");
964
1230
  });
965
1231
  return readable;
966
1232
  }
967
- let strapi$1;
968
- function find(ctx) {
969
- strapi$1 = ctx.strapi;
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;
970
1245
  const page = Math.max(1, Number(ctx.query.page) || 1);
971
1246
  const pageSize = Math.min(100, Math.max(1, Number(ctx.query.pageSize) || 25));
972
- return getAuditLogService().find({ page, pageSize }).then((result) => {
973
- ctx.body = result;
974
- });
1247
+ ctx.body = await getAuditLogService().find({ page, pageSize, filters });
975
1248
  }
976
1249
  async function exportLogs(ctx) {
977
- strapi$1 = ctx.strapi;
1250
+ const filters = parseFiltersOr400(ctx);
1251
+ if (!filters) return;
978
1252
  setNdjsonAttachmentHeaders(ctx, "strapi-oidc-audit-log");
979
- const service = getAuditLogService();
980
- ctx.body = errorAwareNdjsonStream(service);
1253
+ ctx.body = errorAwareNdjsonStream(ctx.strapi, getAuditLogService(), filters);
981
1254
  }
982
1255
  async function clearAll(ctx) {
983
1256
  await getAuditLogService().clearAll();
@@ -1191,10 +1464,10 @@ const routes = {
1191
1464
  }
1192
1465
  };
1193
1466
  const policies = {};
1194
- function renderHtmlTemplate(title, content) {
1467
+ function renderHtmlTemplate(title, content, locale = "en") {
1195
1468
  return `
1196
1469
  <!doctype html>
1197
- <html lang="en">
1470
+ <html lang="${locale}">
1198
1471
  <head>
1199
1472
  <meta charset="utf-8">
1200
1473
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -1373,9 +1646,10 @@ function oauthService({ strapi: strapi2 }) {
1373
1646
  provider: "strapi-plugin-oidc"
1374
1647
  });
1375
1648
  },
1376
- renderSignUpSuccess(jwtToken, user, nonce) {
1649
+ renderSignUpSuccess(jwtToken, user, nonce, locale = "en") {
1377
1650
  const config2 = strapi2.config.get("plugin::strapi-plugin-oidc");
1378
1651
  const isRememberMe = !!config2?.REMEMBER_ME;
1652
+ const messages = authPageMessages(locale);
1379
1653
  const content = `
1380
1654
  <noscript>
1381
1655
  <div class="card">
@@ -1384,8 +1658,8 @@ function oauthService({ strapi: strapi2 }) {
1384
1658
  <path d="M20 6 9 17l-5-5"/>
1385
1659
  </svg>
1386
1660
  </div>
1387
- <h1>JavaScript Required</h1>
1388
- <p>JavaScript must be enabled for authentication to complete.</p>
1661
+ <h1>${messages.noscriptHeading}</h1>
1662
+ <p>${messages.noscriptBody}</p>
1389
1663
  </div>
1390
1664
  </noscript>
1391
1665
  <script nonce="${nonce}">
@@ -1399,9 +1673,10 @@ function oauthService({ strapi: strapi2 }) {
1399
1673
  location.href = '${strapi2.config.admin.url}'
1400
1674
  })
1401
1675
  <\/script>`;
1402
- return renderHtmlTemplate("Authenticating...", content);
1676
+ return renderHtmlTemplate(messages.authenticatingTitle, content, locale);
1403
1677
  },
1404
- renderSignUpError(message) {
1678
+ renderSignUpError(message, locale = "en") {
1679
+ const messages = authPageMessages(locale);
1405
1680
  const safeMessage = String(message).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1406
1681
  const content = `
1407
1682
  <div class="card">
@@ -1412,11 +1687,11 @@ function oauthService({ strapi: strapi2 }) {
1412
1687
  <path d="M12 17h.01"/>
1413
1688
  </svg>
1414
1689
  </div>
1415
- <h1>Authentication Failed</h1>
1690
+ <h1>${messages.errorTitle}</h1>
1416
1691
  <p>${safeMessage}</p>
1417
- <a href="${strapi2.config.admin.url}" class="btn">Return to Login</a>
1692
+ <a href="${strapi2.config.admin.url}" class="btn">${messages.returnToLogin}</a>
1418
1693
  </div>`;
1419
- return renderHtmlTemplate("Authentication Failed", content);
1694
+ return renderHtmlTemplate(messages.errorTitle, content, locale);
1420
1695
  },
1421
1696
  async generateToken(user, ctx) {
1422
1697
  const sessionManager = strapi2.sessionManager;
@@ -1580,6 +1855,58 @@ function translateDetails(key, params) {
1580
1855
  if (!translation) return null;
1581
1856
  return interpolate(translation, params);
1582
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
+ }
1583
1910
  function auditLogService({ strapi: strapi2 }) {
1584
1911
  return {
1585
1912
  async log({ action, email, ip, detailsKey, detailsParams }) {
@@ -1604,20 +1931,31 @@ function auditLogService({ strapi: strapi2 }) {
1604
1931
  },
1605
1932
  async find({
1606
1933
  page = 1,
1607
- pageSize = 25
1934
+ pageSize = 25,
1935
+ filters
1608
1936
  } = {}) {
1609
- const result = await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").findPage({
1610
- sort: { createdAt: "desc" },
1611
- page,
1612
- pageSize
1613
- });
1614
- const results = result.results.map((row) => ({
1615
- ...row,
1616
- details: row.detailsKey ? translateDetails(row.detailsKey, row.detailsParams) : null
1617
- }));
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
+ ]);
1618
1948
  return {
1619
- results,
1620
- 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
+ }
1621
1959
  };
1622
1960
  },
1623
1961
  async clearAll() {
@@ -1629,7 +1967,7 @@ function auditLogService({ strapi: strapi2 }) {
1629
1967
  } while (deletedCount === BATCH_SIZE);
1630
1968
  },
1631
1969
  async cleanup(retentionDays) {
1632
- const cutoff = new Date(Date.now() - retentionDays * 864e5);
1970
+ const cutoff = new Date(Date.now() - retentionDays * DAY_MS);
1633
1971
  await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").deleteMany({ where: { createdAt: { $lt: cutoff } } });
1634
1972
  }
1635
1973
  };