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.
@@ -1,5 +1,6 @@
1
1
  import { randomUUID, randomBytes, createHash } from "node:crypto";
2
2
  import pkceChallenge from "pkce-challenge";
3
+ import { jwtVerify, errors, createRemoteJWKSet } from "jose";
3
4
  import { Readable } from "node:stream";
4
5
  import strapiUtils from "@strapi/utils";
5
6
  import generator from "generate-password";
@@ -37,8 +38,44 @@ const getRoleService = () => strapi.plugin(PLUGIN_NAME).service("role");
37
38
  const getWhitelistService = () => strapi.plugin(PLUGIN_NAME).service("whitelist");
38
39
  const getAuditLogService = () => strapi.plugin(PLUGIN_NAME).service("auditLog");
39
40
  const getAdminUserService = () => strapi.service("admin::user");
41
+ const DISCOVERY_TIMEOUT_MS = 5e3;
42
+ const FIELD_MAP = [
43
+ ["issuer", "OIDC_ISSUER"],
44
+ ["authorization_endpoint", "OIDC_AUTHORIZATION_ENDPOINT"],
45
+ ["token_endpoint", "OIDC_TOKEN_ENDPOINT"],
46
+ ["userinfo_endpoint", "OIDC_USERINFO_ENDPOINT"],
47
+ ["end_session_endpoint", "OIDC_END_SESSION_ENDPOINT"],
48
+ ["jwks_uri", "OIDC_JWKS_URI"]
49
+ ];
50
+ async function applyDiscovery(strapi2) {
51
+ const config2 = strapi2.config.get("plugin::strapi-plugin-oidc");
52
+ const discoveryUrl = config2.OIDC_DISCOVERY_URL;
53
+ if (!discoveryUrl) return;
54
+ let doc;
55
+ try {
56
+ const res = await fetch(discoveryUrl, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) });
57
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
58
+ doc = await res.json();
59
+ } catch (e) {
60
+ strapi2.log.error(
61
+ `[strapi-plugin-oidc] Failed to fetch OIDC discovery document from ${discoveryUrl}: ${e instanceof Error ? e.message : String(e)}`
62
+ );
63
+ return;
64
+ }
65
+ const updates = {};
66
+ for (const [docField, configKey] of FIELD_MAP) {
67
+ if (doc[docField]) {
68
+ updates[configKey] = doc[docField];
69
+ }
70
+ }
71
+ if (Object.keys(updates).length > 0) {
72
+ strapi2.config.set("plugin::strapi-plugin-oidc", { ...config2, ...updates });
73
+ strapi2.log.info(`[strapi-plugin-oidc] Discovery applied: ${Object.keys(updates).join(", ")}`);
74
+ }
75
+ }
40
76
  const AUTH_ROUTES = ["login", "register", "register-admin", "forgot-password", "reset-password"];
41
77
  async function bootstrap({ strapi: strapi2 }) {
78
+ await applyDiscovery(strapi2);
42
79
  const adminUrl = strapi2.config.get("admin.url", "/admin");
43
80
  const tokenRefreshPath = `${adminUrl}/token/refresh`;
44
81
  const enforceOidcMiddleware = async (ctx, next) => {
@@ -93,6 +130,16 @@ async function bootstrap({ strapi: strapi2 }) {
93
130
  { section: "plugins", displayName: "Update", uid: "update", pluginName: "strapi-plugin-oidc" }
94
131
  ];
95
132
  await strapi2.admin.services.permission.actionProvider.registerMany(actions);
133
+ const contentApiScopeUids = [
134
+ "plugin::strapi-plugin-oidc.whitelist.read",
135
+ "plugin::strapi-plugin-oidc.whitelist.write",
136
+ "plugin::strapi-plugin-oidc.whitelist.delete",
137
+ "plugin::strapi-plugin-oidc.audit.read",
138
+ "plugin::strapi-plugin-oidc.audit.delete"
139
+ ];
140
+ for (const uid of contentApiScopeUids) {
141
+ strapi2.contentAPI.permissions.providers.action.register(uid, { uid });
142
+ }
96
143
  const enforceOIDCConfig = getEnforceOIDCConfig(strapi2);
97
144
  if (enforceOIDCConfig !== null) {
98
145
  try {
@@ -140,23 +187,29 @@ function destroy() {
140
187
  const config = {
141
188
  default: {
142
189
  REMEMBER_ME: false,
190
+ OIDC_DISCOVERY_URL: "",
143
191
  OIDC_REDIRECT_URI: "http://localhost:1337/strapi-plugin-oidc/oidc/callback",
144
192
  OIDC_CLIENT_ID: "",
145
193
  OIDC_CLIENT_SECRET: "",
146
194
  OIDC_SCOPE: "openid profile email",
147
- OIDC_AUTHORIZATION_ENDPOINT: "",
148
- OIDC_TOKEN_ENDPOINT: "",
149
- OIDC_USERINFO_ENDPOINT: "",
150
- OIDC_GRANT_TYPE: "authorization_code",
151
195
  OIDC_FAMILY_NAME_FIELD: "family_name",
152
196
  OIDC_GIVEN_NAME_FIELD: "given_name",
153
- OIDC_END_SESSION_ENDPOINT: "",
154
197
  OIDC_SSO_BUTTON_TEXT: "Login via SSO",
155
198
  OIDC_ENFORCE: null,
156
199
  // null = use DB setting; true/false = override DB (useful for lockout recovery)
157
200
  AUDIT_LOG_RETENTION_DAYS: 90,
158
201
  OIDC_GROUP_FIELD: "groups",
159
- OIDC_GROUP_ROLE_MAP: "{}"
202
+ OIDC_GROUP_ROLE_MAP: "{}",
203
+ OIDC_REQUIRE_EMAIL_VERIFIED: true,
204
+ OIDC_TRUSTED_IP_HEADER: "",
205
+ OIDC_FORCE_SECURE_COOKIES: false,
206
+ // Populated at bootstrap from OIDC_DISCOVERY_URL — not user-configurable directly
207
+ OIDC_AUTHORIZATION_ENDPOINT: "",
208
+ OIDC_TOKEN_ENDPOINT: "",
209
+ OIDC_USERINFO_ENDPOINT: "",
210
+ OIDC_END_SESSION_ENDPOINT: "",
211
+ OIDC_JWKS_URI: "",
212
+ OIDC_ISSUER: ""
160
213
  },
161
214
  validator() {
162
215
  }
@@ -205,11 +258,20 @@ const contentTypes = {
205
258
  whitelists,
206
259
  "audit-log": auditLog$1
207
260
  };
208
- function getExpiredCookieOptions(strapi2, ctx) {
261
+ function shouldMarkSecure(strapi2, ctx) {
209
262
  const isProduction = strapi2.config.get("environment") === "production";
263
+ if (!isProduction) return false;
264
+ const config2 = strapi2.config.get("plugin::strapi-plugin-oidc") ?? {};
265
+ if (config2.OIDC_FORCE_SECURE_COOKIES === true) return true;
266
+ if (ctx.request.secure) return true;
267
+ const proxyTrusted = ctx.app?.proxy === true;
268
+ if (proxyTrusted && ctx.get("x-forwarded-proto") === "https") return true;
269
+ return false;
270
+ }
271
+ function getExpiredCookieOptions(strapi2, ctx) {
210
272
  return {
211
273
  httpOnly: true,
212
- secure: isProduction && ctx.request.secure,
274
+ secure: shouldMarkSecure(strapi2, ctx),
213
275
  path: strapi2.config.get("admin.auth.cookie.path", "/admin"),
214
276
  domain: strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain"),
215
277
  sameSite: strapi2.config.get("admin.auth.cookie.sameSite", "lax"),
@@ -236,7 +298,9 @@ const errorCodes = {
236
298
  NONCE_MISMATCH: "NONCE_MISMATCH",
237
299
  ROLE_UPDATE_FAILED: "ROLE_UPDATE_FAILED",
238
300
  USER_CREATION_FAILED: "USER_CREATION_FAILED",
239
- WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED"
301
+ WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED",
302
+ EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED",
303
+ ID_TOKEN_INVALID: "ID_TOKEN_INVALID"
240
304
  };
241
305
  const ERROR_DETAIL_TEMPLATES = {
242
306
  token_exchange_failed: "Token exchange failed with HTTP status {status}",
@@ -246,6 +310,8 @@ const ERROR_DETAIL_TEMPLATES = {
246
310
  id_token_parse_failed: "ID token parse failed: {error}",
247
311
  sign_in_unknown: "Unknown sign-in error: {error}",
248
312
  invalid_email: "Invalid email address received from OIDC provider",
313
+ email_not_verified: "Email address has not been verified by the OIDC provider",
314
+ id_token_invalid: "ID token verification failed: {error}",
249
315
  whitelist_not_present: "Email not present in whitelist",
250
316
  session_manager_unsupported: "sessionManager is not supported. Please upgrade to Strapi v5.24.1 or later.",
251
317
  missing_config: "Missing required config keys: {keys}"
@@ -265,6 +331,8 @@ const errorMessages = {
265
331
  ID_TOKEN_PARSE_FAILED: "Failed to parse ID token",
266
332
  NONCE_MISMATCH: "Nonce mismatch",
267
333
  INVALID_EMAIL: "Invalid email address received from OIDC provider",
334
+ EMAIL_NOT_VERIFIED: "Email address has not been verified by the OIDC provider",
335
+ ID_TOKEN_INVALID: "ID token verification failed",
268
336
  WHITELIST_NOT_PRESENT: "Not present in whitelist",
269
337
  SESSION_MANAGER_UNSUPPORTED: "sessionManager is not supported. Please upgrade to Strapi v5.24.1 or later.",
270
338
  MISSING_CONFIG: (keys) => `Missing required config keys: ${keys}`
@@ -331,7 +399,6 @@ const en = {
331
399
  "auditlog.table.ip": "IP",
332
400
  "auditlog.table.details": "Details",
333
401
  "auditlog.table.empty": "No audit log entries",
334
- "auditlog.loading": "Loading…",
335
402
  "auditlog.clear": "Clear Logs",
336
403
  "auditlog.clear.title": "Clear All Logs",
337
404
  "auditlog.clear.description": "This will permanently delete all {count, plural, one {# audit log entry} other {# audit log entries}}. This action cannot be undone.",
@@ -362,6 +429,8 @@ const en = {
362
429
  "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.",
363
430
  "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
364
431
  "auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
432
+ "auditlog.action.email_not_verified": "The OIDC provider did not confirm the user's email address as verified. Access was denied.",
433
+ "auditlog.action.id_token_invalid": "The ID token failed signature, issuer, audience, or expiry validation. Access was denied.",
365
434
  "auth.page.authenticating.title": "Authenticating...",
366
435
  "auth.page.authenticating.noscript.heading": "JavaScript Required",
367
436
  "auth.page.authenticating.noscript.body": "JavaScript must be enabled for authentication to complete.",
@@ -384,7 +453,6 @@ const locales = Object.fromEntries(
384
453
  return [code ?? "", mod.default];
385
454
  })
386
455
  );
387
- Object.keys(locales).filter(Boolean);
388
456
  const DEFAULT_LOCALE = "en";
389
457
  function parseAcceptLanguage(header) {
390
458
  return header.split(",").map((part) => {
@@ -471,40 +539,99 @@ const OIDC_ERROR_DISPATCH = {
471
539
  code: errorCodes.TOKEN_EXCHANGE_FAILED,
472
540
  key: "sign_in_unknown"
473
541
  },
542
+ email_not_verified: {
543
+ action: "email_not_verified",
544
+ code: errorCodes.EMAIL_NOT_VERIFIED,
545
+ key: "email_not_verified"
546
+ },
547
+ id_token_invalid: {
548
+ action: "id_token_invalid",
549
+ code: errorCodes.ID_TOKEN_INVALID,
550
+ key: "id_token_invalid"
551
+ },
474
552
  unknown: {
475
553
  action: "login_failure",
476
554
  code: errorCodes.TOKEN_EXCHANGE_FAILED,
477
555
  key: "sign_in_unknown"
478
556
  }
479
557
  };
558
+ const TRUSTED_IP_HEADER = "cf-connecting-ip";
559
+ function getTrustedHeaderName() {
560
+ const config2 = strapi.config.get("plugin::strapi-plugin-oidc") ?? {};
561
+ const raw = config2.OIDC_TRUSTED_IP_HEADER;
562
+ if (typeof raw !== "string" || !raw) return void 0;
563
+ const normalized = raw.trim().toLowerCase();
564
+ return normalized === TRUSTED_IP_HEADER ? normalized : void 0;
565
+ }
480
566
  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();
567
+ const proxyTrusted = ctx.app?.proxy === true;
568
+ if (proxyTrusted) {
569
+ const trustedHeader = getTrustedHeaderName();
570
+ if (trustedHeader) {
571
+ const value = ctx.get(trustedHeader);
572
+ if (value) return value.split(",")[0].trim();
573
+ }
574
+ const forwarded = ctx.request.ips;
575
+ if (forwarded && forwarded.length > 0) {
576
+ return forwarded[0];
577
+ }
492
578
  }
493
579
  return ctx.ip;
494
580
  }
581
+ function toMessage(e) {
582
+ return e instanceof Error ? e.message : String(e);
583
+ }
495
584
  const REQUIRED_CONFIG_KEYS = [
585
+ "OIDC_DISCOVERY_URL",
496
586
  "OIDC_CLIENT_ID",
497
587
  "OIDC_CLIENT_SECRET",
498
588
  "OIDC_REDIRECT_URI",
499
589
  "OIDC_SCOPE",
500
- "OIDC_TOKEN_ENDPOINT",
501
- "OIDC_USERINFO_ENDPOINT",
502
- "OIDC_GRANT_TYPE",
503
590
  "OIDC_FAMILY_NAME_FIELD",
504
591
  "OIDC_GIVEN_NAME_FIELD",
592
+ // Populated at bootstrap from OIDC_DISCOVERY_URL — checked here as a runtime safety net
593
+ "OIDC_TOKEN_ENDPOINT",
594
+ "OIDC_USERINFO_ENDPOINT",
505
595
  "OIDC_AUTHORIZATION_ENDPOINT"
506
596
  ];
507
- const LOGOUT_USERINFO_TIMEOUT_MS = 3e3;
597
+ const LOGOUT_USERINFO_TIMEOUT_MS = 1500;
598
+ const jwksCache = /* @__PURE__ */ new Map();
599
+ let jwksDisabledWarned = false;
600
+ function getJwks(uri) {
601
+ let jwks = jwksCache.get(uri);
602
+ if (!jwks) {
603
+ jwks = createRemoteJWKSet(new URL(uri));
604
+ jwksCache.set(uri, jwks);
605
+ }
606
+ return jwks;
607
+ }
608
+ async function verifyIdToken(idToken, config2) {
609
+ const jwksUri = config2.OIDC_JWKS_URI;
610
+ const issuer = config2.OIDC_ISSUER;
611
+ if (!jwksUri) {
612
+ if (!jwksDisabledWarned) {
613
+ jwksDisabledWarned = true;
614
+ strapi.log.warn(
615
+ "[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."
616
+ );
617
+ }
618
+ return null;
619
+ }
620
+ try {
621
+ const jwks = getJwks(jwksUri);
622
+ const { payload } = await jwtVerify(idToken, jwks, {
623
+ issuer: issuer || void 0,
624
+ audience: config2.OIDC_CLIENT_ID
625
+ });
626
+ return payload;
627
+ } catch (e) {
628
+ if (e instanceof errors.JWTClaimValidationFailed || e instanceof errors.JWSSignatureVerificationFailed || e instanceof errors.JWTExpired || e instanceof errors.JWTInvalid || e instanceof errors.JWSInvalid) {
629
+ const msg = toMessage(e);
630
+ throw new OidcError("id_token_invalid", msg, e);
631
+ }
632
+ throw e;
633
+ }
634
+ }
508
635
  function configValidation() {
509
636
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
510
637
  const missing = REQUIRED_CONFIG_KEYS.filter((key) => !config2[key]);
@@ -518,11 +645,10 @@ async function oidcSignIn(ctx) {
518
645
  const { code_verifier: codeVerifier, code_challenge: codeChallenge } = await pkceChallenge();
519
646
  const state = randomBytes(32).toString("base64url");
520
647
  const nonce = randomBytes(32).toString("base64url");
521
- const isProduction = strapi.config.get("environment") === "production";
522
648
  const cookieOptions = {
523
649
  httpOnly: true,
524
650
  maxAge: 6e5,
525
- secure: isProduction && ctx.request.secure,
651
+ secure: shouldMarkSecure(strapi, ctx),
526
652
  sameSite: "lax"
527
653
  };
528
654
  ctx.cookies.set("oidc_code_verifier", codeVerifier, cookieOptions);
@@ -555,14 +681,16 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
555
681
  }
556
682
  const tokenData = await response.json();
557
683
  if (tokenData.id_token) {
684
+ const verifiedPayload = await verifyIdToken(tokenData.id_token, config2);
558
685
  try {
559
- const payloadB64 = tokenData.id_token.split(".")[1];
560
- const idTokenPayload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
686
+ const idTokenPayload = verifiedPayload ?? JSON.parse(
687
+ Buffer.from(tokenData.id_token.split(".")[1], "base64url").toString("utf8")
688
+ );
561
689
  if (idTokenPayload.nonce !== expectedNonce) {
562
690
  throw new OidcError("nonce_mismatch", errorMessages.NONCE_MISMATCH);
563
691
  }
564
692
  } catch (e) {
565
- if (e instanceof OidcError && e.kind === "nonce_mismatch") throw e;
693
+ if (e instanceof OidcError) throw e;
566
694
  throw new OidcError("id_token_parse_failed", errorMessages.ID_TOKEN_PARSE_FAILED, e);
567
695
  }
568
696
  }
@@ -689,7 +817,7 @@ async function ensureUser(userService, oauthService2, email, userResponseData, c
689
817
  );
690
818
  return { user, userCreated: true, rolesUpdated: true };
691
819
  } catch (e) {
692
- const msg = e instanceof Error ? e.message : String(e);
820
+ const msg = toMessage(e);
693
821
  throw new OidcError("user_creation_failed", msg, e);
694
822
  }
695
823
  }
@@ -708,6 +836,13 @@ async function handleUserAuthentication(userService, oauthService2, roleService2
708
836
  if (!email || !isValidEmail(email)) {
709
837
  throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
710
838
  }
839
+ if (config2.OIDC_REQUIRE_EMAIL_VERIFIED !== false) {
840
+ const emailVerified = userResponseData.email_verified;
841
+ const isVerified = emailVerified === true || emailVerified === "true";
842
+ if (!isVerified) {
843
+ throw new OidcError("email_not_verified", errorMessages.EMAIL_NOT_VERIFIED);
844
+ }
845
+ }
711
846
  await whitelistService2.checkWhitelistForEmail(email);
712
847
  const resolved = await resolveRoles(userResponseData, config2, roleService2);
713
848
  const { user, userCreated, rolesUpdated } = await ensureUser(
@@ -732,9 +867,9 @@ async function handleUserAuthentication(userService, oauthService2, roleService2
732
867
  function classifyOidcError(e, userInfo) {
733
868
  const kind = e instanceof OidcError ? e.kind : "unknown";
734
869
  const dispatch = OIDC_ERROR_DISPATCH[kind];
735
- const msg = e instanceof Error ? e.message : String(e);
870
+ const msg = toMessage(e);
736
871
  let params;
737
- if (kind === "id_token_parse_failed" || kind === "unknown") {
872
+ if (kind === "id_token_parse_failed" || kind === "id_token_invalid" || kind === "unknown") {
738
873
  params = { error: msg };
739
874
  } else if (kind === "user_creation_failed" && userInfo?.email) {
740
875
  params = { email: userInfo.email, error: msg };
@@ -781,7 +916,7 @@ async function logSuccessfulAuth(auditLog2, ctx, user, userCreated, rolesUpdated
781
916
  }
782
917
  async function handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx) {
783
918
  const errorInfo = classifyOidcError(e, userInfo);
784
- const message = e instanceof Error ? e.message : String(e);
919
+ const message = toMessage(e);
785
920
  await auditLog2.log({
786
921
  action: errorInfo.action,
787
922
  email: userInfo?.email,
@@ -822,15 +957,14 @@ async function oidcSignInCallback(ctx) {
822
957
  client_id: config2.OIDC_CLIENT_ID,
823
958
  client_secret: config2.OIDC_CLIENT_SECRET,
824
959
  redirect_uri: config2.OIDC_REDIRECT_URI,
825
- grant_type: config2.OIDC_GRANT_TYPE,
960
+ grant_type: "authorization_code",
826
961
  code_verifier: codeVerifier ?? ""
827
962
  });
828
963
  let userInfo;
829
964
  try {
830
965
  const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
831
966
  userInfo = exchangeResult.userInfo;
832
- const isProduction = strapi.config.get("environment") === "production";
833
- const secureFlag = isProduction && ctx.request.secure;
967
+ const secureFlag = shouldMarkSecure(strapi, ctx);
834
968
  ctx.cookies.set("oidc_access_token", exchangeResult.accessToken, {
835
969
  httpOnly: true,
836
970
  maxAge: 3e5,
@@ -867,13 +1001,13 @@ async function oidcSignInCallback(ctx) {
867
1001
  await handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx);
868
1002
  }
869
1003
  }
870
- async function isProviderSessionActive(userinfoEndpoint, accessToken) {
1004
+ async function isProviderSessionExpired(userinfoEndpoint, accessToken) {
871
1005
  try {
872
1006
  const response = await fetch(userinfoEndpoint, {
873
1007
  headers: { Authorization: `Bearer ${accessToken}` },
874
1008
  signal: AbortSignal.timeout(LOGOUT_USERINFO_TIMEOUT_MS)
875
1009
  });
876
- return response.ok;
1010
+ return !response.ok;
877
1011
  } catch {
878
1012
  return false;
879
1013
  }
@@ -893,14 +1027,14 @@ async function logout(ctx) {
893
1027
  }
894
1028
  const logAudit = (action) => userEmail ? auditLog2.log({ action, email: userEmail, ip: getClientIp(ctx) }) : Promise.resolve();
895
1029
  if (logoutUrl && accessToken) {
896
- const active = await isProviderSessionActive(config2.OIDC_USERINFO_ENDPOINT, accessToken);
897
- if (active) {
898
- logAudit("logout").catch(() => {
899
- });
900
- return ctx.redirect(logoutUrl);
1030
+ const expired = await isProviderSessionExpired(config2.OIDC_USERINFO_ENDPOINT, accessToken);
1031
+ if (expired) {
1032
+ await logAudit("session_expired");
1033
+ return ctx.redirect(loginUrl);
901
1034
  }
902
- await logAudit("session_expired");
903
- return ctx.redirect(loginUrl);
1035
+ logAudit("logout").catch(() => {
1036
+ });
1037
+ return ctx.redirect(logoutUrl);
904
1038
  }
905
1039
  await logAudit("logout");
906
1040
  ctx.redirect(logoutUrl || loginUrl);
@@ -1000,16 +1134,34 @@ async function register(ctx) {
1000
1134
  return;
1001
1135
  }
1002
1136
  const rawEmails = Array.isArray(email) ? email : email.split(",");
1003
- const emailList = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
1137
+ const normalized = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
1138
+ const rejectedEmails = [];
1139
+ const validEmails = [];
1140
+ for (const e of normalized) {
1141
+ if (isValidEmail(e)) {
1142
+ validEmails.push(e);
1143
+ } else {
1144
+ rejectedEmails.push(e);
1145
+ }
1146
+ }
1147
+ if (validEmails.length === 0) {
1148
+ ctx.status = 400;
1149
+ ctx.body = { error: "No valid email addresses supplied", rejectedEmails };
1150
+ return;
1151
+ }
1004
1152
  const whitelistService2 = getWhitelistService();
1005
- const matchedExistingUsersCount = await whitelistService2.countAdminUsersByEmails(emailList);
1006
- for (const singleEmail of emailList) {
1153
+ let acceptedCount = 0;
1154
+ let alreadyWhitelistedCount = 0;
1155
+ for (const singleEmail of validEmails) {
1007
1156
  const alreadyWhitelisted = await whitelistService2.hasUser(singleEmail);
1008
- if (!alreadyWhitelisted) {
1157
+ if (alreadyWhitelisted) {
1158
+ alreadyWhitelistedCount++;
1159
+ } else {
1009
1160
  await whitelistService2.registerUser(singleEmail);
1161
+ acceptedCount++;
1010
1162
  }
1011
1163
  }
1012
- ctx.body = { matchedExistingUsersCount };
1164
+ ctx.body = { acceptedCount, alreadyWhitelistedCount, rejectedEmails };
1013
1165
  }
1014
1166
  async function removeEmail(ctx) {
1015
1167
  const { email } = ctx.params;
@@ -1040,13 +1192,9 @@ async function importUsers(ctx) {
1040
1192
  const whitelistService2 = getWhitelistService();
1041
1193
  const existing = await whitelistService2.getUsers();
1042
1194
  const existingEmails = new Set(existing.map((u) => u.email));
1043
- let importedCount = 0;
1044
- for (const email of deduped) {
1045
- if (existingEmails.has(email)) continue;
1046
- await whitelistService2.registerUser(email);
1047
- importedCount++;
1048
- }
1049
- ctx.body = { importedCount };
1195
+ const toImport = deduped.filter((email) => !existingEmails.has(email));
1196
+ await Promise.all(toImport.map((email) => whitelistService2.registerUser(email)));
1197
+ ctx.body = { importedCount: toImport.length };
1050
1198
  }
1051
1199
  async function syncUsers(ctx) {
1052
1200
  const { users: rawUsers } = ctx.request.body;
@@ -1055,17 +1203,11 @@ async function syncUsers(ctx) {
1055
1203
  const currentUsers = await whitelistService2.getUsers();
1056
1204
  const syncEmailSet = new Set(emails);
1057
1205
  const currentUsersByEmail = new Map(currentUsers.map((u) => [u.email, u]));
1058
- for (const currUser of currentUsers) {
1059
- if (!syncEmailSet.has(currUser.email)) {
1060
- await whitelistService2.removeUser(currUser.email);
1061
- }
1062
- }
1063
- for (const email of emails) {
1064
- if (!currentUsersByEmail.has(email)) {
1065
- await whitelistService2.registerUser(email);
1066
- }
1067
- }
1068
- ctx.body = { matchedExistingUsersCount: 0 };
1206
+ await Promise.all([
1207
+ ...currentUsers.filter((u) => !syncEmailSet.has(u.email)).map((u) => whitelistService2.removeUser(u.email)),
1208
+ ...emails.filter((email) => !currentUsersByEmail.has(email)).map((email) => whitelistService2.registerUser(email))
1209
+ ]);
1210
+ ctx.body = {};
1069
1211
  }
1070
1212
  const whitelist = {
1071
1213
  info,
@@ -1086,6 +1228,8 @@ const AUDIT_ACTIONS = [
1086
1228
  "nonce_mismatch",
1087
1229
  "token_exchange_failed",
1088
1230
  "whitelist_rejected",
1231
+ "email_not_verified",
1232
+ "id_token_invalid",
1089
1233
  "logout",
1090
1234
  "session_expired",
1091
1235
  "user_created"
@@ -1285,6 +1429,22 @@ const controllers = {
1285
1429
  const rateLimitMap = /* @__PURE__ */ new Map();
1286
1430
  const RATE_LIMIT_WINDOW = 6e4;
1287
1431
  const MAX_REQUESTS = 1e3;
1432
+ const MAX_MAP_SIZE = 1e4;
1433
+ const PRUNE_THRESHOLD = 1e3;
1434
+ function pruneExpiredEntries(now) {
1435
+ const windowStart = now - RATE_LIMIT_WINDOW;
1436
+ for (const [key, stamps] of rateLimitMap) {
1437
+ if (stamps.length === 0 || stamps[stamps.length - 1] <= windowStart) {
1438
+ rateLimitMap.delete(key);
1439
+ }
1440
+ }
1441
+ }
1442
+ function evictOldestEntry() {
1443
+ const oldest = rateLimitMap.keys().next().value;
1444
+ if (oldest !== void 0) {
1445
+ rateLimitMap.delete(oldest);
1446
+ }
1447
+ }
1288
1448
  function getRateLimitKey(ctx) {
1289
1449
  const ip = getClientIp(ctx);
1290
1450
  const ua = ctx.request.header["user-agent"] ?? "";
@@ -1295,6 +1455,9 @@ function rateLimitMiddleware(ctx, next) {
1295
1455
  const key = getRateLimitKey(ctx);
1296
1456
  const now = Date.now();
1297
1457
  const windowStart = now - RATE_LIMIT_WINDOW;
1458
+ if (rateLimitMap.size > PRUNE_THRESHOLD) {
1459
+ pruneExpiredEntries(now);
1460
+ }
1298
1461
  const requestStamps = (rateLimitMap.get(key) ?? []).filter((ts) => ts > windowStart);
1299
1462
  if (requestStamps.length >= MAX_REQUESTS) {
1300
1463
  ctx.status = 429;
@@ -1302,6 +1465,9 @@ function rateLimitMiddleware(ctx, next) {
1302
1465
  return;
1303
1466
  }
1304
1467
  requestStamps.push(now);
1468
+ if (!rateLimitMap.has(key) && rateLimitMap.size >= MAX_MAP_SIZE) {
1469
+ evictOldestEntry();
1470
+ }
1305
1471
  rateLimitMap.set(key, requestStamps);
1306
1472
  return next();
1307
1473
  }
@@ -1350,6 +1516,12 @@ const routes = {
1350
1516
  handler: "oidc.logout",
1351
1517
  config: { auth: false }
1352
1518
  },
1519
+ {
1520
+ method: "POST",
1521
+ path: "/logout",
1522
+ handler: "oidc.logout",
1523
+ config: { auth: false }
1524
+ },
1353
1525
  {
1354
1526
  method: "GET",
1355
1527
  path: "/whitelist",
@@ -1427,53 +1599,63 @@ const routes = {
1427
1599
  // API-token-authenticated routes for programmatic whitelist management.
1428
1600
  // Accessible at /strapi-plugin-oidc/... using a Strapi API token
1429
1601
  // (full-access or custom) in the Authorization: Bearer <token> header.
1602
+ // Custom tokens must be granted one or more of the semantic scopes below.
1430
1603
  "content-api": {
1431
1604
  type: "content-api",
1432
1605
  routes: [
1433
1606
  {
1434
1607
  method: "GET",
1435
1608
  path: "/whitelist",
1436
- handler: "whitelist.info"
1609
+ handler: "whitelist.info",
1610
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.read"] } }
1437
1611
  },
1438
1612
  {
1439
1613
  method: "POST",
1440
1614
  path: "/whitelist",
1441
- handler: "whitelist.register"
1615
+ handler: "whitelist.register",
1616
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.write"] } }
1442
1617
  },
1443
1618
  {
1444
1619
  method: "POST",
1445
1620
  path: "/whitelist/import",
1446
- handler: "whitelist.importUsers"
1621
+ handler: "whitelist.importUsers",
1622
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.write"] } }
1447
1623
  },
1448
1624
  {
1449
1625
  method: "DELETE",
1450
1626
  path: "/whitelist/:email",
1451
- handler: "whitelist.removeEmail"
1627
+ handler: "whitelist.removeEmail",
1628
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.delete"] } }
1452
1629
  },
1453
1630
  {
1454
1631
  method: "DELETE",
1455
1632
  path: "/whitelist",
1456
- handler: "whitelist.deleteAll"
1633
+ handler: "whitelist.deleteAll",
1634
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.delete"] } }
1457
1635
  },
1458
1636
  {
1459
1637
  method: "GET",
1460
1638
  path: "/whitelist/export",
1461
- handler: "whitelist.exportWhitelist"
1639
+ handler: "whitelist.exportWhitelist",
1640
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.read"] } }
1462
1641
  },
1463
1642
  {
1464
1643
  method: "GET",
1465
1644
  path: "/audit-logs",
1466
- handler: "auditLog.find"
1645
+ handler: "auditLog.find",
1646
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.read"] } }
1467
1647
  },
1468
1648
  {
1469
1649
  method: "GET",
1470
1650
  path: "/audit-logs/export",
1471
- handler: "auditLog.export"
1651
+ handler: "auditLog.export",
1652
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.read"] } }
1472
1653
  },
1473
1654
  {
1474
1655
  method: "DELETE",
1475
1656
  path: "/audit-logs",
1476
- handler: "auditLog.clearAll"
1657
+ handler: "auditLog.clearAll",
1658
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.delete"] } }
1477
1659
  }
1478
1660
  ]
1479
1661
  }
@@ -1642,9 +1824,10 @@ function oauthService({ strapi: strapi2 }) {
1642
1824
  }
1643
1825
  const modelDef = strapi2.getModel("admin::user");
1644
1826
  const sanitizedEntity = await strapiUtils.sanitize.sanitizers.defaultSanitizeOutput(
1645
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1646
- { schema: modelDef, getModel: (uid2) => strapi2.getModel(uid2) },
1647
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1827
+ {
1828
+ schema: modelDef,
1829
+ getModel: (uid2) => strapi2.getModel(uid2)
1830
+ },
1648
1831
  user
1649
1832
  );
1650
1833
  eventHub?.emit(ENTRY_CREATE ?? "entry.create", {
@@ -1725,13 +1908,12 @@ function oauthService({ strapi: strapi2 }) {
1725
1908
  type: rememberMe ? "refresh" : "session"
1726
1909
  }
1727
1910
  );
1728
- const isProduction = strapi2.config.get("environment") === "production";
1729
1911
  const domain = strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain");
1730
1912
  const path = strapi2.config.get("admin.auth.cookie.path", "/admin");
1731
1913
  const sameSite = strapi2.config.get("admin.auth.cookie.sameSite", "lax");
1732
1914
  const cookieOptions = {
1733
1915
  httpOnly: true,
1734
- secure: isProduction && ctx.request.secure,
1916
+ secure: shouldMarkSecure(strapi2, ctx),
1735
1917
  overwrite: true,
1736
1918
  domain,
1737
1919
  path,
@@ -1850,14 +2032,6 @@ function whitelistService({ strapi: strapi2 }) {
1850
2032
  },
1851
2033
  async deleteAllUsers() {
1852
2034
  await getWhitelistQuery().deleteMany({});
1853
- },
1854
- async countAdminUsersByEmails(emails) {
1855
- if (emails.length === 0) return 0;
1856
- const rows = await strapi2.query("admin::user").findMany({
1857
- where: { email: { $in: emails } },
1858
- select: ["id"]
1859
- });
1860
- return rows.length;
1861
2035
  }
1862
2036
  };
1863
2037
  }