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.
@@ -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;
@@ -560,62 +626,99 @@ async function updateUserRoles(user, currentRoleIds, newRoleIds) {
560
626
  throw updateErr;
561
627
  }
562
628
  }
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);
629
+ async function resolveRolesFromGroups(candidateNames) {
630
+ const matchedRoles = await strapi.db.query("admin::role").findMany({
631
+ where: { name: { $in: candidateNames } },
632
+ select: ["id", "name"]
633
+ });
634
+ const nameToId = new Map(matchedRoles.map((r) => [r.name, String(r.id)]));
635
+ const roles2 = [];
636
+ for (const name of candidateNames) {
637
+ const id = nameToId.get(name);
638
+ if (id) roles2.push(id);
568
639
  }
569
- await whitelistService2.checkWhitelistForEmail(email);
640
+ return {
641
+ roles: roles2,
642
+ fromGroupMapping: true,
643
+ resolvedRoleNames: matchedRoles.map((r) => r.name)
644
+ };
645
+ }
646
+ async function resolveRolesFromDefaults(roleService2) {
647
+ const oidcRolesResult = await roleService2.oidcRoles();
648
+ const roles2 = oidcRolesResult?.roles || [];
649
+ if (roles2.length === 0) {
650
+ return { roles: roles2, fromGroupMapping: false, resolvedRoleNames: [] };
651
+ }
652
+ const records = await strapi.db.query("admin::role").findMany({
653
+ where: { id: { $in: roles2.map(Number) } },
654
+ select: ["id", "name"]
655
+ });
656
+ return {
657
+ roles: roles2,
658
+ fromGroupMapping: false,
659
+ resolvedRoleNames: records.map((r) => r.name)
660
+ };
661
+ }
662
+ async function resolveRoles(userResponseData, config2, roleService2) {
570
663
  const candidateNames = collectGroupMapRoleNames(userResponseData, config2);
571
- let roles2 = [];
572
- let fromGroupMapping = false;
573
- let resolvedRoleNames = [];
574
664
  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
- }
665
+ return resolveRolesFromGroups(candidateNames);
596
666
  }
597
- let userCreated = false;
598
- let rolesUpdated = false;
599
- let user = await userService.findOneByEmail(email, ["roles"]);
600
- if (!user) {
667
+ return resolveRolesFromDefaults(roleService2);
668
+ }
669
+ async function ensureUser(userService, oauthService2, email, userResponseData, config2, ctx, resolved) {
670
+ const existing = await userService.findOneByEmail(email, ["roles"]);
671
+ if (!existing) {
601
672
  try {
602
- user = await registerNewUser(oauthService2, email, userResponseData, config2, ctx, roles2);
673
+ const user = await registerNewUser(
674
+ oauthService2,
675
+ email,
676
+ userResponseData,
677
+ config2,
678
+ ctx,
679
+ resolved.roles
680
+ );
681
+ return { user, userCreated: true, rolesUpdated: true };
603
682
  } catch (e) {
604
683
  const msg = e instanceof Error ? e.message : String(e);
605
684
  throw new OidcError("user_creation_failed", msg, e);
606
685
  }
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
686
  }
687
+ if (!resolved.fromGroupMapping || resolved.roles.length === 0) {
688
+ return { user: existing, userCreated: false, rolesUpdated: false };
689
+ }
690
+ const currentRoleIds = new Set((existing.roles ?? []).map((r) => String(r.id)));
691
+ if (!rolesChanged(currentRoleIds, new Set(resolved.roles))) {
692
+ return { user: existing, userCreated: false, rolesUpdated: false };
693
+ }
694
+ await updateUserRoles(existing, currentRoleIds, resolved.roles);
695
+ return { user: existing, userCreated: false, rolesUpdated: true };
696
+ }
697
+ async function handleUserAuthentication(userService, oauthService2, roleService2, whitelistService2, userResponseData, config2, ctx) {
698
+ const email = String(userResponseData.email ?? "").toLowerCase();
699
+ if (!email || !isValidEmail(email)) {
700
+ throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
701
+ }
702
+ await whitelistService2.checkWhitelistForEmail(email);
703
+ const resolved = await resolveRoles(userResponseData, config2, roleService2);
704
+ const { user, userCreated, rolesUpdated } = await ensureUser(
705
+ userService,
706
+ oauthService2,
707
+ email,
708
+ userResponseData,
709
+ config2,
710
+ ctx,
711
+ resolved
712
+ );
616
713
  const jwtToken = await oauthService2.generateToken(user, ctx);
617
714
  oauthService2.triggerSignInSuccess(user);
618
- return { activateUser: user, jwtToken, userCreated, rolesUpdated, resolvedRoleNames };
715
+ return {
716
+ activateUser: user,
717
+ jwtToken,
718
+ userCreated,
719
+ rolesUpdated,
720
+ resolvedRoleNames: resolved.resolvedRoleNames
721
+ };
619
722
  }
620
723
  function classifyOidcError(e, userInfo) {
621
724
  const kind = e instanceof OidcError ? e.kind : "unknown";
@@ -634,26 +737,76 @@ function classifyOidcError(e, userInfo) {
634
737
  params
635
738
  };
636
739
  }
740
+ function readAndClearPkceCookies(ctx) {
741
+ const oidcState = ctx.cookies.get("oidc_state");
742
+ const codeVerifier = ctx.cookies.get("oidc_code_verifier");
743
+ const oidcNonce = ctx.cookies.get("oidc_nonce");
744
+ ctx.cookies.set("oidc_state", null);
745
+ ctx.cookies.set("oidc_code_verifier", null);
746
+ ctx.cookies.set("oidc_nonce", null);
747
+ return { oidcState, codeVerifier, oidcNonce };
748
+ }
749
+ async function logSuccessfulAuth(auditLog2, ctx, user, userCreated, rolesUpdated, resolvedRoleNames) {
750
+ const roles2 = resolvedRoleNames.join(", ");
751
+ const entries = [
752
+ auditLog2.log({
753
+ action: "login_success",
754
+ email: user.email,
755
+ ip: ctx.ip,
756
+ detailsKey: rolesUpdated ? "roles_updated" : void 0,
757
+ detailsParams: rolesUpdated ? { roles: roles2 } : void 0
758
+ })
759
+ ];
760
+ if (userCreated) {
761
+ entries.push(
762
+ auditLog2.log({
763
+ action: "user_created",
764
+ email: user.email,
765
+ ip: ctx.ip,
766
+ detailsKey: "user_created",
767
+ detailsParams: { roles: roles2 }
768
+ })
769
+ );
770
+ }
771
+ await Promise.all(entries);
772
+ }
773
+ async function handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx) {
774
+ const errorInfo = classifyOidcError(e, userInfo);
775
+ const message = e instanceof Error ? e.message : String(e);
776
+ await auditLog2.log({
777
+ action: errorInfo.action,
778
+ email: userInfo?.email,
779
+ ip: ctx.ip,
780
+ detailsKey: errorInfo.action,
781
+ detailsParams: errorInfo.action === "login_failure" ? { message } : void 0
782
+ });
783
+ strapi.log.error({
784
+ code: errorInfo.code,
785
+ phase: "oidc_callback",
786
+ message: e instanceof Error ? e.message : "Unknown sign-in error",
787
+ detail: errorInfo.key ? getErrorDetail(errorInfo.key, errorInfo.params) : void 0,
788
+ email: userInfo?.email
789
+ });
790
+ const locale = negotiateLocale(ctx.request.headers["accept-language"]);
791
+ ctx.send(oauthService2.renderSignUpError(userFacingMessages(locale).signInError, locale));
792
+ }
637
793
  async function oidcSignInCallback(ctx) {
638
794
  const config2 = configValidation();
639
- const userService = getAdminUserService();
640
795
  const oauthService2 = getOauthService();
641
- const roleService2 = getRoleService();
642
- const whitelistService2 = getWhitelistService();
643
796
  const auditLog2 = getAuditLogService();
797
+ const locale = negotiateLocale(ctx.request.headers["accept-language"]);
644
798
  if (!ctx.query.code) {
645
799
  await auditLog2.log({ action: "missing_code", ip: ctx.ip });
646
- return ctx.send(oauthService2.renderSignUpError(userFacingMessages.missing_code));
800
+ return ctx.send(
801
+ oauthService2.renderSignUpError(userFacingMessages(locale).missing_code, locale)
802
+ );
647
803
  }
648
- const oidcState = ctx.cookies.get("oidc_state");
649
- const codeVerifier = ctx.cookies.get("oidc_code_verifier");
650
- const oidcNonce = ctx.cookies.get("oidc_nonce");
651
- ctx.cookies.set("oidc_state", null);
652
- ctx.cookies.set("oidc_code_verifier", null);
653
- ctx.cookies.set("oidc_nonce", null);
804
+ const { oidcState, codeVerifier, oidcNonce } = readAndClearPkceCookies(ctx);
654
805
  if (!ctx.query.state || ctx.query.state !== oidcState) {
655
806
  await auditLog2.log({ action: "state_mismatch", ip: ctx.ip });
656
- return ctx.send(oauthService2.renderSignUpError(userFacingMessages.invalid_state));
807
+ return ctx.send(
808
+ oauthService2.renderSignUpError(userFacingMessages(locale).invalid_state, locale)
809
+ );
657
810
  }
658
811
  const params = new URLSearchParams({
659
812
  code: ctx.query.code,
@@ -667,67 +820,53 @@ async function oidcSignInCallback(ctx) {
667
820
  try {
668
821
  const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
669
822
  userInfo = exchangeResult.userInfo;
670
- const accessToken = exchangeResult.accessToken;
671
823
  const isProduction = strapi.config.get("environment") === "production";
672
- ctx.cookies.set("oidc_access_token", accessToken, {
824
+ const secureFlag = isProduction && ctx.request.secure;
825
+ ctx.cookies.set("oidc_access_token", exchangeResult.accessToken, {
673
826
  httpOnly: true,
674
827
  maxAge: 3e5,
675
- secure: isProduction && ctx.request.secure,
828
+ secure: secureFlag,
676
829
  sameSite: "lax"
677
830
  });
678
831
  const { activateUser, jwtToken, userCreated, rolesUpdated, resolvedRoleNames } = await handleUserAuthentication(
679
- userService,
832
+ getAdminUserService(),
680
833
  oauthService2,
681
- roleService2,
682
- whitelistService2,
834
+ getRoleService(),
835
+ getWhitelistService(),
683
836
  userInfo,
684
837
  config2,
685
838
  ctx
686
839
  );
687
- const identityCookieOptions = {
840
+ ctx.cookies.set("oidc_user_email", activateUser.email, {
688
841
  httpOnly: true,
689
842
  path: "/",
690
- secure: isProduction && ctx.request.secure,
843
+ secure: secureFlag,
691
844
  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
845
  });
846
+ await logSuccessfulAuth(
847
+ auditLog2,
848
+ ctx,
849
+ activateUser,
850
+ userCreated,
851
+ rolesUpdated,
852
+ resolvedRoleNames
853
+ );
710
854
  const nonce = node_crypto.randomUUID();
711
- const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
712
855
  ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
713
- ctx.send(html);
856
+ ctx.send(oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce, locale));
714
857
  } 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
858
+ await handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx);
859
+ }
860
+ }
861
+ async function isProviderSessionActive(userinfoEndpoint, accessToken) {
862
+ try {
863
+ const response = await fetch(userinfoEndpoint, {
864
+ headers: { Authorization: `Bearer ${accessToken}` },
865
+ signal: AbortSignal.timeout(LOGOUT_USERINFO_TIMEOUT_MS)
729
866
  });
730
- ctx.send(oauthService2.renderSignUpError(userFacingMessages.signInError));
867
+ return response.ok;
868
+ } catch {
869
+ return false;
731
870
  }
732
871
  }
733
872
  async function logout(ctx) {
@@ -735,38 +874,27 @@ async function logout(ctx) {
735
874
  const auditLog2 = getAuditLogService();
736
875
  const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
737
876
  const adminPanelUrl = strapi.config.get("admin.url", "/admin");
877
+ const loginUrl = `${adminPanelUrl}/auth/login`;
738
878
  const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
739
879
  const accessToken = ctx.cookies.get("oidc_access_token");
740
880
  const userEmail = ctx.cookies.get("oidc_user_email") ?? void 0;
741
881
  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)
882
+ if (!isOidcSession) {
883
+ return ctx.redirect(loginUrl);
884
+ }
885
+ const logAudit = (action) => userEmail ? auditLog2.log({ action, email: userEmail, ip: ctx.ip }) : Promise.resolve();
886
+ if (logoutUrl && accessToken) {
887
+ const active = await isProviderSessionActive(config2.OIDC_USERINFO_ENDPOINT, accessToken);
888
+ if (active) {
889
+ logAudit("logout").catch(() => {
747
890
  });
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`);
891
+ return ctx.redirect(logoutUrl);
761
892
  }
893
+ await logAudit("session_expired");
894
+ return ctx.redirect(loginUrl);
762
895
  }
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`);
896
+ await logAudit("logout");
897
+ ctx.redirect(logoutUrl || loginUrl);
770
898
  }
771
899
  const oidc = {
772
900
  oidcSignIn,
@@ -941,11 +1069,149 @@ const whitelist = {
941
1069
  importUsers,
942
1070
  exportWhitelist
943
1071
  };
1072
+ const AUDIT_ACTIONS = [
1073
+ "login_success",
1074
+ "login_failure",
1075
+ "missing_code",
1076
+ "state_mismatch",
1077
+ "nonce_mismatch",
1078
+ "token_exchange_failed",
1079
+ "whitelist_rejected",
1080
+ "logout",
1081
+ "session_expired",
1082
+ "user_created"
1083
+ ];
1084
+ const ISO_UTC_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
1085
+ function isIsoUtcDatetime(value) {
1086
+ return typeof value === "string" && ISO_UTC_DATETIME.test(value);
1087
+ }
1088
+ const ALLOWED_FIELDS = /* @__PURE__ */ new Set(["action", "email", "ip", "createdAt"]);
1089
+ const STRING_OPERATORS = /* @__PURE__ */ new Set([
1090
+ "$eq",
1091
+ "$contains",
1092
+ "$endsWith",
1093
+ "$null",
1094
+ "$notNull"
1095
+ ]);
1096
+ const DATE_OPERATORS = /* @__PURE__ */ new Set(["$gte", "$lt", "$lte", "$between", "$in"]);
1097
+ const ENUM_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$in"]);
1098
+ function isPlainObject(value) {
1099
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1100
+ const proto = Object.getPrototypeOf(value);
1101
+ return proto === Object.prototype || proto === null;
1102
+ }
1103
+ function isStringOperator(op) {
1104
+ return STRING_OPERATORS.has(op);
1105
+ }
1106
+ function isDateOperator(op) {
1107
+ return DATE_OPERATORS.has(op);
1108
+ }
1109
+ function isEnumOperator(op) {
1110
+ return ENUM_OPERATORS.has(op);
1111
+ }
1112
+ function isAuditAction(value) {
1113
+ return AUDIT_ACTIONS.includes(value);
1114
+ }
1115
+ class ValidationError extends Error {
1116
+ constructor(message) {
1117
+ super(message);
1118
+ this.name = "ValidationError";
1119
+ }
1120
+ }
1121
+ function requireType(field, op, value, check, expected) {
1122
+ if (!check) {
1123
+ throw new ValidationError(`Operator "${op}" for field "${field}" requires ${expected}`);
1124
+ }
1125
+ return value;
1126
+ }
1127
+ function parseActionOperator(op, opValue) {
1128
+ if (!isEnumOperator(op)) {
1129
+ throw new ValidationError(`Unknown operator "${op}" for field "action"`);
1130
+ }
1131
+ if (op === "$in") {
1132
+ requireType("action", op, opValue, Array.isArray(opValue), "an array value");
1133
+ for (const v of opValue) {
1134
+ if (!isAuditAction(v)) {
1135
+ throw new ValidationError(
1136
+ `Invalid action value "${v}" — must be one of: ${AUDIT_ACTIONS.join(", ")}`
1137
+ );
1138
+ }
1139
+ }
1140
+ return opValue;
1141
+ }
1142
+ if (!isAuditAction(opValue)) {
1143
+ throw new ValidationError(
1144
+ `Invalid action value "${opValue}" — must be one of: ${AUDIT_ACTIONS.join(", ")}`
1145
+ );
1146
+ }
1147
+ return opValue;
1148
+ }
1149
+ function parseCreatedAtOperator(op, opValue) {
1150
+ if (!isDateOperator(op)) {
1151
+ throw new ValidationError(`Unknown operator "${op}" for field "createdAt"`);
1152
+ }
1153
+ const expected = 'an ISO-8601 UTC datetime string (e.g. "2024-01-15T00:00:00.000Z")';
1154
+ if (op === "$between") {
1155
+ const isTuple = Array.isArray(opValue) && opValue.length === 2;
1156
+ requireType("createdAt", op, opValue, isTuple, "a tuple [start, end]");
1157
+ const [a, b] = opValue;
1158
+ requireType("createdAt", op, opValue, isIsoUtcDatetime(a) && isIsoUtcDatetime(b), expected);
1159
+ return opValue;
1160
+ }
1161
+ if (op === "$in") {
1162
+ requireType("createdAt", op, opValue, Array.isArray(opValue), "an array value");
1163
+ for (const v of opValue) {
1164
+ requireType("createdAt", op, v, isIsoUtcDatetime(v), expected);
1165
+ }
1166
+ return opValue;
1167
+ }
1168
+ return requireType("createdAt", op, opValue, isIsoUtcDatetime(opValue), expected);
1169
+ }
1170
+ function parseStringFieldOperator(field, op, opValue) {
1171
+ if (!isStringOperator(op)) {
1172
+ throw new ValidationError(`Unknown operator "${op}" for field "${field}"`);
1173
+ }
1174
+ if (op === "$null" || op === "$notNull") {
1175
+ return requireType(field, op, opValue, typeof opValue === "boolean", "a boolean value");
1176
+ }
1177
+ return requireType(field, op, opValue, typeof opValue === "string", "a string value");
1178
+ }
1179
+ function parseFieldOperators(field, fieldValue) {
1180
+ if (!isPlainObject(fieldValue)) {
1181
+ throw new ValidationError(
1182
+ `Filter field "${field}" must be an object of operators, got ${typeof fieldValue}`
1183
+ );
1184
+ }
1185
+ const parsed = {};
1186
+ for (const [op, opValue] of Object.entries(fieldValue)) {
1187
+ if (field === "action") parsed[op] = parseActionOperator(op, opValue);
1188
+ else if (field === "createdAt") parsed[op] = parseCreatedAtOperator(op, opValue);
1189
+ else parsed[op] = parseStringFieldOperator(field, op, opValue);
1190
+ }
1191
+ return Object.keys(parsed).length > 0 ? parsed : null;
1192
+ }
1193
+ function parseAuditLogFilters(query) {
1194
+ if (!isPlainObject(query)) return {};
1195
+ const result = {};
1196
+ const filters = query.filters;
1197
+ if (filters === void 0) return result;
1198
+ if (!isPlainObject(filters)) {
1199
+ throw new ValidationError(`"filters" must be an object, got ${typeof filters}`);
1200
+ }
1201
+ for (const [field, fieldValue] of Object.entries(filters)) {
1202
+ if (!ALLOWED_FIELDS.has(field)) {
1203
+ throw new ValidationError(`Unknown filter field: "${field}"`);
1204
+ }
1205
+ const parsed = parseFieldOperators(field, fieldValue);
1206
+ if (parsed) result[field] = parsed;
1207
+ }
1208
+ return result;
1209
+ }
944
1210
  const EXPORT_PAGE_SIZE = 500;
945
- async function* ndjsonRowStream(service) {
1211
+ async function* ndjsonRowStream(service, filters) {
946
1212
  let page = 1;
947
1213
  while (true) {
948
- const { results } = await service.find({ page, pageSize: EXPORT_PAGE_SIZE });
1214
+ const { results } = await service.find({ page, pageSize: EXPORT_PAGE_SIZE, filters });
949
1215
  if (results.length === 0) return;
950
1216
  let chunk = "";
951
1217
  for (const row of results) {
@@ -962,28 +1228,35 @@ async function* ndjsonRowStream(service) {
962
1228
  page++;
963
1229
  }
964
1230
  }
965
- function errorAwareNdjsonStream(service) {
966
- const gen = ndjsonRowStream(service);
1231
+ function errorAwareNdjsonStream(strapi2, service, filters) {
1232
+ const gen = ndjsonRowStream(service, filters);
967
1233
  const readable = node_stream.Readable.from(gen);
968
1234
  readable.on("error", (err) => {
969
- strapi$1.log.error({ phase: "audit_log_export", err }, "NDJSON export stream failed");
1235
+ strapi2.log.error({ phase: "audit_log_export", err }, "NDJSON export stream failed");
970
1236
  });
971
1237
  return readable;
972
1238
  }
973
- let strapi$1;
974
- function find(ctx) {
975
- strapi$1 = ctx.strapi;
1239
+ function parseFiltersOr400(ctx) {
1240
+ try {
1241
+ return parseAuditLogFilters(ctx.query);
1242
+ } catch (err) {
1243
+ ctx.status = 400;
1244
+ ctx.body = { message: err instanceof ValidationError ? err.message : "Invalid filters" };
1245
+ return null;
1246
+ }
1247
+ }
1248
+ async function find(ctx) {
1249
+ const filters = parseFiltersOr400(ctx);
1250
+ if (!filters) return;
976
1251
  const page = Math.max(1, Number(ctx.query.page) || 1);
977
1252
  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
- });
1253
+ ctx.body = await getAuditLogService().find({ page, pageSize, filters });
981
1254
  }
982
1255
  async function exportLogs(ctx) {
983
- strapi$1 = ctx.strapi;
1256
+ const filters = parseFiltersOr400(ctx);
1257
+ if (!filters) return;
984
1258
  setNdjsonAttachmentHeaders(ctx, "strapi-oidc-audit-log");
985
- const service = getAuditLogService();
986
- ctx.body = errorAwareNdjsonStream(service);
1259
+ ctx.body = errorAwareNdjsonStream(ctx.strapi, getAuditLogService(), filters);
987
1260
  }
988
1261
  async function clearAll(ctx) {
989
1262
  await getAuditLogService().clearAll();
@@ -1197,10 +1470,10 @@ const routes = {
1197
1470
  }
1198
1471
  };
1199
1472
  const policies = {};
1200
- function renderHtmlTemplate(title, content) {
1473
+ function renderHtmlTemplate(title, content, locale = "en") {
1201
1474
  return `
1202
1475
  <!doctype html>
1203
- <html lang="en">
1476
+ <html lang="${locale}">
1204
1477
  <head>
1205
1478
  <meta charset="utf-8">
1206
1479
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -1379,9 +1652,10 @@ function oauthService({ strapi: strapi2 }) {
1379
1652
  provider: "strapi-plugin-oidc"
1380
1653
  });
1381
1654
  },
1382
- renderSignUpSuccess(jwtToken, user, nonce) {
1655
+ renderSignUpSuccess(jwtToken, user, nonce, locale = "en") {
1383
1656
  const config2 = strapi2.config.get("plugin::strapi-plugin-oidc");
1384
1657
  const isRememberMe = !!config2?.REMEMBER_ME;
1658
+ const messages = authPageMessages(locale);
1385
1659
  const content = `
1386
1660
  <noscript>
1387
1661
  <div class="card">
@@ -1390,8 +1664,8 @@ function oauthService({ strapi: strapi2 }) {
1390
1664
  <path d="M20 6 9 17l-5-5"/>
1391
1665
  </svg>
1392
1666
  </div>
1393
- <h1>JavaScript Required</h1>
1394
- <p>JavaScript must be enabled for authentication to complete.</p>
1667
+ <h1>${messages.noscriptHeading}</h1>
1668
+ <p>${messages.noscriptBody}</p>
1395
1669
  </div>
1396
1670
  </noscript>
1397
1671
  <script nonce="${nonce}">
@@ -1405,9 +1679,10 @@ function oauthService({ strapi: strapi2 }) {
1405
1679
  location.href = '${strapi2.config.admin.url}'
1406
1680
  })
1407
1681
  <\/script>`;
1408
- return renderHtmlTemplate("Authenticating...", content);
1682
+ return renderHtmlTemplate(messages.authenticatingTitle, content, locale);
1409
1683
  },
1410
- renderSignUpError(message) {
1684
+ renderSignUpError(message, locale = "en") {
1685
+ const messages = authPageMessages(locale);
1411
1686
  const safeMessage = String(message).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1412
1687
  const content = `
1413
1688
  <div class="card">
@@ -1418,11 +1693,11 @@ function oauthService({ strapi: strapi2 }) {
1418
1693
  <path d="M12 17h.01"/>
1419
1694
  </svg>
1420
1695
  </div>
1421
- <h1>Authentication Failed</h1>
1696
+ <h1>${messages.errorTitle}</h1>
1422
1697
  <p>${safeMessage}</p>
1423
- <a href="${strapi2.config.admin.url}" class="btn">Return to Login</a>
1698
+ <a href="${strapi2.config.admin.url}" class="btn">${messages.returnToLogin}</a>
1424
1699
  </div>`;
1425
- return renderHtmlTemplate("Authentication Failed", content);
1700
+ return renderHtmlTemplate(messages.errorTitle, content, locale);
1426
1701
  },
1427
1702
  async generateToken(user, ctx) {
1428
1703
  const sessionManager = strapi2.sessionManager;
@@ -1586,6 +1861,58 @@ function translateDetails(key, params) {
1586
1861
  if (!translation) return null;
1587
1862
  return interpolate(translation, params);
1588
1863
  }
1864
+ const STRING_OP_MAP = {
1865
+ $eq: (v) => v,
1866
+ $contains: (v) => ({ $containsi: v }),
1867
+ $endsWith: (v) => ({ $endsWith: v }),
1868
+ $null: (v) => v === true ? null : void 0,
1869
+ $notNull: (v) => v === true ? { $notNull: true } : void 0
1870
+ };
1871
+ const DATE_OP_MAP = {
1872
+ $gte: (v) => ({ $gte: v }),
1873
+ $lt: (v) => ({ $lt: v }),
1874
+ $lte: (v) => ({ $lte: v }),
1875
+ $between: (v) => ({ $between: v })
1876
+ // $in is handled separately: each ISO day-start is expanded to a [day, day+1) range.
1877
+ };
1878
+ const DAY_MS = 864e5;
1879
+ function nextDayIso(iso) {
1880
+ return new Date(new Date(iso).getTime() + DAY_MS).toISOString();
1881
+ }
1882
+ function expandCreatedAtInToDayRanges(days) {
1883
+ const ranges = days.map((d) => ({ createdAt: { $gte: d, $lt: nextDayIso(d) } }));
1884
+ return ranges.length === 1 ? ranges[0] : { $or: ranges };
1885
+ }
1886
+ const ACTION_OP_MAP = {
1887
+ $eq: (v) => v,
1888
+ $in: (v) => ({ $in: v })
1889
+ };
1890
+ function mapFieldFilter(conditions, field, filter, opMap) {
1891
+ for (const [op, value] of Object.entries(filter)) {
1892
+ const transform = opMap[op];
1893
+ if (!transform) continue;
1894
+ const result = transform(value);
1895
+ if (result !== void 0) conditions.push({ [field]: result });
1896
+ }
1897
+ }
1898
+ function buildWhereClause(filters) {
1899
+ const conditions = [];
1900
+ if (filters.action) mapFieldFilter(conditions, "action", filters.action, ACTION_OP_MAP);
1901
+ if (filters.email) mapFieldFilter(conditions, "email", filters.email, STRING_OP_MAP);
1902
+ if (filters.ip) mapFieldFilter(conditions, "ip", filters.ip, STRING_OP_MAP);
1903
+ if (filters.createdAt) {
1904
+ const { $in: inDays, ...rest } = filters.createdAt;
1905
+ if (Array.isArray(inDays) && inDays.length > 0) {
1906
+ conditions.push(expandCreatedAtInToDayRanges(inDays));
1907
+ }
1908
+ if (Object.keys(rest).length > 0) {
1909
+ mapFieldFilter(conditions, "createdAt", rest, DATE_OP_MAP);
1910
+ }
1911
+ }
1912
+ if (conditions.length === 0) return {};
1913
+ if (conditions.length === 1) return conditions[0];
1914
+ return { $and: conditions };
1915
+ }
1589
1916
  function auditLogService({ strapi: strapi2 }) {
1590
1917
  return {
1591
1918
  async log({ action, email, ip, detailsKey, detailsParams }) {
@@ -1610,20 +1937,31 @@ function auditLogService({ strapi: strapi2 }) {
1610
1937
  },
1611
1938
  async find({
1612
1939
  page = 1,
1613
- pageSize = 25
1940
+ pageSize = 25,
1941
+ filters
1614
1942
  } = {}) {
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
- }));
1943
+ const where = filters ? buildWhereClause(filters) : {};
1944
+ const dbQuery = strapi2.db.query("plugin::strapi-plugin-oidc.audit-log");
1945
+ const [rows, total] = await Promise.all([
1946
+ dbQuery.findMany({
1947
+ where,
1948
+ orderBy: [{ createdAt: "desc" }],
1949
+ limit: pageSize,
1950
+ offset: (page - 1) * pageSize
1951
+ }),
1952
+ dbQuery.count({ where })
1953
+ ]);
1624
1954
  return {
1625
- results,
1626
- pagination: result.pagination
1955
+ results: rows.map((row) => ({
1956
+ ...row,
1957
+ details: row.detailsKey ? translateDetails(row.detailsKey, row.detailsParams) : null
1958
+ })),
1959
+ pagination: {
1960
+ page,
1961
+ pageSize,
1962
+ total,
1963
+ pageCount: Math.ceil(total / pageSize)
1964
+ }
1627
1965
  };
1628
1966
  },
1629
1967
  async clearAll() {
@@ -1635,7 +1973,7 @@ function auditLogService({ strapi: strapi2 }) {
1635
1973
  } while (deletedCount === BATCH_SIZE);
1636
1974
  },
1637
1975
  async cleanup(retentionDays) {
1638
- const cutoff = new Date(Date.now() - retentionDays * 864e5);
1976
+ const cutoff = new Date(Date.now() - retentionDays * DAY_MS);
1639
1977
  await strapi2.db.query("plugin::strapi-plugin-oidc.audit-log").deleteMany({ where: { createdAt: { $lt: cutoff } } });
1640
1978
  }
1641
1979
  };