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.
@@ -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;
@@ -411,6 +477,21 @@ const OIDC_ERROR_DISPATCH = {
411
477
  key: "sign_in_unknown"
412
478
  }
413
479
  };
480
+ function getClientIp(ctx) {
481
+ const cfConnectingIp = ctx.get("CF-Connecting-IP");
482
+ if (cfConnectingIp) {
483
+ return cfConnectingIp.split(",")[0].trim();
484
+ }
485
+ const forwardedFor = ctx.get("X-Forwarded-For");
486
+ if (forwardedFor) {
487
+ return forwardedFor.split(",")[0].trim();
488
+ }
489
+ const realIp = ctx.get("X-Real-IP");
490
+ if (realIp) {
491
+ return realIp.trim();
492
+ }
493
+ return ctx.ip;
494
+ }
414
495
  const REQUIRED_CONFIG_KEYS = [
415
496
  "OIDC_CLIENT_ID",
416
497
  "OIDC_CLIENT_SECRET",
@@ -554,62 +635,99 @@ async function updateUserRoles(user, currentRoleIds, newRoleIds) {
554
635
  throw updateErr;
555
636
  }
556
637
  }
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);
638
+ async function resolveRolesFromGroups(candidateNames) {
639
+ const matchedRoles = await strapi.db.query("admin::role").findMany({
640
+ where: { name: { $in: candidateNames } },
641
+ select: ["id", "name"]
642
+ });
643
+ const nameToId = new Map(matchedRoles.map((r) => [r.name, String(r.id)]));
644
+ const roles2 = [];
645
+ for (const name of candidateNames) {
646
+ const id = nameToId.get(name);
647
+ if (id) roles2.push(id);
562
648
  }
563
- await whitelistService2.checkWhitelistForEmail(email);
649
+ return {
650
+ roles: roles2,
651
+ fromGroupMapping: true,
652
+ resolvedRoleNames: matchedRoles.map((r) => r.name)
653
+ };
654
+ }
655
+ async function resolveRolesFromDefaults(roleService2) {
656
+ const oidcRolesResult = await roleService2.oidcRoles();
657
+ const roles2 = oidcRolesResult?.roles || [];
658
+ if (roles2.length === 0) {
659
+ return { roles: roles2, fromGroupMapping: false, resolvedRoleNames: [] };
660
+ }
661
+ const records = await strapi.db.query("admin::role").findMany({
662
+ where: { id: { $in: roles2.map(Number) } },
663
+ select: ["id", "name"]
664
+ });
665
+ return {
666
+ roles: roles2,
667
+ fromGroupMapping: false,
668
+ resolvedRoleNames: records.map((r) => r.name)
669
+ };
670
+ }
671
+ async function resolveRoles(userResponseData, config2, roleService2) {
564
672
  const candidateNames = collectGroupMapRoleNames(userResponseData, config2);
565
- let roles2 = [];
566
- let fromGroupMapping = false;
567
- let resolvedRoleNames = [];
568
673
  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
- }
674
+ return resolveRolesFromGroups(candidateNames);
590
675
  }
591
- let userCreated = false;
592
- let rolesUpdated = false;
593
- let user = await userService.findOneByEmail(email, ["roles"]);
594
- if (!user) {
676
+ return resolveRolesFromDefaults(roleService2);
677
+ }
678
+ async function ensureUser(userService, oauthService2, email, userResponseData, config2, ctx, resolved) {
679
+ const existing = await userService.findOneByEmail(email, ["roles"]);
680
+ if (!existing) {
595
681
  try {
596
- user = await registerNewUser(oauthService2, email, userResponseData, config2, ctx, roles2);
682
+ const user = await registerNewUser(
683
+ oauthService2,
684
+ email,
685
+ userResponseData,
686
+ config2,
687
+ ctx,
688
+ resolved.roles
689
+ );
690
+ return { user, userCreated: true, rolesUpdated: true };
597
691
  } catch (e) {
598
692
  const msg = e instanceof Error ? e.message : String(e);
599
693
  throw new OidcError("user_creation_failed", msg, e);
600
694
  }
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
695
  }
696
+ if (!resolved.fromGroupMapping || resolved.roles.length === 0) {
697
+ return { user: existing, userCreated: false, rolesUpdated: false };
698
+ }
699
+ const currentRoleIds = new Set((existing.roles ?? []).map((r) => String(r.id)));
700
+ if (!rolesChanged(currentRoleIds, new Set(resolved.roles))) {
701
+ return { user: existing, userCreated: false, rolesUpdated: false };
702
+ }
703
+ await updateUserRoles(existing, currentRoleIds, resolved.roles);
704
+ return { user: existing, userCreated: false, rolesUpdated: true };
705
+ }
706
+ async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
707
+ const email = String(userResponseData.email ?? "").toLowerCase();
708
+ if (!email || !isValidEmail(email)) {
709
+ throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
710
+ }
711
+ await whitelistService2.checkWhitelistForEmail(email);
712
+ const resolved = await resolveRoles(userResponseData, config2, roleService2);
713
+ const { user, userCreated, rolesUpdated } = await ensureUser(
714
+ userService,
715
+ oauthService2,
716
+ email,
717
+ userResponseData,
718
+ config2,
719
+ ctx,
720
+ resolved
721
+ );
610
722
  const jwtToken = await oauthService2.generateToken(user, ctx);
611
723
  oauthService2.triggerSignInSuccess(user);
612
- return { activateUser: user, jwtToken, userCreated, rolesUpdated, resolvedRoleNames };
724
+ return {
725
+ activateUser: user,
726
+ jwtToken,
727
+ userCreated,
728
+ rolesUpdated,
729
+ resolvedRoleNames: resolved.resolvedRoleNames
730
+ };
613
731
  }
614
732
  function classifyOidcError(e, userInfo) {
615
733
  const kind = e instanceof OidcError ? e.kind : "unknown";
@@ -628,26 +746,76 @@ function classifyOidcError(e, userInfo) {
628
746
  params
629
747
  };
630
748
  }
631
- async function oidcSignInCallback(ctx) {
632
- const config2 = configValidation();
633
- const userService = getAdminUserService();
634
- const oauthService2 = getOauthService();
635
- const roleService2 = getRoleService();
636
- const whitelistService2 = getWhitelistService();
637
- const auditLog2 = getAuditLogService();
638
- if (!ctx.query.code) {
639
- await auditLog2.log({ action: "missing_code", ip: ctx.ip });
640
- return ctx.send(oauthService2.renderSignUpError(userFacingMessages.missing_code));
641
- }
749
+ function readAndClearPkceCookies(ctx) {
642
750
  const oidcState = ctx.cookies.get("oidc_state");
643
751
  const codeVerifier = ctx.cookies.get("oidc_code_verifier");
644
752
  const oidcNonce = ctx.cookies.get("oidc_nonce");
645
753
  ctx.cookies.set("oidc_state", null);
646
754
  ctx.cookies.set("oidc_code_verifier", null);
647
755
  ctx.cookies.set("oidc_nonce", null);
756
+ return { oidcState, codeVerifier, oidcNonce };
757
+ }
758
+ async function logSuccessfulAuth(auditLog2, ctx, user, userCreated, rolesUpdated, resolvedRoleNames) {
759
+ const roles2 = resolvedRoleNames.join(", ");
760
+ const entries = [
761
+ auditLog2.log({
762
+ action: "login_success",
763
+ email: user.email,
764
+ ip: getClientIp(ctx),
765
+ detailsKey: rolesUpdated ? "roles_updated" : void 0,
766
+ detailsParams: rolesUpdated ? { roles: roles2 } : void 0
767
+ })
768
+ ];
769
+ if (userCreated) {
770
+ entries.push(
771
+ auditLog2.log({
772
+ action: "user_created",
773
+ email: user.email,
774
+ ip: getClientIp(ctx),
775
+ detailsKey: "user_created",
776
+ detailsParams: { roles: roles2 }
777
+ })
778
+ );
779
+ }
780
+ await Promise.all(entries);
781
+ }
782
+ async function handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx) {
783
+ const errorInfo = classifyOidcError(e, userInfo);
784
+ const message = e instanceof Error ? e.message : String(e);
785
+ await auditLog2.log({
786
+ action: errorInfo.action,
787
+ email: userInfo?.email,
788
+ ip: getClientIp(ctx),
789
+ detailsKey: errorInfo.action,
790
+ detailsParams: errorInfo.action === "login_failure" ? { message } : void 0
791
+ });
792
+ strapi.log.error({
793
+ code: errorInfo.code,
794
+ phase: "oidc_callback",
795
+ message: e instanceof Error ? e.message : "Unknown sign-in error",
796
+ detail: errorInfo.key ? getErrorDetail(errorInfo.key, errorInfo.params) : void 0,
797
+ email: userInfo?.email
798
+ });
799
+ const locale = negotiateLocale(ctx.request.headers["accept-language"]);
800
+ ctx.send(oauthService2.renderSignUpError(userFacingMessages(locale).signInError, locale));
801
+ }
802
+ async function oidcSignInCallback(ctx) {
803
+ const config2 = configValidation();
804
+ const oauthService2 = getOauthService();
805
+ const auditLog2 = getAuditLogService();
806
+ const locale = negotiateLocale(ctx.request.headers["accept-language"]);
807
+ if (!ctx.query.code) {
808
+ await auditLog2.log({ action: "missing_code", ip: getClientIp(ctx) });
809
+ return ctx.send(
810
+ oauthService2.renderSignUpError(userFacingMessages(locale).missing_code, locale)
811
+ );
812
+ }
813
+ const { oidcState, codeVerifier, oidcNonce } = readAndClearPkceCookies(ctx);
648
814
  if (!ctx.query.state || ctx.query.state !== oidcState) {
649
- await auditLog2.log({ action: "state_mismatch", ip: ctx.ip });
650
- return ctx.send(oauthService2.renderSignUpError(userFacingMessages.invalid_state));
815
+ await auditLog2.log({ action: "state_mismatch", ip: getClientIp(ctx) });
816
+ return ctx.send(
817
+ oauthService2.renderSignUpError(userFacingMessages(locale).invalid_state, locale)
818
+ );
651
819
  }
652
820
  const params = new URLSearchParams({
653
821
  code: ctx.query.code,
@@ -661,67 +829,53 @@ async function oidcSignInCallback(ctx) {
661
829
  try {
662
830
  const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
663
831
  userInfo = exchangeResult.userInfo;
664
- const accessToken = exchangeResult.accessToken;
665
832
  const isProduction = strapi.config.get("environment") === "production";
666
- ctx.cookies.set("oidc_access_token", accessToken, {
833
+ const secureFlag = isProduction && ctx.request.secure;
834
+ ctx.cookies.set("oidc_access_token", exchangeResult.accessToken, {
667
835
  httpOnly: true,
668
836
  maxAge: 3e5,
669
- secure: isProduction && ctx.request.secure,
837
+ secure: secureFlag,
670
838
  sameSite: "lax"
671
839
  });
672
840
  const { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames } = await handleUserAuthentication(
673
- userService,
841
+ getAdminUserService(),
674
842
  oauthService2,
675
- roleService2,
676
- whitelistService2,
843
+ getRoleService(),
844
+ getWhitelistService(),
677
845
  userInfo,
678
846
  config2,
679
847
  ctx
680
848
  );
681
- const identityCookieOptions = {
849
+ ctx.cookies.set("oidc_user_email", activateUser.email, {
682
850
  httpOnly: true,
683
851
  path: "/",
684
- secure: isProduction && ctx.request.secure,
852
+ secure: secureFlag,
685
853
  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
854
  });
855
+ await logSuccessfulAuth(
856
+ auditLog2,
857
+ ctx,
858
+ activateUser,
859
+ userCreated,
860
+ rolesUpdated,
861
+ resolvedRoleNames
862
+ );
704
863
  const nonce = randomUUID();
705
- const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
706
864
  ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
707
- ctx.send(html);
865
+ ctx.send(oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce, locale));
708
866
  } 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
867
+ await handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx);
868
+ }
869
+ }
870
+ async function isProviderSessionActive(userinfoEndpoint, accessToken) {
871
+ try {
872
+ const response = await fetch(userinfoEndpoint, {
873
+ headers: { Authorization: `Bearer ${accessToken}` },
874
+ signal: AbortSignal.timeout(LOGOUT_USERINFO_TIMEOUT_MS)
723
875
  });
724
- ctx.send(oauthService2.renderSignUpError(userFacingMessages.signInError));
876
+ return response.ok;
877
+ } catch {
878
+ return false;
725
879
  }
726
880
  }
727
881
  async function logout(ctx) {
@@ -729,38 +883,27 @@ async function logout(ctx) {
729
883
  const auditLog2 = getAuditLogService();
730
884
  const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
731
885
  const adminPanelUrl = strapi.config.get("admin.url", "/admin");
886
+ const loginUrl = `${adminPanelUrl}/auth/login`;
732
887
  const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
733
888
  const accessToken = ctx.cookies.get("oidc_access_token");
734
889
  const userEmail = ctx.cookies.get("oidc_user_email") ?? void 0;
735
890
  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)
891
+ if (!isOidcSession) {
892
+ return ctx.redirect(loginUrl);
893
+ }
894
+ const logAudit = (action) => userEmail ? auditLog2.log({ action, email: userEmail, ip: getClientIp(ctx) }) : Promise.resolve();
895
+ if (logoutUrl && accessToken) {
896
+ const active = await isProviderSessionActive(config2.OIDC_USERINFO_ENDPOINT, accessToken);
897
+ if (active) {
898
+ logAudit("logout").catch(() => {
741
899
  });
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`);
900
+ return ctx.redirect(logoutUrl);
755
901
  }
902
+ await logAudit("session_expired");
903
+ return ctx.redirect(loginUrl);
756
904
  }
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`);
905
+ await logAudit("logout");
906
+ ctx.redirect(logoutUrl || loginUrl);
764
907
  }
765
908
  const oidc = {
766
909
  oidcSignIn,
@@ -935,11 +1078,149 @@ const whitelist = {
935
1078
  importUsers,
936
1079
  exportWhitelist
937
1080
  };
1081
+ const AUDIT_ACTIONS = [
1082
+ "login_success",
1083
+ "login_failure",
1084
+ "missing_code",
1085
+ "state_mismatch",
1086
+ "nonce_mismatch",
1087
+ "token_exchange_failed",
1088
+ "whitelist_rejected",
1089
+ "logout",
1090
+ "session_expired",
1091
+ "user_created"
1092
+ ];
1093
+ const ISO_UTC_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
1094
+ function isIsoUtcDatetime(value) {
1095
+ return typeof value === "string" && ISO_UTC_DATETIME.test(value);
1096
+ }
1097
+ const ALLOWED_FIELDS = /* @__PURE__ */ new Set(["action", "email", "ip", "createdAt"]);
1098
+ const STRING_OPERATORS = /* @__PURE__ */ new Set([
1099
+ "$eq",
1100
+ "$contains",
1101
+ "$endsWith",
1102
+ "$null",
1103
+ "$notNull"
1104
+ ]);
1105
+ const DATE_OPERATORS = /* @__PURE__ */ new Set(["$gte", "$lt", "$lte", "$between", "$in"]);
1106
+ const ENUM_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$in"]);
1107
+ function isPlainObject(value) {
1108
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1109
+ const proto = Object.getPrototypeOf(value);
1110
+ return proto === Object.prototype || proto === null;
1111
+ }
1112
+ function isStringOperator(op) {
1113
+ return STRING_OPERATORS.has(op);
1114
+ }
1115
+ function isDateOperator(op) {
1116
+ return DATE_OPERATORS.has(op);
1117
+ }
1118
+ function isEnumOperator(op) {
1119
+ return ENUM_OPERATORS.has(op);
1120
+ }
1121
+ function isAuditAction(value) {
1122
+ return AUDIT_ACTIONS.includes(value);
1123
+ }
1124
+ class ValidationError extends Error {
1125
+ constructor(message) {
1126
+ super(message);
1127
+ this.name = "ValidationError";
1128
+ }
1129
+ }
1130
+ function requireType(field, op, value, check, expected) {
1131
+ if (!check) {
1132
+ throw new ValidationError(`Operator "${op}" for field "${field}" requires ${expected}`);
1133
+ }
1134
+ return value;
1135
+ }
1136
+ function parseActionOperator(op, opValue) {
1137
+ if (!isEnumOperator(op)) {
1138
+ throw new ValidationError(`Unknown operator "${op}" for field "action"`);
1139
+ }
1140
+ if (op === "$in") {
1141
+ requireType("action", op, opValue, Array.isArray(opValue), "an array value");
1142
+ for (const v of opValue) {
1143
+ if (!isAuditAction(v)) {
1144
+ throw new ValidationError(
1145
+ `Invalid action value "${v}" — must be one of: ${AUDIT_ACTIONS.join(", ")}`
1146
+ );
1147
+ }
1148
+ }
1149
+ return opValue;
1150
+ }
1151
+ if (!isAuditAction(opValue)) {
1152
+ throw new ValidationError(
1153
+ `Invalid action value "${opValue}" — must be one of: ${AUDIT_ACTIONS.join(", ")}`
1154
+ );
1155
+ }
1156
+ return opValue;
1157
+ }
1158
+ function parseCreatedAtOperator(op, opValue) {
1159
+ if (!isDateOperator(op)) {
1160
+ throw new ValidationError(`Unknown operator "${op}" for field "createdAt"`);
1161
+ }
1162
+ const expected = 'an ISO-8601 UTC datetime string (e.g. "2024-01-15T00:00:00.000Z")';
1163
+ if (op === "$between") {
1164
+ const isTuple = Array.isArray(opValue) && opValue.length === 2;
1165
+ requireType("createdAt", op, opValue, isTuple, "a tuple [start, end]");
1166
+ const [a, b] = opValue;
1167
+ requireType("createdAt", op, opValue, isIsoUtcDatetime(a) && isIsoUtcDatetime(b), expected);
1168
+ return opValue;
1169
+ }
1170
+ if (op === "$in") {
1171
+ requireType("createdAt", op, opValue, Array.isArray(opValue), "an array value");
1172
+ for (const v of opValue) {
1173
+ requireType("createdAt", op, v, isIsoUtcDatetime(v), expected);
1174
+ }
1175
+ return opValue;
1176
+ }
1177
+ return requireType("createdAt", op, opValue, isIsoUtcDatetime(opValue), expected);
1178
+ }
1179
+ function parseStringFieldOperator(field, op, opValue) {
1180
+ if (!isStringOperator(op)) {
1181
+ throw new ValidationError(`Unknown operator "${op}" for field "${field}"`);
1182
+ }
1183
+ if (op === "$null" || op === "$notNull") {
1184
+ return requireType(field, op, opValue, typeof opValue === "boolean", "a boolean value");
1185
+ }
1186
+ return requireType(field, op, opValue, typeof opValue === "string", "a string value");
1187
+ }
1188
+ function parseFieldOperators(field, fieldValue) {
1189
+ if (!isPlainObject(fieldValue)) {
1190
+ throw new ValidationError(
1191
+ `Filter field "${field}" must be an object of operators, got ${typeof fieldValue}`
1192
+ );
1193
+ }
1194
+ const parsed = {};
1195
+ for (const [op, opValue] of Object.entries(fieldValue)) {
1196
+ if (field === "action") parsed[op] = parseActionOperator(op, opValue);
1197
+ else if (field === "createdAt") parsed[op] = parseCreatedAtOperator(op, opValue);
1198
+ else parsed[op] = parseStringFieldOperator(field, op, opValue);
1199
+ }
1200
+ return Object.keys(parsed).length > 0 ? parsed : null;
1201
+ }
1202
+ function parseAuditLogFilters(query) {
1203
+ if (!isPlainObject(query)) return {};
1204
+ const result = {};
1205
+ const filters = query.filters;
1206
+ if (filters === void 0) return result;
1207
+ if (!isPlainObject(filters)) {
1208
+ throw new ValidationError(`"filters" must be an object, got ${typeof filters}`);
1209
+ }
1210
+ for (const [field, fieldValue] of Object.entries(filters)) {
1211
+ if (!ALLOWED_FIELDS.has(field)) {
1212
+ throw new ValidationError(`Unknown filter field: "${field}"`);
1213
+ }
1214
+ const parsed = parseFieldOperators(field, fieldValue);
1215
+ if (parsed) result[field] = parsed;
1216
+ }
1217
+ return result;
1218
+ }
938
1219
  const EXPORT_PAGE_SIZE = 500;
939
- async function* ndjsonRowStream(service) {
1220
+ async function* ndjsonRowStream(service, filters) {
940
1221
  let page = 1;
941
1222
  while (true) {
942
- const { results } = await service.find({ page, pageSize: EXPORT_PAGE_SIZE });
1223
+ const { results } = await service.find({ page, pageSize: EXPORT_PAGE_SIZE, filters });
943
1224
  if (results.length === 0) return;
944
1225
  let chunk = "";
945
1226
  for (const row of results) {
@@ -956,28 +1237,35 @@ async function* ndjsonRowStream(service) {
956
1237
  page++;
957
1238
  }
958
1239
  }
959
- function errorAwareNdjsonStream(service) {
960
- const gen = ndjsonRowStream(service);
1240
+ function errorAwareNdjsonStream(strapi2, service, filters) {
1241
+ const gen = ndjsonRowStream(service, filters);
961
1242
  const readable = Readable.from(gen);
962
1243
  readable.on("error", (err) => {
963
- strapi$1.log.error({ phase: "audit_log_export", err }, "NDJSON export stream failed");
1244
+ strapi2.log.error({ phase: "audit_log_export", err }, "NDJSON export stream failed");
964
1245
  });
965
1246
  return readable;
966
1247
  }
967
- let strapi$1;
968
- function find(ctx) {
969
- strapi$1 = ctx.strapi;
1248
+ function parseFiltersOr400(ctx) {
1249
+ try {
1250
+ return parseAuditLogFilters(ctx.query);
1251
+ } catch (err) {
1252
+ ctx.status = 400;
1253
+ ctx.body = { message: err instanceof ValidationError ? err.message : "Invalid filters" };
1254
+ return null;
1255
+ }
1256
+ }
1257
+ async function find(ctx) {
1258
+ const filters = parseFiltersOr400(ctx);
1259
+ if (!filters) return;
970
1260
  const page = Math.max(1, Number(ctx.query.page) || 1);
971
1261
  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
- });
1262
+ ctx.body = await getAuditLogService().find({ page, pageSize, filters });
975
1263
  }
976
1264
  async function exportLogs(ctx) {
977
- strapi$1 = ctx.strapi;
1265
+ const filters = parseFiltersOr400(ctx);
1266
+ if (!filters) return;
978
1267
  setNdjsonAttachmentHeaders(ctx, "strapi-oidc-audit-log");
979
- const service = getAuditLogService();
980
- ctx.body = errorAwareNdjsonStream(service);
1268
+ ctx.body = errorAwareNdjsonStream(ctx.strapi, getAuditLogService(), filters);
981
1269
  }
982
1270
  async function clearAll(ctx) {
983
1271
  await getAuditLogService().clearAll();
@@ -998,7 +1286,7 @@ const rateLimitMap = /* @__PURE__ */ new Map();
998
1286
  const RATE_LIMIT_WINDOW = 6e4;
999
1287
  const MAX_REQUESTS = 1e3;
1000
1288
  function getRateLimitKey(ctx) {
1001
- const ip = ctx.request.ip;
1289
+ const ip = getClientIp(ctx);
1002
1290
  const ua = ctx.request.header["user-agent"] ?? "";
1003
1291
  const uaHash = createHash("sha256").update(ua).digest("hex").slice(0, 16);
1004
1292
  return `${ip}:${uaHash}`;
@@ -1191,10 +1479,10 @@ const routes = {
1191
1479
  }
1192
1480
  };
1193
1481
  const policies = {};
1194
- function renderHtmlTemplate(title, content) {
1482
+ function renderHtmlTemplate(title, content, locale = "en") {
1195
1483
  return `
1196
1484
  <!doctype html>
1197
- <html lang="en">
1485
+ <html lang="${locale}">
1198
1486
  <head>
1199
1487
  <meta charset="utf-8">
1200
1488
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -1373,9 +1661,10 @@ function oauthService({ strapi: strapi2 }) {
1373
1661
  provider: "strapi-plugin-oidc"
1374
1662
  });
1375
1663
  },
1376
- renderSignUpSuccess(jwtToken, user, nonce) {
1664
+ renderSignUpSuccess(jwtToken, user, nonce, locale = "en") {
1377
1665
  const config2 = strapi2.config.get("plugin::strapi-plugin-oidc");
1378
1666
  const isRememberMe = !!config2?.REMEMBER_ME;
1667
+ const messages = authPageMessages(locale);
1379
1668
  const content = `
1380
1669
  <noscript>
1381
1670
  <div class="card">
@@ -1384,8 +1673,8 @@ function oauthService({ strapi: strapi2 }) {
1384
1673
  <path d="M20 6 9 17l-5-5"/>
1385
1674
  </svg>
1386
1675
  </div>
1387
- <h1>JavaScript Required</h1>
1388
- <p>JavaScript must be enabled for authentication to complete.</p>
1676
+ <h1>${messages.noscriptHeading}</h1>
1677
+ <p>${messages.noscriptBody}</p>
1389
1678
  </div>
1390
1679
  </noscript>
1391
1680
  <script nonce="${nonce}">
@@ -1399,9 +1688,10 @@ function oauthService({ strapi: strapi2 }) {
1399
1688
  location.href = '${strapi2.config.admin.url}'
1400
1689
  })
1401
1690
  <\/script>`;
1402
- return renderHtmlTemplate("Authenticating...", content);
1691
+ return renderHtmlTemplate(messages.authenticatingTitle, content, locale);
1403
1692
  },
1404
- renderSignUpError(message) {
1693
+ renderSignUpError(message, locale = "en") {
1694
+ const messages = authPageMessages(locale);
1405
1695
  const safeMessage = String(message).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1406
1696
  const content = `
1407
1697
  <div class="card">
@@ -1412,11 +1702,11 @@ function oauthService({ strapi: strapi2 }) {
1412
1702
  <path d="M12 17h.01"/>
1413
1703
  </svg>
1414
1704
  </div>
1415
- <h1>Authentication Failed</h1>
1705
+ <h1>${messages.errorTitle}</h1>
1416
1706
  <p>${safeMessage}</p>
1417
- <a href="${strapi2.config.admin.url}" class="btn">Return to Login</a>
1707
+ <a href="${strapi2.config.admin.url}" class="btn">${messages.returnToLogin}</a>
1418
1708
  </div>`;
1419
- return renderHtmlTemplate("Authentication Failed", content);
1709
+ return renderHtmlTemplate(messages.errorTitle, content, locale);
1420
1710
  },
1421
1711
  async generateToken(user, ctx) {
1422
1712
  const sessionManager = strapi2.sessionManager;
@@ -1580,6 +1870,58 @@ function translateDetails(key, params) {
1580
1870
  if (!translation) return null;
1581
1871
  return interpolate(translation, params);
1582
1872
  }
1873
+ const STRING_OP_MAP = {
1874
+ $eq: (v) => v,
1875
+ $contains: (v) => ({ $containsi: v }),
1876
+ $endsWith: (v) => ({ $endsWith: v }),
1877
+ $null: (v) => v === true ? null : void 0,
1878
+ $notNull: (v) => v === true ? { $notNull: true } : void 0
1879
+ };
1880
+ const DATE_OP_MAP = {
1881
+ $gte: (v) => ({ $gte: v }),
1882
+ $lt: (v) => ({ $lt: v }),
1883
+ $lte: (v) => ({ $lte: v }),
1884
+ $between: (v) => ({ $between: v })
1885
+ // $in is handled separately: each ISO day-start is expanded to a [day, day+1) range.
1886
+ };
1887
+ const DAY_MS = 864e5;
1888
+ function nextDayIso(iso) {
1889
+ return new Date(new Date(iso).getTime() + DAY_MS).toISOString();
1890
+ }
1891
+ function expandCreatedAtInToDayRanges(days) {
1892
+ const ranges = days.map((d) => ({ createdAt: { $gte: d, $lt: nextDayIso(d) } }));
1893
+ return ranges.length === 1 ? ranges[0] : { $or: ranges };
1894
+ }
1895
+ const ACTION_OP_MAP = {
1896
+ $eq: (v) => v,
1897
+ $in: (v) => ({ $in: v })
1898
+ };
1899
+ function mapFieldFilter(conditions, field, filter, opMap) {
1900
+ for (const [op, value] of Object.entries(filter)) {
1901
+ const transform = opMap[op];
1902
+ if (!transform) continue;
1903
+ const result = transform(value);
1904
+ if (result !== void 0) conditions.push({ [field]: result });
1905
+ }
1906
+ }
1907
+ function buildWhereClause(filters) {
1908
+ const conditions = [];
1909
+ if (filters.action) mapFieldFilter(conditions, "action", filters.action, ACTION_OP_MAP);
1910
+ if (filters.email) mapFieldFilter(conditions, "email", filters.email, STRING_OP_MAP);
1911
+ if (filters.ip) mapFieldFilter(conditions, "ip", filters.ip, STRING_OP_MAP);
1912
+ if (filters.createdAt) {
1913
+ const { $in: inDays, ...rest } = filters.createdAt;
1914
+ if (Array.isArray(inDays) && inDays.length > 0) {
1915
+ conditions.push(expandCreatedAtInToDayRanges(inDays));
1916
+ }
1917
+ if (Object.keys(rest).length > 0) {
1918
+ mapFieldFilter(conditions, "createdAt", rest, DATE_OP_MAP);
1919
+ }
1920
+ }
1921
+ if (conditions.length === 0) return {};
1922
+ if (conditions.length === 1) return conditions[0];
1923
+ return { $and: conditions };
1924
+ }
1583
1925
  function auditLogService({ strapi: strapi2 }) {
1584
1926
  return {
1585
1927
  async log({ action, email, ip, detailsKey, detailsParams }) {
@@ -1604,20 +1946,31 @@ function auditLogService({ strapi: strapi2 }) {
1604
1946
  },
1605
1947
  async find({
1606
1948
  page = 1,
1607
- pageSize = 25
1949
+ pageSize = 25,
1950
+ filters
1608
1951
  } = {}) {
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
- }));
1952
+ const where = filters ? buildWhereClause(filters) : {};
1953
+ const dbQuery = strapi2.db.query("plugin::strapi-plugin-oidc.audit-log");
1954
+ const [rows, total] = await Promise.all([
1955
+ dbQuery.findMany({
1956
+ where,
1957
+ orderBy: [{ createdAt: "desc" }],
1958
+ limit: pageSize,
1959
+ offset: (page - 1) * pageSize
1960
+ }),
1961
+ dbQuery.count({ where })
1962
+ ]);
1618
1963
  return {
1619
- results,
1620
- pagination: result.pagination
1964
+ results: rows.map((row) => ({
1965
+ ...row,
1966
+ details: row.detailsKey ? translateDetails(row.detailsKey, row.detailsParams) : null
1967
+ })),
1968
+ pagination: {
1969
+ page,
1970
+ pageSize,
1971
+ total,
1972
+ pageCount: Math.ceil(total / pageSize)
1973
+ }
1621
1974
  };
1622
1975
  },
1623
1976
  async clearAll() {
@@ -1629,7 +1982,7 @@ function auditLogService({ strapi: strapi2 }) {
1629
1982
  } while (deletedCount === BATCH_SIZE);
1630
1983
  },
1631
1984
  async cleanup(retentionDays) {
1632
- const cutoff = new Date(Date.now() - retentionDays * 864e5);
1985
+ const cutoff = new Date(Date.now() - retentionDays * DAY_MS);
1633
1986
  await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").deleteMany({ where: { createdAt: { $lt: cutoff } } });
1634
1987
  }
1635
1988
  };