strapi-plugin-oidc 1.8.0 → 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.
@@ -38,8 +38,44 @@ const getRoleService = () => strapi.plugin(PLUGIN_NAME).service("role");
38
38
  const getWhitelistService = () => strapi.plugin(PLUGIN_NAME).service("whitelist");
39
39
  const getAuditLogService = () => strapi.plugin(PLUGIN_NAME).service("auditLog");
40
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
+ }
41
76
  const AUTH_ROUTES = ["login", "register", "register-admin", "forgot-password", "reset-password"];
42
77
  async function bootstrap({ strapi: strapi2 }) {
78
+ await applyDiscovery(strapi2);
43
79
  const adminUrl = strapi2.config.get("admin.url", "/admin");
44
80
  const tokenRefreshPath = `${adminUrl}/token/refresh`;
45
81
  const enforceOidcMiddleware = async (ctx, next) => {
@@ -151,17 +187,13 @@ function destroy() {
151
187
  const config = {
152
188
  default: {
153
189
  REMEMBER_ME: false,
190
+ OIDC_DISCOVERY_URL: "",
154
191
  OIDC_REDIRECT_URI: "http://localhost:1337/strapi-plugin-oidc/oidc/callback",
155
192
  OIDC_CLIENT_ID: "",
156
193
  OIDC_CLIENT_SECRET: "",
157
194
  OIDC_SCOPE: "openid profile email",
158
- OIDC_AUTHORIZATION_ENDPOINT: "",
159
- OIDC_TOKEN_ENDPOINT: "",
160
- OIDC_USERINFO_ENDPOINT: "",
161
- OIDC_GRANT_TYPE: "authorization_code",
162
195
  OIDC_FAMILY_NAME_FIELD: "family_name",
163
196
  OIDC_GIVEN_NAME_FIELD: "given_name",
164
- OIDC_END_SESSION_ENDPOINT: "",
165
197
  OIDC_SSO_BUTTON_TEXT: "Login via SSO",
166
198
  OIDC_ENFORCE: null,
167
199
  // null = use DB setting; true/false = override DB (useful for lockout recovery)
@@ -170,9 +202,14 @@ const config = {
170
202
  OIDC_GROUP_ROLE_MAP: "{}",
171
203
  OIDC_REQUIRE_EMAIL_VERIFIED: true,
172
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: "",
173
211
  OIDC_JWKS_URI: "",
174
- OIDC_ISSUER: "",
175
- OIDC_FORCE_SECURE_COOKIES: false
212
+ OIDC_ISSUER: ""
176
213
  },
177
214
  validator() {
178
215
  }
@@ -228,8 +265,7 @@ function shouldMarkSecure(strapi2, ctx) {
228
265
  if (config2.OIDC_FORCE_SECURE_COOKIES === true) return true;
229
266
  if (ctx.request.secure) return true;
230
267
  const proxyTrusted = ctx.app?.proxy === true;
231
- if (proxyTrusted && typeof ctx.get === "function" && ctx.get("x-forwarded-proto") === "https")
232
- return true;
268
+ if (proxyTrusted && ctx.get("x-forwarded-proto") === "https") return true;
233
269
  return false;
234
270
  }
235
271
  function getExpiredCookieOptions(strapi2, ctx) {
@@ -363,7 +399,6 @@ const en = {
363
399
  "auditlog.table.ip": "IP",
364
400
  "auditlog.table.details": "Details",
365
401
  "auditlog.table.empty": "No audit log entries",
366
- "auditlog.loading": "Loading…",
367
402
  "auditlog.clear": "Clear Logs",
368
403
  "auditlog.clear.title": "Clear All Logs",
369
404
  "auditlog.clear.description": "This will permanently delete all {count, plural, one {# audit log entry} other {# audit log entries}}. This action cannot be undone.",
@@ -418,7 +453,6 @@ const locales = Object.fromEntries(
418
453
  return [code ?? "", mod.default];
419
454
  })
420
455
  );
421
- Object.keys(locales).filter(Boolean);
422
456
  const DEFAULT_LOCALE = "en";
423
457
  function parseAcceptLanguage(header) {
424
458
  return header.split(",").map((part) => {
@@ -521,13 +555,13 @@ const OIDC_ERROR_DISPATCH = {
521
555
  key: "sign_in_unknown"
522
556
  }
523
557
  };
524
- const TRUSTED_HEADER_WHITELIST = /* @__PURE__ */ new Set(["cf-connecting-ip"]);
558
+ const TRUSTED_IP_HEADER = "cf-connecting-ip";
525
559
  function getTrustedHeaderName() {
526
560
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc") ?? {};
527
561
  const raw = config2.OIDC_TRUSTED_IP_HEADER;
528
562
  if (typeof raw !== "string" || !raw) return void 0;
529
563
  const normalized = raw.trim().toLowerCase();
530
- return TRUSTED_HEADER_WHITELIST.has(normalized) ? normalized : void 0;
564
+ return normalized === TRUSTED_IP_HEADER ? normalized : void 0;
531
565
  }
532
566
  function getClientIp(ctx) {
533
567
  const proxyTrusted = ctx.app?.proxy === true;
@@ -544,19 +578,23 @@ function getClientIp(ctx) {
544
578
  }
545
579
  return ctx.ip;
546
580
  }
581
+ function toMessage(e) {
582
+ return e instanceof Error ? e.message : String(e);
583
+ }
547
584
  const REQUIRED_CONFIG_KEYS = [
585
+ "OIDC_DISCOVERY_URL",
548
586
  "OIDC_CLIENT_ID",
549
587
  "OIDC_CLIENT_SECRET",
550
588
  "OIDC_REDIRECT_URI",
551
589
  "OIDC_SCOPE",
552
- "OIDC_TOKEN_ENDPOINT",
553
- "OIDC_USERINFO_ENDPOINT",
554
- "OIDC_GRANT_TYPE",
555
590
  "OIDC_FAMILY_NAME_FIELD",
556
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",
557
595
  "OIDC_AUTHORIZATION_ENDPOINT"
558
596
  ];
559
- const LOGOUT_USERINFO_TIMEOUT_MS = 3e3;
597
+ const LOGOUT_USERINFO_TIMEOUT_MS = 1500;
560
598
  const jwksCache = /* @__PURE__ */ new Map();
561
599
  let jwksDisabledWarned = false;
562
600
  function getJwks(uri) {
@@ -588,7 +626,7 @@ async function verifyIdToken(idToken, config2) {
588
626
  return payload;
589
627
  } catch (e) {
590
628
  if (e instanceof errors.JWTClaimValidationFailed || e instanceof errors.JWSSignatureVerificationFailed || e instanceof errors.JWTExpired || e instanceof errors.JWTInvalid || e instanceof errors.JWSInvalid) {
591
- const msg = e instanceof Error ? e.message : String(e);
629
+ const msg = toMessage(e);
592
630
  throw new OidcError("id_token_invalid", msg, e);
593
631
  }
594
632
  throw e;
@@ -779,7 +817,7 @@ async function ensureUser(userService, oauthService2, email, userResponseData, c
779
817
  );
780
818
  return { user, userCreated: true, rolesUpdated: true };
781
819
  } catch (e) {
782
- const msg = e instanceof Error ? e.message : String(e);
820
+ const msg = toMessage(e);
783
821
  throw new OidcError("user_creation_failed", msg, e);
784
822
  }
785
823
  }
@@ -829,7 +867,7 @@ async function handleUserAuthentication(userService, oauthService2, roleService2
829
867
  function classifyOidcError(e, userInfo) {
830
868
  const kind = e instanceof OidcError ? e.kind : "unknown";
831
869
  const dispatch = OIDC_ERROR_DISPATCH[kind];
832
- const msg = e instanceof Error ? e.message : String(e);
870
+ const msg = toMessage(e);
833
871
  let params;
834
872
  if (kind === "id_token_parse_failed" || kind === "id_token_invalid" || kind === "unknown") {
835
873
  params = { error: msg };
@@ -878,7 +916,7 @@ async function logSuccessfulAuth(auditLog2, ctx, user, userCreated, rolesUpdated
878
916
  }
879
917
  async function handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx) {
880
918
  const errorInfo = classifyOidcError(e, userInfo);
881
- const message = e instanceof Error ? e.message : String(e);
919
+ const message = toMessage(e);
882
920
  await auditLog2.log({
883
921
  action: errorInfo.action,
884
922
  email: userInfo?.email,
@@ -919,7 +957,7 @@ async function oidcSignInCallback(ctx) {
919
957
  client_id: config2.OIDC_CLIENT_ID,
920
958
  client_secret: config2.OIDC_CLIENT_SECRET,
921
959
  redirect_uri: config2.OIDC_REDIRECT_URI,
922
- grant_type: config2.OIDC_GRANT_TYPE,
960
+ grant_type: "authorization_code",
923
961
  code_verifier: codeVerifier ?? ""
924
962
  });
925
963
  let userInfo;
@@ -963,13 +1001,13 @@ async function oidcSignInCallback(ctx) {
963
1001
  await handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx);
964
1002
  }
965
1003
  }
966
- async function isProviderSessionActive(userinfoEndpoint, accessToken) {
1004
+ async function isProviderSessionExpired(userinfoEndpoint, accessToken) {
967
1005
  try {
968
1006
  const response = await fetch(userinfoEndpoint, {
969
1007
  headers: { Authorization: `Bearer ${accessToken}` },
970
1008
  signal: AbortSignal.timeout(LOGOUT_USERINFO_TIMEOUT_MS)
971
1009
  });
972
- return response.ok;
1010
+ return !response.ok;
973
1011
  } catch {
974
1012
  return false;
975
1013
  }
@@ -989,14 +1027,14 @@ async function logout(ctx) {
989
1027
  }
990
1028
  const logAudit = (action) => userEmail ? auditLog2.log({ action, email: userEmail, ip: getClientIp(ctx) }) : Promise.resolve();
991
1029
  if (logoutUrl && accessToken) {
992
- const active = await isProviderSessionActive(config2.OIDC_USERINFO_ENDPOINT, accessToken);
993
- if (active) {
994
- logAudit("logout").catch(() => {
995
- });
996
- 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);
997
1034
  }
998
- await logAudit("session_expired");
999
- return ctx.redirect(loginUrl);
1035
+ logAudit("logout").catch(() => {
1036
+ });
1037
+ return ctx.redirect(logoutUrl);
1000
1038
  }
1001
1039
  await logAudit("logout");
1002
1040
  ctx.redirect(logoutUrl || loginUrl);
@@ -1154,13 +1192,9 @@ async function importUsers(ctx) {
1154
1192
  const whitelistService2 = getWhitelistService();
1155
1193
  const existing = await whitelistService2.getUsers();
1156
1194
  const existingEmails = new Set(existing.map((u) => u.email));
1157
- let importedCount = 0;
1158
- for (const email of deduped) {
1159
- if (existingEmails.has(email)) continue;
1160
- await whitelistService2.registerUser(email);
1161
- importedCount++;
1162
- }
1163
- 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 };
1164
1198
  }
1165
1199
  async function syncUsers(ctx) {
1166
1200
  const { users: rawUsers } = ctx.request.body;
@@ -1169,16 +1203,10 @@ async function syncUsers(ctx) {
1169
1203
  const currentUsers = await whitelistService2.getUsers();
1170
1204
  const syncEmailSet = new Set(emails);
1171
1205
  const currentUsersByEmail = new Map(currentUsers.map((u) => [u.email, u]));
1172
- for (const currUser of currentUsers) {
1173
- if (!syncEmailSet.has(currUser.email)) {
1174
- await whitelistService2.removeUser(currUser.email);
1175
- }
1176
- }
1177
- for (const email of emails) {
1178
- if (!currentUsersByEmail.has(email)) {
1179
- await whitelistService2.registerUser(email);
1180
- }
1181
- }
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
+ ]);
1182
1210
  ctx.body = {};
1183
1211
  }
1184
1212
  const whitelist = {
@@ -1482,6 +1510,12 @@ const routes = {
1482
1510
  handler: "oidc.oidcSignInCallback",
1483
1511
  config: { auth: false, middlewares: [rateLimitMiddleware] }
1484
1512
  },
1513
+ {
1514
+ method: "GET",
1515
+ path: "/logout",
1516
+ handler: "oidc.logout",
1517
+ config: { auth: false }
1518
+ },
1485
1519
  {
1486
1520
  method: "POST",
1487
1521
  path: "/logout",
@@ -1790,9 +1824,10 @@ function oauthService({ strapi: strapi2 }) {
1790
1824
  }
1791
1825
  const modelDef = strapi2.getModel("admin::user");
1792
1826
  const sanitizedEntity = await strapiUtils.sanitize.sanitizers.defaultSanitizeOutput(
1793
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1794
- { schema: modelDef, getModel: (uid2) => strapi2.getModel(uid2) },
1795
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1827
+ {
1828
+ schema: modelDef,
1829
+ getModel: (uid2) => strapi2.getModel(uid2)
1830
+ },
1796
1831
  user
1797
1832
  );
1798
1833
  eventHub?.emit(ENTRY_CREATE ?? "entry.create", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-oidc",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "A Strapi plugin that provides OpenID Connect (OIDC) authentication functionality for the Strapi Admin Panel.",
5
5
  "strapi": {
6
6
  "displayName": "OIDC Plugin",