strapi-plugin-oidc 1.7.6 → 1.8.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.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
3
  const node_crypto = require("node:crypto");
4
4
  const pkceChallenge = require("pkce-challenge");
5
+ const jose = require("jose");
5
6
  const node_stream = require("node:stream");
6
7
  const strapiUtils = require("@strapi/utils");
7
8
  const generator = require("generate-password");
@@ -43,8 +44,44 @@ const getRoleService = () => strapi.plugin(PLUGIN_NAME).service("role");
43
44
  const getWhitelistService = () => strapi.plugin(PLUGIN_NAME).service("whitelist");
44
45
  const getAuditLogService = () => strapi.plugin(PLUGIN_NAME).service("auditLog");
45
46
  const getAdminUserService = () => strapi.service("admin::user");
47
+ const DISCOVERY_TIMEOUT_MS = 5e3;
48
+ const FIELD_MAP = [
49
+ ["issuer", "OIDC_ISSUER"],
50
+ ["authorization_endpoint", "OIDC_AUTHORIZATION_ENDPOINT"],
51
+ ["token_endpoint", "OIDC_TOKEN_ENDPOINT"],
52
+ ["userinfo_endpoint", "OIDC_USERINFO_ENDPOINT"],
53
+ ["end_session_endpoint", "OIDC_END_SESSION_ENDPOINT"],
54
+ ["jwks_uri", "OIDC_JWKS_URI"]
55
+ ];
56
+ async function applyDiscovery(strapi2) {
57
+ const config2 = strapi2.config.get("plugin::strapi-plugin-oidc");
58
+ const discoveryUrl = config2.OIDC_DISCOVERY_URL;
59
+ if (!discoveryUrl) return;
60
+ let doc;
61
+ try {
62
+ const res = await fetch(discoveryUrl, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) });
63
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
64
+ doc = await res.json();
65
+ } catch (e) {
66
+ strapi2.log.error(
67
+ `[strapi-plugin-oidc] Failed to fetch OIDC discovery document from ${discoveryUrl}: ${e instanceof Error ? e.message : String(e)}`
68
+ );
69
+ return;
70
+ }
71
+ const updates = {};
72
+ for (const [docField, configKey] of FIELD_MAP) {
73
+ if (doc[docField]) {
74
+ updates[configKey] = doc[docField];
75
+ }
76
+ }
77
+ if (Object.keys(updates).length > 0) {
78
+ strapi2.config.set("plugin::strapi-plugin-oidc", { ...config2, ...updates });
79
+ strapi2.log.info(`[strapi-plugin-oidc] Discovery applied: ${Object.keys(updates).join(", ")}`);
80
+ }
81
+ }
46
82
  const AUTH_ROUTES = ["login", "register", "register-admin", "forgot-password", "reset-password"];
47
83
  async function bootstrap({ strapi: strapi2 }) {
84
+ await applyDiscovery(strapi2);
48
85
  const adminUrl = strapi2.config.get("admin.url", "/admin");
49
86
  const tokenRefreshPath = `${adminUrl}/token/refresh`;
50
87
  const enforceOidcMiddleware = async (ctx, next) => {
@@ -99,6 +136,16 @@ async function bootstrap({ strapi: strapi2 }) {
99
136
  { section: "plugins", displayName: "Update", uid: "update", pluginName: "strapi-plugin-oidc" }
100
137
  ];
101
138
  await strapi2.admin.services.permission.actionProvider.registerMany(actions);
139
+ const contentApiScopeUids = [
140
+ "plugin::strapi-plugin-oidc.whitelist.read",
141
+ "plugin::strapi-plugin-oidc.whitelist.write",
142
+ "plugin::strapi-plugin-oidc.whitelist.delete",
143
+ "plugin::strapi-plugin-oidc.audit.read",
144
+ "plugin::strapi-plugin-oidc.audit.delete"
145
+ ];
146
+ for (const uid of contentApiScopeUids) {
147
+ strapi2.contentAPI.permissions.providers.action.register(uid, { uid });
148
+ }
102
149
  const enforceOIDCConfig = getEnforceOIDCConfig(strapi2);
103
150
  if (enforceOIDCConfig !== null) {
104
151
  try {
@@ -146,23 +193,29 @@ function destroy() {
146
193
  const config = {
147
194
  default: {
148
195
  REMEMBER_ME: false,
196
+ OIDC_DISCOVERY_URL: "",
149
197
  OIDC_REDIRECT_URI: "http://localhost:1337/strapi-plugin-oidc/oidc/callback",
150
198
  OIDC_CLIENT_ID: "",
151
199
  OIDC_CLIENT_SECRET: "",
152
200
  OIDC_SCOPE: "openid profile email",
153
- OIDC_AUTHORIZATION_ENDPOINT: "",
154
- OIDC_TOKEN_ENDPOINT: "",
155
- OIDC_USERINFO_ENDPOINT: "",
156
- OIDC_GRANT_TYPE: "authorization_code",
157
201
  OIDC_FAMILY_NAME_FIELD: "family_name",
158
202
  OIDC_GIVEN_NAME_FIELD: "given_name",
159
- OIDC_END_SESSION_ENDPOINT: "",
160
203
  OIDC_SSO_BUTTON_TEXT: "Login via SSO",
161
204
  OIDC_ENFORCE: null,
162
205
  // null = use DB setting; true/false = override DB (useful for lockout recovery)
163
206
  AUDIT_LOG_RETENTION_DAYS: 90,
164
207
  OIDC_GROUP_FIELD: "groups",
165
- OIDC_GROUP_ROLE_MAP: "{}"
208
+ OIDC_GROUP_ROLE_MAP: "{}",
209
+ OIDC_REQUIRE_EMAIL_VERIFIED: true,
210
+ OIDC_TRUSTED_IP_HEADER: "",
211
+ OIDC_FORCE_SECURE_COOKIES: false,
212
+ // Populated at bootstrap from OIDC_DISCOVERY_URL — not user-configurable directly
213
+ OIDC_AUTHORIZATION_ENDPOINT: "",
214
+ OIDC_TOKEN_ENDPOINT: "",
215
+ OIDC_USERINFO_ENDPOINT: "",
216
+ OIDC_END_SESSION_ENDPOINT: "",
217
+ OIDC_JWKS_URI: "",
218
+ OIDC_ISSUER: ""
166
219
  },
167
220
  validator() {
168
221
  }
@@ -211,11 +264,20 @@ const contentTypes = {
211
264
  whitelists,
212
265
  "audit-log": auditLog$1
213
266
  };
214
- function getExpiredCookieOptions(strapi2, ctx) {
267
+ function shouldMarkSecure(strapi2, ctx) {
215
268
  const isProduction = strapi2.config.get("environment") === "production";
269
+ if (!isProduction) return false;
270
+ const config2 = strapi2.config.get("plugin::strapi-plugin-oidc") ?? {};
271
+ if (config2.OIDC_FORCE_SECURE_COOKIES === true) return true;
272
+ if (ctx.request.secure) return true;
273
+ const proxyTrusted = ctx.app?.proxy === true;
274
+ if (proxyTrusted && ctx.get("x-forwarded-proto") === "https") return true;
275
+ return false;
276
+ }
277
+ function getExpiredCookieOptions(strapi2, ctx) {
216
278
  return {
217
279
  httpOnly: true,
218
- secure: isProduction && ctx.request.secure,
280
+ secure: shouldMarkSecure(strapi2, ctx),
219
281
  path: strapi2.config.get("admin.auth.cookie.path", "/admin"),
220
282
  domain: strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain"),
221
283
  sameSite: strapi2.config.get("admin.auth.cookie.sameSite", "lax"),
@@ -242,7 +304,9 @@ const errorCodes = {
242
304
  NONCE_MISMATCH: "NONCE_MISMATCH",
243
305
  ROLE_UPDATE_FAILED: "ROLE_UPDATE_FAILED",
244
306
  USER_CREATION_FAILED: "USER_CREATION_FAILED",
245
- WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED"
307
+ WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED",
308
+ EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED",
309
+ ID_TOKEN_INVALID: "ID_TOKEN_INVALID"
246
310
  };
247
311
  const ERROR_DETAIL_TEMPLATES = {
248
312
  token_exchange_failed: "Token exchange failed with HTTP status {status}",
@@ -252,6 +316,8 @@ const ERROR_DETAIL_TEMPLATES = {
252
316
  id_token_parse_failed: "ID token parse failed: {error}",
253
317
  sign_in_unknown: "Unknown sign-in error: {error}",
254
318
  invalid_email: "Invalid email address received from OIDC provider",
319
+ email_not_verified: "Email address has not been verified by the OIDC provider",
320
+ id_token_invalid: "ID token verification failed: {error}",
255
321
  whitelist_not_present: "Email not present in whitelist",
256
322
  session_manager_unsupported: "sessionManager is not supported. Please upgrade to Strapi v5.24.1 or later.",
257
323
  missing_config: "Missing required config keys: {keys}"
@@ -271,6 +337,8 @@ const errorMessages = {
271
337
  ID_TOKEN_PARSE_FAILED: "Failed to parse ID token",
272
338
  NONCE_MISMATCH: "Nonce mismatch",
273
339
  INVALID_EMAIL: "Invalid email address received from OIDC provider",
340
+ EMAIL_NOT_VERIFIED: "Email address has not been verified by the OIDC provider",
341
+ ID_TOKEN_INVALID: "ID token verification failed",
274
342
  WHITELIST_NOT_PRESENT: "Not present in whitelist",
275
343
  SESSION_MANAGER_UNSUPPORTED: "sessionManager is not supported. Please upgrade to Strapi v5.24.1 or later.",
276
344
  MISSING_CONFIG: (keys) => `Missing required config keys: ${keys}`
@@ -337,7 +405,6 @@ const en = {
337
405
  "auditlog.table.ip": "IP",
338
406
  "auditlog.table.details": "Details",
339
407
  "auditlog.table.empty": "No audit log entries",
340
- "auditlog.loading": "Loading…",
341
408
  "auditlog.clear": "Clear Logs",
342
409
  "auditlog.clear.title": "Clear All Logs",
343
410
  "auditlog.clear.description": "This will permanently delete all {count, plural, one {# audit log entry} other {# audit log entries}}. This action cannot be undone.",
@@ -368,6 +435,8 @@ const en = {
368
435
  "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.",
369
436
  "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
370
437
  "auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
438
+ "auditlog.action.email_not_verified": "The OIDC provider did not confirm the user's email address as verified. Access was denied.",
439
+ "auditlog.action.id_token_invalid": "The ID token failed signature, issuer, audience, or expiry validation. Access was denied.",
371
440
  "auth.page.authenticating.title": "Authenticating...",
372
441
  "auth.page.authenticating.noscript.heading": "JavaScript Required",
373
442
  "auth.page.authenticating.noscript.body": "JavaScript must be enabled for authentication to complete.",
@@ -390,7 +459,6 @@ const locales = Object.fromEntries(
390
459
  return [code ?? "", mod.default];
391
460
  })
392
461
  );
393
- Object.keys(locales).filter(Boolean);
394
462
  const DEFAULT_LOCALE = "en";
395
463
  function parseAcceptLanguage(header) {
396
464
  return header.split(",").map((part) => {
@@ -477,40 +545,99 @@ const OIDC_ERROR_DISPATCH = {
477
545
  code: errorCodes.TOKEN_EXCHANGE_FAILED,
478
546
  key: "sign_in_unknown"
479
547
  },
548
+ email_not_verified: {
549
+ action: "email_not_verified",
550
+ code: errorCodes.EMAIL_NOT_VERIFIED,
551
+ key: "email_not_verified"
552
+ },
553
+ id_token_invalid: {
554
+ action: "id_token_invalid",
555
+ code: errorCodes.ID_TOKEN_INVALID,
556
+ key: "id_token_invalid"
557
+ },
480
558
  unknown: {
481
559
  action: "login_failure",
482
560
  code: errorCodes.TOKEN_EXCHANGE_FAILED,
483
561
  key: "sign_in_unknown"
484
562
  }
485
563
  };
564
+ const TRUSTED_IP_HEADER = "cf-connecting-ip";
565
+ function getTrustedHeaderName() {
566
+ const config2 = strapi.config.get("plugin::strapi-plugin-oidc") ?? {};
567
+ const raw = config2.OIDC_TRUSTED_IP_HEADER;
568
+ if (typeof raw !== "string" || !raw) return void 0;
569
+ const normalized = raw.trim().toLowerCase();
570
+ return normalized === TRUSTED_IP_HEADER ? normalized : void 0;
571
+ }
486
572
  function getClientIp(ctx) {
487
- const cfConnectingIp = ctx.get("CF-Connecting-IP");
488
- if (cfConnectingIp) {
489
- return cfConnectingIp.split(",")[0].trim();
490
- }
491
- const forwardedFor = ctx.get("X-Forwarded-For");
492
- if (forwardedFor) {
493
- return forwardedFor.split(",")[0].trim();
494
- }
495
- const realIp = ctx.get("X-Real-IP");
496
- if (realIp) {
497
- return realIp.trim();
573
+ const proxyTrusted = ctx.app?.proxy === true;
574
+ if (proxyTrusted) {
575
+ const trustedHeader = getTrustedHeaderName();
576
+ if (trustedHeader) {
577
+ const value = ctx.get(trustedHeader);
578
+ if (value) return value.split(",")[0].trim();
579
+ }
580
+ const forwarded = ctx.request.ips;
581
+ if (forwarded && forwarded.length > 0) {
582
+ return forwarded[0];
583
+ }
498
584
  }
499
585
  return ctx.ip;
500
586
  }
587
+ function toMessage(e) {
588
+ return e instanceof Error ? e.message : String(e);
589
+ }
501
590
  const REQUIRED_CONFIG_KEYS = [
591
+ "OIDC_DISCOVERY_URL",
502
592
  "OIDC_CLIENT_ID",
503
593
  "OIDC_CLIENT_SECRET",
504
594
  "OIDC_REDIRECT_URI",
505
595
  "OIDC_SCOPE",
506
- "OIDC_TOKEN_ENDPOINT",
507
- "OIDC_USERINFO_ENDPOINT",
508
- "OIDC_GRANT_TYPE",
509
596
  "OIDC_FAMILY_NAME_FIELD",
510
597
  "OIDC_GIVEN_NAME_FIELD",
598
+ // Populated at bootstrap from OIDC_DISCOVERY_URL — checked here as a runtime safety net
599
+ "OIDC_TOKEN_ENDPOINT",
600
+ "OIDC_USERINFO_ENDPOINT",
511
601
  "OIDC_AUTHORIZATION_ENDPOINT"
512
602
  ];
513
- const LOGOUT_USERINFO_TIMEOUT_MS = 3e3;
603
+ const LOGOUT_USERINFO_TIMEOUT_MS = 1500;
604
+ const jwksCache = /* @__PURE__ */ new Map();
605
+ let jwksDisabledWarned = false;
606
+ function getJwks(uri) {
607
+ let jwks = jwksCache.get(uri);
608
+ if (!jwks) {
609
+ jwks = jose.createRemoteJWKSet(new URL(uri));
610
+ jwksCache.set(uri, jwks);
611
+ }
612
+ return jwks;
613
+ }
614
+ async function verifyIdToken(idToken, config2) {
615
+ const jwksUri = config2.OIDC_JWKS_URI;
616
+ const issuer = config2.OIDC_ISSUER;
617
+ if (!jwksUri) {
618
+ if (!jwksDisabledWarned) {
619
+ jwksDisabledWarned = true;
620
+ strapi.log.warn(
621
+ "[OIDC] OIDC_JWKS_URI is not configured — ID token signature verification is disabled. Set OIDC_JWKS_URI and OIDC_ISSUER from your provider's discovery document."
622
+ );
623
+ }
624
+ return null;
625
+ }
626
+ try {
627
+ const jwks = getJwks(jwksUri);
628
+ const { payload } = await jose.jwtVerify(idToken, jwks, {
629
+ issuer: issuer || void 0,
630
+ audience: config2.OIDC_CLIENT_ID
631
+ });
632
+ return payload;
633
+ } catch (e) {
634
+ if (e instanceof jose.errors.JWTClaimValidationFailed || e instanceof jose.errors.JWSSignatureVerificationFailed || e instanceof jose.errors.JWTExpired || e instanceof jose.errors.JWTInvalid || e instanceof jose.errors.JWSInvalid) {
635
+ const msg = toMessage(e);
636
+ throw new OidcError("id_token_invalid", msg, e);
637
+ }
638
+ throw e;
639
+ }
640
+ }
514
641
  function configValidation() {
515
642
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
516
643
  const missing = REQUIRED_CONFIG_KEYS.filter((key) => !config2[key]);
@@ -524,11 +651,10 @@ async function oidcSignIn(ctx) {
524
651
  const { code_verifier: codeVerifier, code_challenge: codeChallenge } = await pkceChallenge__default.default();
525
652
  const state = node_crypto.randomBytes(32).toString("base64url");
526
653
  const nonce = node_crypto.randomBytes(32).toString("base64url");
527
- const isProduction = strapi.config.get("environment") === "production";
528
654
  const cookieOptions = {
529
655
  httpOnly: true,
530
656
  maxAge: 6e5,
531
- secure: isProduction && ctx.request.secure,
657
+ secure: shouldMarkSecure(strapi, ctx),
532
658
  sameSite: "lax"
533
659
  };
534
660
  ctx.cookies.set("oidc_code_verifier", codeVerifier, cookieOptions);
@@ -561,14 +687,16 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
561
687
  }
562
688
  const tokenData = await response.json();
563
689
  if (tokenData.id_token) {
690
+ const verifiedPayload = await verifyIdToken(tokenData.id_token, config2);
564
691
  try {
565
- const payloadB64 = tokenData.id_token.split(".")[1];
566
- const idTokenPayload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
692
+ const idTokenPayload = verifiedPayload ?? JSON.parse(
693
+ Buffer.from(tokenData.id_token.split(".")[1], "base64url").toString("utf8")
694
+ );
567
695
  if (idTokenPayload.nonce !== expectedNonce) {
568
696
  throw new OidcError("nonce_mismatch", errorMessages.NONCE_MISMATCH);
569
697
  }
570
698
  } catch (e) {
571
- if (e instanceof OidcError && e.kind === "nonce_mismatch") throw e;
699
+ if (e instanceof OidcError) throw e;
572
700
  throw new OidcError("id_token_parse_failed", errorMessages.ID_TOKEN_PARSE_FAILED, e);
573
701
  }
574
702
  }
@@ -695,7 +823,7 @@ async function ensureUser(userService, oauthService2, email, userResponseData, c
695
823
  );
696
824
  return { user, userCreated: true, rolesUpdated: true };
697
825
  } catch (e) {
698
- const msg = e instanceof Error ? e.message : String(e);
826
+ const msg = toMessage(e);
699
827
  throw new OidcError("user_creation_failed", msg, e);
700
828
  }
701
829
  }
@@ -714,6 +842,13 @@ async function handleUserAuthentication(userService, oauthService2, roleService2
714
842
  if (!email || !isValidEmail(email)) {
715
843
  throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
716
844
  }
845
+ if (config2.OIDC_REQUIRE_EMAIL_VERIFIED !== false) {
846
+ const emailVerified = userResponseData.email_verified;
847
+ const isVerified = emailVerified === true || emailVerified === "true";
848
+ if (!isVerified) {
849
+ throw new OidcError("email_not_verified", errorMessages.EMAIL_NOT_VERIFIED);
850
+ }
851
+ }
717
852
  await whitelistService2.checkWhitelistForEmail(email);
718
853
  const resolved = await resolveRoles(userResponseData, config2, roleService2);
719
854
  const { user, userCreated, rolesUpdated } = await ensureUser(
@@ -738,9 +873,9 @@ async function handleUserAuthentication(userService, oauthService2, roleService2
738
873
  function classifyOidcError(e, userInfo) {
739
874
  const kind = e instanceof OidcError ? e.kind : "unknown";
740
875
  const dispatch = OIDC_ERROR_DISPATCH[kind];
741
- const msg = e instanceof Error ? e.message : String(e);
876
+ const msg = toMessage(e);
742
877
  let params;
743
- if (kind === "id_token_parse_failed" || kind === "unknown") {
878
+ if (kind === "id_token_parse_failed" || kind === "id_token_invalid" || kind === "unknown") {
744
879
  params = { error: msg };
745
880
  } else if (kind === "user_creation_failed" && userInfo?.email) {
746
881
  params = { email: userInfo.email, error: msg };
@@ -787,7 +922,7 @@ async function logSuccessfulAuth(auditLog2, ctx, user, userCreated, rolesUpdated
787
922
  }
788
923
  async function handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx) {
789
924
  const errorInfo = classifyOidcError(e, userInfo);
790
- const message = e instanceof Error ? e.message : String(e);
925
+ const message = toMessage(e);
791
926
  await auditLog2.log({
792
927
  action: errorInfo.action,
793
928
  email: userInfo?.email,
@@ -828,15 +963,14 @@ async function oidcSignInCallback(ctx) {
828
963
  client_id: config2.OIDC_CLIENT_ID,
829
964
  client_secret: config2.OIDC_CLIENT_SECRET,
830
965
  redirect_uri: config2.OIDC_REDIRECT_URI,
831
- grant_type: config2.OIDC_GRANT_TYPE,
966
+ grant_type: "authorization_code",
832
967
  code_verifier: codeVerifier ?? ""
833
968
  });
834
969
  let userInfo;
835
970
  try {
836
971
  const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
837
972
  userInfo = exchangeResult.userInfo;
838
- const isProduction = strapi.config.get("environment") === "production";
839
- const secureFlag = isProduction && ctx.request.secure;
973
+ const secureFlag = shouldMarkSecure(strapi, ctx);
840
974
  ctx.cookies.set("oidc_access_token", exchangeResult.accessToken, {
841
975
  httpOnly: true,
842
976
  maxAge: 3e5,
@@ -873,13 +1007,13 @@ async function oidcSignInCallback(ctx) {
873
1007
  await handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx);
874
1008
  }
875
1009
  }
876
- async function isProviderSessionActive(userinfoEndpoint, accessToken) {
1010
+ async function isProviderSessionExpired(userinfoEndpoint, accessToken) {
877
1011
  try {
878
1012
  const response = await fetch(userinfoEndpoint, {
879
1013
  headers: { Authorization: `Bearer ${accessToken}` },
880
1014
  signal: AbortSignal.timeout(LOGOUT_USERINFO_TIMEOUT_MS)
881
1015
  });
882
- return response.ok;
1016
+ return !response.ok;
883
1017
  } catch {
884
1018
  return false;
885
1019
  }
@@ -899,14 +1033,14 @@ async function logout(ctx) {
899
1033
  }
900
1034
  const logAudit = (action) => userEmail ? auditLog2.log({ action, email: userEmail, ip: getClientIp(ctx) }) : Promise.resolve();
901
1035
  if (logoutUrl && accessToken) {
902
- const active = await isProviderSessionActive(config2.OIDC_USERINFO_ENDPOINT, accessToken);
903
- if (active) {
904
- logAudit("logout").catch(() => {
905
- });
906
- return ctx.redirect(logoutUrl);
1036
+ const expired = await isProviderSessionExpired(config2.OIDC_USERINFO_ENDPOINT, accessToken);
1037
+ if (expired) {
1038
+ await logAudit("session_expired");
1039
+ return ctx.redirect(loginUrl);
907
1040
  }
908
- await logAudit("session_expired");
909
- return ctx.redirect(loginUrl);
1041
+ logAudit("logout").catch(() => {
1042
+ });
1043
+ return ctx.redirect(logoutUrl);
910
1044
  }
911
1045
  await logAudit("logout");
912
1046
  ctx.redirect(logoutUrl || loginUrl);
@@ -1006,16 +1140,34 @@ async function register(ctx) {
1006
1140
  return;
1007
1141
  }
1008
1142
  const rawEmails = Array.isArray(email) ? email : email.split(",");
1009
- const emailList = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
1143
+ const normalized = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
1144
+ const rejectedEmails = [];
1145
+ const validEmails = [];
1146
+ for (const e of normalized) {
1147
+ if (isValidEmail(e)) {
1148
+ validEmails.push(e);
1149
+ } else {
1150
+ rejectedEmails.push(e);
1151
+ }
1152
+ }
1153
+ if (validEmails.length === 0) {
1154
+ ctx.status = 400;
1155
+ ctx.body = { error: "No valid email addresses supplied", rejectedEmails };
1156
+ return;
1157
+ }
1010
1158
  const whitelistService2 = getWhitelistService();
1011
- const matchedExistingUsersCount = await whitelistService2.countAdminUsersByEmails(emailList);
1012
- for (const singleEmail of emailList) {
1159
+ let acceptedCount = 0;
1160
+ let alreadyWhitelistedCount = 0;
1161
+ for (const singleEmail of validEmails) {
1013
1162
  const alreadyWhitelisted = await whitelistService2.hasUser(singleEmail);
1014
- if (!alreadyWhitelisted) {
1163
+ if (alreadyWhitelisted) {
1164
+ alreadyWhitelistedCount++;
1165
+ } else {
1015
1166
  await whitelistService2.registerUser(singleEmail);
1167
+ acceptedCount++;
1016
1168
  }
1017
1169
  }
1018
- ctx.body = { matchedExistingUsersCount };
1170
+ ctx.body = { acceptedCount, alreadyWhitelistedCount, rejectedEmails };
1019
1171
  }
1020
1172
  async function removeEmail(ctx) {
1021
1173
  const { email } = ctx.params;
@@ -1046,13 +1198,9 @@ async function importUsers(ctx) {
1046
1198
  const whitelistService2 = getWhitelistService();
1047
1199
  const existing = await whitelistService2.getUsers();
1048
1200
  const existingEmails = new Set(existing.map((u) => u.email));
1049
- let importedCount = 0;
1050
- for (const email of deduped) {
1051
- if (existingEmails.has(email)) continue;
1052
- await whitelistService2.registerUser(email);
1053
- importedCount++;
1054
- }
1055
- ctx.body = { importedCount };
1201
+ const toImport = deduped.filter((email) => !existingEmails.has(email));
1202
+ await Promise.all(toImport.map((email) => whitelistService2.registerUser(email)));
1203
+ ctx.body = { importedCount: toImport.length };
1056
1204
  }
1057
1205
  async function syncUsers(ctx) {
1058
1206
  const { users: rawUsers } = ctx.request.body;
@@ -1061,17 +1209,11 @@ async function syncUsers(ctx) {
1061
1209
  const currentUsers = await whitelistService2.getUsers();
1062
1210
  const syncEmailSet = new Set(emails);
1063
1211
  const currentUsersByEmail = new Map(currentUsers.map((u) => [u.email, u]));
1064
- for (const currUser of currentUsers) {
1065
- if (!syncEmailSet.has(currUser.email)) {
1066
- await whitelistService2.removeUser(currUser.email);
1067
- }
1068
- }
1069
- for (const email of emails) {
1070
- if (!currentUsersByEmail.has(email)) {
1071
- await whitelistService2.registerUser(email);
1072
- }
1073
- }
1074
- ctx.body = { matchedExistingUsersCount: 0 };
1212
+ await Promise.all([
1213
+ ...currentUsers.filter((u) => !syncEmailSet.has(u.email)).map((u) => whitelistService2.removeUser(u.email)),
1214
+ ...emails.filter((email) => !currentUsersByEmail.has(email)).map((email) => whitelistService2.registerUser(email))
1215
+ ]);
1216
+ ctx.body = {};
1075
1217
  }
1076
1218
  const whitelist = {
1077
1219
  info,
@@ -1092,6 +1234,8 @@ const AUDIT_ACTIONS = [
1092
1234
  "nonce_mismatch",
1093
1235
  "token_exchange_failed",
1094
1236
  "whitelist_rejected",
1237
+ "email_not_verified",
1238
+ "id_token_invalid",
1095
1239
  "logout",
1096
1240
  "session_expired",
1097
1241
  "user_created"
@@ -1291,6 +1435,22 @@ const controllers = {
1291
1435
  const rateLimitMap = /* @__PURE__ */ new Map();
1292
1436
  const RATE_LIMIT_WINDOW = 6e4;
1293
1437
  const MAX_REQUESTS = 1e3;
1438
+ const MAX_MAP_SIZE = 1e4;
1439
+ const PRUNE_THRESHOLD = 1e3;
1440
+ function pruneExpiredEntries(now) {
1441
+ const windowStart = now - RATE_LIMIT_WINDOW;
1442
+ for (const [key, stamps] of rateLimitMap) {
1443
+ if (stamps.length === 0 || stamps[stamps.length - 1] <= windowStart) {
1444
+ rateLimitMap.delete(key);
1445
+ }
1446
+ }
1447
+ }
1448
+ function evictOldestEntry() {
1449
+ const oldest = rateLimitMap.keys().next().value;
1450
+ if (oldest !== void 0) {
1451
+ rateLimitMap.delete(oldest);
1452
+ }
1453
+ }
1294
1454
  function getRateLimitKey(ctx) {
1295
1455
  const ip = getClientIp(ctx);
1296
1456
  const ua = ctx.request.header["user-agent"] ?? "";
@@ -1301,6 +1461,9 @@ function rateLimitMiddleware(ctx, next) {
1301
1461
  const key = getRateLimitKey(ctx);
1302
1462
  const now = Date.now();
1303
1463
  const windowStart = now - RATE_LIMIT_WINDOW;
1464
+ if (rateLimitMap.size > PRUNE_THRESHOLD) {
1465
+ pruneExpiredEntries(now);
1466
+ }
1304
1467
  const requestStamps = (rateLimitMap.get(key) ?? []).filter((ts) => ts > windowStart);
1305
1468
  if (requestStamps.length >= MAX_REQUESTS) {
1306
1469
  ctx.status = 429;
@@ -1308,6 +1471,9 @@ function rateLimitMiddleware(ctx, next) {
1308
1471
  return;
1309
1472
  }
1310
1473
  requestStamps.push(now);
1474
+ if (!rateLimitMap.has(key) && rateLimitMap.size >= MAX_MAP_SIZE) {
1475
+ evictOldestEntry();
1476
+ }
1311
1477
  rateLimitMap.set(key, requestStamps);
1312
1478
  return next();
1313
1479
  }
@@ -1356,6 +1522,12 @@ const routes = {
1356
1522
  handler: "oidc.logout",
1357
1523
  config: { auth: false }
1358
1524
  },
1525
+ {
1526
+ method: "POST",
1527
+ path: "/logout",
1528
+ handler: "oidc.logout",
1529
+ config: { auth: false }
1530
+ },
1359
1531
  {
1360
1532
  method: "GET",
1361
1533
  path: "/whitelist",
@@ -1433,53 +1605,63 @@ const routes = {
1433
1605
  // API-token-authenticated routes for programmatic whitelist management.
1434
1606
  // Accessible at /strapi-plugin-oidc/... using a Strapi API token
1435
1607
  // (full-access or custom) in the Authorization: Bearer <token> header.
1608
+ // Custom tokens must be granted one or more of the semantic scopes below.
1436
1609
  "content-api": {
1437
1610
  type: "content-api",
1438
1611
  routes: [
1439
1612
  {
1440
1613
  method: "GET",
1441
1614
  path: "/whitelist",
1442
- handler: "whitelist.info"
1615
+ handler: "whitelist.info",
1616
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.read"] } }
1443
1617
  },
1444
1618
  {
1445
1619
  method: "POST",
1446
1620
  path: "/whitelist",
1447
- handler: "whitelist.register"
1621
+ handler: "whitelist.register",
1622
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.write"] } }
1448
1623
  },
1449
1624
  {
1450
1625
  method: "POST",
1451
1626
  path: "/whitelist/import",
1452
- handler: "whitelist.importUsers"
1627
+ handler: "whitelist.importUsers",
1628
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.write"] } }
1453
1629
  },
1454
1630
  {
1455
1631
  method: "DELETE",
1456
1632
  path: "/whitelist/:email",
1457
- handler: "whitelist.removeEmail"
1633
+ handler: "whitelist.removeEmail",
1634
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.delete"] } }
1458
1635
  },
1459
1636
  {
1460
1637
  method: "DELETE",
1461
1638
  path: "/whitelist",
1462
- handler: "whitelist.deleteAll"
1639
+ handler: "whitelist.deleteAll",
1640
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.delete"] } }
1463
1641
  },
1464
1642
  {
1465
1643
  method: "GET",
1466
1644
  path: "/whitelist/export",
1467
- handler: "whitelist.exportWhitelist"
1645
+ handler: "whitelist.exportWhitelist",
1646
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.read"] } }
1468
1647
  },
1469
1648
  {
1470
1649
  method: "GET",
1471
1650
  path: "/audit-logs",
1472
- handler: "auditLog.find"
1651
+ handler: "auditLog.find",
1652
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.read"] } }
1473
1653
  },
1474
1654
  {
1475
1655
  method: "GET",
1476
1656
  path: "/audit-logs/export",
1477
- handler: "auditLog.export"
1657
+ handler: "auditLog.export",
1658
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.read"] } }
1478
1659
  },
1479
1660
  {
1480
1661
  method: "DELETE",
1481
1662
  path: "/audit-logs",
1482
- handler: "auditLog.clearAll"
1663
+ handler: "auditLog.clearAll",
1664
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.delete"] } }
1483
1665
  }
1484
1666
  ]
1485
1667
  }
@@ -1648,9 +1830,10 @@ function oauthService({ strapi: strapi2 }) {
1648
1830
  }
1649
1831
  const modelDef = strapi2.getModel("admin::user");
1650
1832
  const sanitizedEntity = await strapiUtils__default.default.sanitize.sanitizers.defaultSanitizeOutput(
1651
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1652
- { schema: modelDef, getModel: (uid2) => strapi2.getModel(uid2) },
1653
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1833
+ {
1834
+ schema: modelDef,
1835
+ getModel: (uid2) => strapi2.getModel(uid2)
1836
+ },
1654
1837
  user
1655
1838
  );
1656
1839
  eventHub?.emit(ENTRY_CREATE ?? "entry.create", {
@@ -1731,13 +1914,12 @@ function oauthService({ strapi: strapi2 }) {
1731
1914
  type: rememberMe ? "refresh" : "session"
1732
1915
  }
1733
1916
  );
1734
- const isProduction = strapi2.config.get("environment") === "production";
1735
1917
  const domain = strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain");
1736
1918
  const path = strapi2.config.get("admin.auth.cookie.path", "/admin");
1737
1919
  const sameSite = strapi2.config.get("admin.auth.cookie.sameSite", "lax");
1738
1920
  const cookieOptions = {
1739
1921
  httpOnly: true,
1740
- secure: isProduction && ctx.request.secure,
1922
+ secure: shouldMarkSecure(strapi2, ctx),
1741
1923
  overwrite: true,
1742
1924
  domain,
1743
1925
  path,
@@ -1856,14 +2038,6 @@ function whitelistService({ strapi: strapi2 }) {
1856
2038
  },
1857
2039
  async deleteAllUsers() {
1858
2040
  await getWhitelistQuery().deleteMany({});
1859
- },
1860
- async countAdminUsersByEmails(emails) {
1861
- if (emails.length === 0) return 0;
1862
- const rows = await strapi2.query("admin::user").findMany({
1863
- where: { email: { $in: emails } },
1864
- select: ["id"]
1865
- });
1866
- return rows.length;
1867
2041
  }
1868
2042
  };
1869
2043
  }