strapi-plugin-oidc 1.7.6 → 1.8.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.
package/README.md CHANGED
@@ -44,19 +44,38 @@ module.exports = ({ env }) => ({
44
44
  OIDC_GRANT_TYPE: 'authorization_code',
45
45
  OIDC_FAMILY_NAME_FIELD: 'family_name',
46
46
  OIDC_GIVEN_NAME_FIELD: 'given_name',
47
- OIDC_END_SESSION_ENDPOINT: '', // Provider end-session URL for RP-initiated logout
47
+ OIDC_END_SESSION_ENDPOINT: '', // Provider end-session URL (from discovery `end_session_endpoint`)
48
48
  OIDC_SSO_BUTTON_TEXT: 'Login via SSO',
49
49
  OIDC_ENFORCE: null, // null = use Admin UI toggle; true/false = override in config
50
50
  REMEMBER_ME: false, // Persist session across browser restarts
51
51
  AUDIT_LOG_RETENTION_DAYS: 90, // Set to 0 to disable audit logging; otherwise entries older than this many days are purged daily at midnight
52
52
  OIDC_GROUP_FIELD: 'groups', // OIDC claim field containing group membership
53
53
  OIDC_GROUP_ROLE_MAP: '{}', // JSON map of group names to Strapi role names
54
+ OIDC_REQUIRE_EMAIL_VERIFIED: true, // Reject logins when provider does not report email_verified=true (set false to disable)
55
+ OIDC_TRUSTED_IP_HEADER: '', // Optional: 'cf-connecting-ip' for Cloudflare; read only when Strapi trusts the proxy
56
+ OIDC_JWKS_URI: '', // Provider's JWKS URI (from discovery `jwks_uri`) — enables ID token signature verification
57
+ OIDC_ISSUER: '', // Provider's issuer (from discovery `issuer`) — verified against ID token's `iss`
58
+ OIDC_FORCE_SECURE_COOKIES: false, // Set true when behind a trusted HTTPS proxy that Strapi can't auto-detect
54
59
  },
55
60
  },
56
61
  });
57
62
  ```
58
63
 
59
- All required values come from your provider's OIDC discovery document, typically available at `https://your-provider/.well-known/openid-configuration`.
64
+ All OIDC values come from your provider's discovery document, typically available at `https://your-provider/.well-known/openid-configuration`.
65
+
66
+ ### Security features
67
+
68
+ - **ID token verification** — `OIDC_JWKS_URI` and `OIDC_ISSUER` enable signature, issuer, audience, and expiry validation via [`jose`](https://github.com/panva/jose)
69
+ - **Email verification** — `OIDC_REQUIRE_EMAIL_VERIFIED: true` (default) rejects unverified emails
70
+ - **CSRF protection** — OIDC state/nonce and POST-only logout endpoint
71
+ - **Rate limiting** — 1 000 req/min per IP+UA (in-process; use a reverse-proxy-level limiter for multi-node)
72
+ - **Secure cookies** — `OIDC_FORCE_SECURE_COOKIES` ensures cookies are marked Secure
73
+
74
+ ### Client IP attribution and reverse proxies
75
+
76
+ The plugin logs client IPs for rate-limit buckets and audit logs. When Strapi runs behind a reverse proxy, **set `server.proxy: true`** so Koa trusts `X-Forwarded-For`; otherwise all IPs will be the proxy's.
77
+
78
+ Set `OIDC_TRUSTED_IP_HEADER: 'cf-connecting-ip'` when behind Cloudflare. The header is only honoured when `server.proxy: true` is set.
60
79
 
61
80
  ## Login
62
81
 
@@ -64,27 +83,27 @@ Navigate to `/strapi-plugin-oidc/oidc` to start the OIDC flow, or click the **Lo
64
83
 
65
84
  ## Logout
66
85
 
67
- When `OIDC_END_SESSION_ENDPOINT` is set, clicking logout in Strapi redirects the browser to the provider's end-session URL (RP-initiated logout). If the provider session has already expired, Strapi skips the redirect and goes straight to the login page.
86
+ When `OIDC_END_SESSION_ENDPOINT` is set, clicking logout redirects to the provider's end-session URL (RP-initiated logout). If the provider session has already expired, Strapi skips the redirect and goes straight to the login page.
87
+
88
+ The logout endpoint is `POST /strapi-plugin-oidc/logout`. Using POST instead of GET prevents CSRF-forced-logout attacks.
68
89
 
69
90
  ## Admin Settings
70
91
 
71
92
  Manage the plugin under **Settings → OIDC Plugin**.
72
93
 
73
- **Default Roles** — Select which Strapi admin role(s) are assigned to new users on first login.
94
+ **Default Roles** — Strapi admin role(s) assigned to new users on first login.
74
95
 
75
- **Whitelist** — Restrict access to specific email addresses. When enabled, only listed emails can log in. When empty, any successfully authenticated OIDC user gets an account. The whitelist supports:
96
+ **Whitelist** — Restrict access to specific email addresses. When empty, any authenticated OIDC user gets an account. Supports:
76
97
 
77
- - Adding individual emails with optional role overrides
78
- - JSON import / export (see [format](#import-format) below)
98
+ - Individual emails with optional role overrides
99
+ - JSON import / export
79
100
  - Bulk delete with confirmation
80
- - Unsaved changes are held in the UI until **Save Changes** is clicked
81
101
 
82
- **Audit Logs** — Every authentication event is recorded in the plugin's audit log table and visible in the **Audit Logs** section at the bottom of the settings page. Entries can be filtered by action, email, IP address, and date, and a **Download** button exports the current (filtered) view as NDJSON (newline-delimited JSON), compatible with SIEM tools and log processors. Setting `AUDIT_LOG_RETENTION_DAYS` to `0` disables audit logging entirely. Otherwise records older than the configured value (default: 90 days) are automatically purged by a daily cron job. The audit log is also accessible [via API](#audit-log-api).
102
+ **Audit Logs** — Authentication events recorded and visible in the settings page. Filter by action, email, IP, and date. **Download** exports the current view as NDJSON. Set `AUDIT_LOG_RETENTION_DAYS` to `0` to disable. Records older than the configured value (default: 90 days) are purged daily.
83
103
 
84
- **Enforce OIDC Login** — Removes the standard email/password fields from the login page and blocks direct login API calls server-side. Automatically disabled when the whitelist is empty to prevent lockout.
104
+ **Enforce OIDC Login** — Removes email/password fields from the login page and blocks direct login API calls. Automatically disabled when the whitelist is empty to prevent lockout.
85
105
 
86
- - The toggle is grayed out and locked when `OIDC_ENFORCE` is set in config.
87
- - **Lockout recovery**: set `OIDC_ENFORCE: false` in your plugin config and restart Strapi. This writes through to the database so removing the variable afterwards keeps the setting.
106
+ The toggle is grayed out when `OIDC_ENFORCE` is set in config. **Lockout recovery**: set `OIDC_ENFORCE: false` in your plugin config and restart Strapi.
88
107
 
89
108
  ## Group-to-Role Mapping
90
109
 
@@ -118,18 +137,26 @@ Role names are the **display names** shown in **Settings → Roles** (e.g. `"Edi
118
137
 
119
138
  ### Role assignment precedence
120
139
 
121
- 1. **User's OIDC groups match `OIDC_GROUP_ROLE_MAP`** → use the mapped Strapi roles
122
- 2. **No group match or no mapping configured** → use the default OIDC roles (new users only — see below)
140
+ 1. **OIDC groups match `OIDC_GROUP_ROLE_MAP`** → mapped Strapi roles
141
+ 2. **No match or no mapping** → default OIDC roles (new users only)
123
142
 
124
143
  ### Role updates on subsequent logins
125
144
 
126
- - **New users** — Roles are always assigned on first login: group-mapped roles if a match is found, otherwise the configured default OIDC roles.
127
- - **Existing users with a group mapping match** — Roles are updated to reflect the current mapping. If a user's groups change between logins, their Strapi roles are updated accordingly.
128
- - **Existing users with no group mapping match** — Roles are left unchanged, regardless of what the default OIDC roles are set to. Manually-assigned roles are never overwritten by a default fallback.
145
+ - **New users** — Roles assigned on first login (group-mapped or default).
146
+ - **Existing users with group match** — Roles updated to reflect current mapping.
147
+ - **Existing users without group match** — Roles left unchanged. Manually-assigned roles are never overwritten.
129
148
 
130
149
  ## Whitelist API
131
150
 
132
- The whitelist can be managed programmatically using a Strapi **API token** (Settings → API Tokens → Full Access). All endpoints are under `/api/strapi-plugin-oidc` and require `Authorization: Bearer <token>`.
151
+ The whitelist can be managed programmatically using a Strapi **API token**. All endpoints are under `/api/strapi-plugin-oidc` and require `Authorization: Bearer <token>`.
152
+
153
+ **Full-access tokens** can call all routes. **Custom tokens** must be granted one of the following scopes (Settings → API Tokens → Custom → plugin permissions):
154
+
155
+ | Scope | Routes |
156
+ | --------------------------------------------- | ----------------------------------------------- |
157
+ | `plugin::strapi-plugin-oidc.whitelist.read` | `GET /whitelist`, `GET /whitelist/export` |
158
+ | `plugin::strapi-plugin-oidc.whitelist.write` | `POST /whitelist`, `POST /whitelist/import` |
159
+ | `plugin::strapi-plugin-oidc.whitelist.delete` | `DELETE /whitelist`, `DELETE /whitelist/:email` |
133
160
 
134
161
  | Method | Path | Description |
135
162
  | -------- | ------------------------------------------ | ---------------------- |
@@ -185,7 +212,14 @@ curl -X DELETE -H "Authorization: Bearer <token>" \
185
212
 
186
213
  ## Audit Log API
187
214
 
188
- Audit log entries can be fetched programmatically using a Strapi **API token** (Settings → API Tokens → Full Access). Endpoints are under `/api/strapi-plugin-oidc` and require `Authorization: Bearer <token>`.
215
+ Audit log entries can be fetched programmatically using a Strapi **API token**. Endpoints are under `/api/strapi-plugin-oidc` and require `Authorization: Bearer <token>`.
216
+
217
+ **Full-access tokens** can call all routes. **Custom tokens** must be granted one of the following scopes:
218
+
219
+ | Scope | Routes |
220
+ | ----------------------------------------- | ------------------------------------------- |
221
+ | `plugin::strapi-plugin-oidc.audit.read` | `GET /audit-logs`, `GET /audit-logs/export` |
222
+ | `plugin::strapi-plugin-oidc.audit.delete` | `DELETE /audit-logs` |
189
223
 
190
224
  | Method | Path | Description |
191
225
  | -------- | ------------------------------------------- | ----------------------------------- |
@@ -246,18 +280,20 @@ curl -H "Authorization: Bearer <token>" -G \
246
280
 
247
281
  ### Recorded actions
248
282
 
249
- | Action | Trigger |
250
- | ----------------------- | --------------------------------------------------- |
251
- | `login_success` | Successful OIDC authentication |
252
- | `user_created` | New Strapi admin user created during login |
253
- | `login_failure` | Unexpected error during the OIDC login flow |
254
- | `missing_code` | Callback received without an authorisation code |
255
- | `state_mismatch` | CSRF state cookie does not match callback parameter |
256
- | `nonce_mismatch` | ID token nonce does not match the session nonce |
257
- | `token_exchange_failed` | Provider returned an error during token exchange |
258
- | `whitelist_rejected` | Email not present in the active whitelist |
259
- | `logout` | User logged out via `/logout` |
260
- | `session_expired` | Logout attempted but provider session already stale |
283
+ | Action | Trigger |
284
+ | ----------------------- | ----------------------------------------------------------------- |
285
+ | `login_success` | Successful OIDC authentication |
286
+ | `user_created` | New Strapi admin user created during login |
287
+ | `login_failure` | Unexpected error during the OIDC login flow |
288
+ | `missing_code` | Callback received without an authorisation code |
289
+ | `state_mismatch` | CSRF state cookie does not match callback parameter |
290
+ | `nonce_mismatch` | ID token nonce does not match the session nonce |
291
+ | `token_exchange_failed` | Provider returned an error during token exchange |
292
+ | `whitelist_rejected` | Email not present in the active whitelist |
293
+ | `email_not_verified` | Provider did not report `email_verified=true` |
294
+ | `id_token_invalid` | ID token failed signature, issuer, audience, or expiry validation |
295
+ | `logout` | User logged out via `/logout` |
296
+ | `session_expired` | Logout attempted but provider session already stale |
261
297
 
262
298
  Each event is also emitted on Strapi's internal eventHub as `strapi-plugin-oidc::auth.<action>`, which Enterprise audit log listeners pick up automatically.
263
299
 
@@ -280,16 +316,16 @@ This plugin is a hard fork of [`strapi-plugin-sso`](https://github.com/yasudaclo
280
316
 
281
317
  ### Changes from the original:
282
318
 
283
- - Removed alternative SSO methods to focus solely on OIDC.
284
- - Redesigned the Whitelist and Role management UI using native Strapi components.
285
- - Added OIDC enforcement with an admin toggle and config override (`OIDC_ENFORCE`).
286
- - Added RP-initiated logout with smart session detection — skips the provider redirect if the session is already expired.
287
- - Migrated to Vitest with comprehensive e2e test coverage.
288
- - Config variable names aligned with OIDC discovery document field names (`OIDC_SCOPE`, `OIDC_USERINFO_ENDPOINT`, `OIDC_END_SESSION_ENDPOINT`).
289
- - Always injects a **Login via SSO** button on the Strapi login page. Button text is configurable via `OIDC_SSO_BUTTON_TEXT`.
290
- - Whitelist: programmatic REST API with JSON import/export, bulk delete, delete by email, and unsaved changes guard.
291
- - Hardened OIDC flow: server-generated state and nonce, PKCE, Bearer token auth for userinfo, and generic error messages on failure.
292
- - Audit log: records all auth events to a queryable table with Admin UI, JSON export, and REST API. API responses use a single `datetime` field and omit framework metadata (id, documentId, locale, publishedAt, etc.). A separate `user_created` event is emitted when a Strapi admin is provisioned during login.
319
+ - OIDC-only (removed other SSO methods)
320
+ - Redesigned whitelist and role management UI using native Strapi components
321
+ - OIDC enforcement via admin toggle or `OIDC_ENFORCE` config
322
+ - RP-initiated logout with smart session detection
323
+ - Migrated to Vitest with e2e coverage
324
+ - Config variable names aligned with OIDC discovery document field names
325
+ - **Login via SSO** button always injected; text configurable via `OIDC_SSO_BUTTON_TEXT`
326
+ - Whitelist REST API with JSON import/export, bulk delete, delete by email
327
+ - Hardened OIDC flow: server-generated state and nonce, PKCE, Bearer token auth for userinfo, generic error messages on failure
328
+ - Audit log: records all auth events to a queryable table with UI, JSON/NDJSON export, and REST API
293
329
 
294
330
  ## License
295
331
 
@@ -5,7 +5,7 @@ import { useState, useRef, useId, useEffect, useCallback, useReducer, useMemo, m
5
5
  import { Typography, Flex, Box, MultiSelect, MultiSelectOption, Button, Dialog, Table, Pagination, PreviousLink, NextLink, PageLink, Field, Divider, Thead, Tr, Th, Tbody, Td, IconButton, Tooltip, Alert } from "@strapi/design-system";
6
6
  import { Cross, WarningCircle, Plus, Download, Upload, Trash, Calendar, Mail, Information } from "@strapi/icons";
7
7
  import { useIntl } from "react-intl";
8
- import { g as getTrad } from "./index-DRJ6Ty2J.mjs";
8
+ import { g as getTrad } from "./index-B-K4X_N9.mjs";
9
9
  import styled from "styled-components";
10
10
  import { Filter, ClipboardList, Server } from "lucide-react";
11
11
  function Role({ oidcRoles, roles, onChangeRole }) {
@@ -3611,6 +3611,8 @@ const AUDIT_ACTIONS = [
3611
3611
  "nonce_mismatch",
3612
3612
  "token_exchange_failed",
3613
3613
  "whitelist_rejected",
3614
+ "email_not_verified",
3615
+ "id_token_invalid",
3614
3616
  "logout",
3615
3617
  "session_expired",
3616
3618
  "user_created"
@@ -125,6 +125,8 @@ const en = {
125
125
  "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.",
126
126
  "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
127
127
  "auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
128
+ "auditlog.action.email_not_verified": "The OIDC provider did not confirm the user's email address as verified. Access was denied.",
129
+ "auditlog.action.id_token_invalid": "The ID token failed signature, issuer, audience, or expiry validation. Access was denied.",
128
130
  "auth.page.authenticating.title": "Authenticating...",
129
131
  "auth.page.authenticating.noscript.heading": "JavaScript Required",
130
132
  "auth.page.authenticating.noscript.body": "JavaScript must be enabled for authentication to complete.",
@@ -165,7 +167,7 @@ const index = {
165
167
  defaultMessage: "Configuration"
166
168
  },
167
169
  Component: async () => {
168
- return await import("./index-pieFAsgM.mjs");
170
+ return await import("./index-8YTLPV3h.mjs");
169
171
  },
170
172
  permissions: [{ action: "plugin::strapi-plugin-oidc.read", subject: null }]
171
173
  }
@@ -268,7 +270,11 @@ const index = {
268
270
  window.sessionStorage.removeItem("isLoggedIn");
269
271
  document.cookie = "jwtToken=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
270
272
  document.cookie = "jwtToken=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/admin";
271
- window.location.href = "/strapi-plugin-oidc/logout";
273
+ const form = document.createElement("form");
274
+ form.method = "POST";
275
+ form.action = "/strapi-plugin-oidc/logout";
276
+ document.body.appendChild(form);
277
+ form.submit();
272
278
  return new Promise(() => {
273
279
  });
274
280
  }
@@ -126,6 +126,8 @@ const en = {
126
126
  "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.",
127
127
  "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
128
128
  "auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
129
+ "auditlog.action.email_not_verified": "The OIDC provider did not confirm the user's email address as verified. Access was denied.",
130
+ "auditlog.action.id_token_invalid": "The ID token failed signature, issuer, audience, or expiry validation. Access was denied.",
129
131
  "auth.page.authenticating.title": "Authenticating...",
130
132
  "auth.page.authenticating.noscript.heading": "JavaScript Required",
131
133
  "auth.page.authenticating.noscript.body": "JavaScript must be enabled for authentication to complete.",
@@ -166,7 +168,7 @@ const index = {
166
168
  defaultMessage: "Configuration"
167
169
  },
168
170
  Component: async () => {
169
- return await Promise.resolve().then(() => require("./index-D2rlNx1-.js"));
171
+ return await Promise.resolve().then(() => require("./index-CgG_mHzZ.js"));
170
172
  },
171
173
  permissions: [{ action: "plugin::strapi-plugin-oidc.read", subject: null }]
172
174
  }
@@ -269,7 +271,11 @@ const index = {
269
271
  window.sessionStorage.removeItem("isLoggedIn");
270
272
  document.cookie = "jwtToken=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
271
273
  document.cookie = "jwtToken=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/admin";
272
- window.location.href = "/strapi-plugin-oidc/logout";
274
+ const form = document.createElement("form");
275
+ form.method = "POST";
276
+ form.action = "/strapi-plugin-oidc/logout";
277
+ document.body.appendChild(form);
278
+ form.submit();
273
279
  return new Promise(() => {
274
280
  });
275
281
  }
@@ -7,7 +7,7 @@ const react = require("react");
7
7
  const designSystem = require("@strapi/design-system");
8
8
  const icons = require("@strapi/icons");
9
9
  const reactIntl = require("react-intl");
10
- const index = require("./index-CrnGXADu.js");
10
+ const index = require("./index-BSgVStns.js");
11
11
  const styled = require("styled-components");
12
12
  const lucideReact = require("lucide-react");
13
13
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
@@ -3615,6 +3615,8 @@ const AUDIT_ACTIONS = [
3615
3615
  "nonce_mismatch",
3616
3616
  "token_exchange_failed",
3617
3617
  "whitelist_rejected",
3618
+ "email_not_verified",
3619
+ "id_token_invalid",
3618
3620
  "logout",
3619
3621
  "session_expired",
3620
3622
  "user_created"
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
- const index = require("./index-CrnGXADu.js");
3
+ const index = require("./index-BSgVStns.js");
4
4
  exports.default = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "./index-DRJ6Ty2J.mjs";
1
+ import { i } from "./index-B-K4X_N9.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -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");
@@ -99,6 +100,16 @@ async function bootstrap({ strapi: strapi2 }) {
99
100
  { section: "plugins", displayName: "Update", uid: "update", pluginName: "strapi-plugin-oidc" }
100
101
  ];
101
102
  await strapi2.admin.services.permission.actionProvider.registerMany(actions);
103
+ const contentApiScopeUids = [
104
+ "plugin::strapi-plugin-oidc.whitelist.read",
105
+ "plugin::strapi-plugin-oidc.whitelist.write",
106
+ "plugin::strapi-plugin-oidc.whitelist.delete",
107
+ "plugin::strapi-plugin-oidc.audit.read",
108
+ "plugin::strapi-plugin-oidc.audit.delete"
109
+ ];
110
+ for (const uid of contentApiScopeUids) {
111
+ strapi2.contentAPI.permissions.providers.action.register(uid, { uid });
112
+ }
102
113
  const enforceOIDCConfig = getEnforceOIDCConfig(strapi2);
103
114
  if (enforceOIDCConfig !== null) {
104
115
  try {
@@ -162,7 +173,12 @@ const config = {
162
173
  // null = use DB setting; true/false = override DB (useful for lockout recovery)
163
174
  AUDIT_LOG_RETENTION_DAYS: 90,
164
175
  OIDC_GROUP_FIELD: "groups",
165
- OIDC_GROUP_ROLE_MAP: "{}"
176
+ OIDC_GROUP_ROLE_MAP: "{}",
177
+ OIDC_REQUIRE_EMAIL_VERIFIED: true,
178
+ OIDC_TRUSTED_IP_HEADER: "",
179
+ OIDC_JWKS_URI: "",
180
+ OIDC_ISSUER: "",
181
+ OIDC_FORCE_SECURE_COOKIES: false
166
182
  },
167
183
  validator() {
168
184
  }
@@ -211,11 +227,21 @@ const contentTypes = {
211
227
  whitelists,
212
228
  "audit-log": auditLog$1
213
229
  };
214
- function getExpiredCookieOptions(strapi2, ctx) {
230
+ function shouldMarkSecure(strapi2, ctx) {
215
231
  const isProduction = strapi2.config.get("environment") === "production";
232
+ if (!isProduction) return false;
233
+ const config2 = strapi2.config.get("plugin::strapi-plugin-oidc") ?? {};
234
+ if (config2.OIDC_FORCE_SECURE_COOKIES === true) return true;
235
+ if (ctx.request.secure) return true;
236
+ const proxyTrusted = ctx.app?.proxy === true;
237
+ if (proxyTrusted && typeof ctx.get === "function" && ctx.get("x-forwarded-proto") === "https")
238
+ return true;
239
+ return false;
240
+ }
241
+ function getExpiredCookieOptions(strapi2, ctx) {
216
242
  return {
217
243
  httpOnly: true,
218
- secure: isProduction && ctx.request.secure,
244
+ secure: shouldMarkSecure(strapi2, ctx),
219
245
  path: strapi2.config.get("admin.auth.cookie.path", "/admin"),
220
246
  domain: strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain"),
221
247
  sameSite: strapi2.config.get("admin.auth.cookie.sameSite", "lax"),
@@ -242,7 +268,9 @@ const errorCodes = {
242
268
  NONCE_MISMATCH: "NONCE_MISMATCH",
243
269
  ROLE_UPDATE_FAILED: "ROLE_UPDATE_FAILED",
244
270
  USER_CREATION_FAILED: "USER_CREATION_FAILED",
245
- WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED"
271
+ WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED",
272
+ EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED",
273
+ ID_TOKEN_INVALID: "ID_TOKEN_INVALID"
246
274
  };
247
275
  const ERROR_DETAIL_TEMPLATES = {
248
276
  token_exchange_failed: "Token exchange failed with HTTP status {status}",
@@ -252,6 +280,8 @@ const ERROR_DETAIL_TEMPLATES = {
252
280
  id_token_parse_failed: "ID token parse failed: {error}",
253
281
  sign_in_unknown: "Unknown sign-in error: {error}",
254
282
  invalid_email: "Invalid email address received from OIDC provider",
283
+ email_not_verified: "Email address has not been verified by the OIDC provider",
284
+ id_token_invalid: "ID token verification failed: {error}",
255
285
  whitelist_not_present: "Email not present in whitelist",
256
286
  session_manager_unsupported: "sessionManager is not supported. Please upgrade to Strapi v5.24.1 or later.",
257
287
  missing_config: "Missing required config keys: {keys}"
@@ -271,6 +301,8 @@ const errorMessages = {
271
301
  ID_TOKEN_PARSE_FAILED: "Failed to parse ID token",
272
302
  NONCE_MISMATCH: "Nonce mismatch",
273
303
  INVALID_EMAIL: "Invalid email address received from OIDC provider",
304
+ EMAIL_NOT_VERIFIED: "Email address has not been verified by the OIDC provider",
305
+ ID_TOKEN_INVALID: "ID token verification failed",
274
306
  WHITELIST_NOT_PRESENT: "Not present in whitelist",
275
307
  SESSION_MANAGER_UNSUPPORTED: "sessionManager is not supported. Please upgrade to Strapi v5.24.1 or later.",
276
308
  MISSING_CONFIG: (keys) => `Missing required config keys: ${keys}`
@@ -368,6 +400,8 @@ const en = {
368
400
  "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
401
  "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
370
402
  "auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
403
+ "auditlog.action.email_not_verified": "The OIDC provider did not confirm the user's email address as verified. Access was denied.",
404
+ "auditlog.action.id_token_invalid": "The ID token failed signature, issuer, audience, or expiry validation. Access was denied.",
371
405
  "auth.page.authenticating.title": "Authenticating...",
372
406
  "auth.page.authenticating.noscript.heading": "JavaScript Required",
373
407
  "auth.page.authenticating.noscript.body": "JavaScript must be enabled for authentication to complete.",
@@ -477,24 +511,42 @@ const OIDC_ERROR_DISPATCH = {
477
511
  code: errorCodes.TOKEN_EXCHANGE_FAILED,
478
512
  key: "sign_in_unknown"
479
513
  },
514
+ email_not_verified: {
515
+ action: "email_not_verified",
516
+ code: errorCodes.EMAIL_NOT_VERIFIED,
517
+ key: "email_not_verified"
518
+ },
519
+ id_token_invalid: {
520
+ action: "id_token_invalid",
521
+ code: errorCodes.ID_TOKEN_INVALID,
522
+ key: "id_token_invalid"
523
+ },
480
524
  unknown: {
481
525
  action: "login_failure",
482
526
  code: errorCodes.TOKEN_EXCHANGE_FAILED,
483
527
  key: "sign_in_unknown"
484
528
  }
485
529
  };
530
+ const TRUSTED_HEADER_WHITELIST = /* @__PURE__ */ new Set(["cf-connecting-ip"]);
531
+ function getTrustedHeaderName() {
532
+ const config2 = strapi.config.get("plugin::strapi-plugin-oidc") ?? {};
533
+ const raw = config2.OIDC_TRUSTED_IP_HEADER;
534
+ if (typeof raw !== "string" || !raw) return void 0;
535
+ const normalized = raw.trim().toLowerCase();
536
+ return TRUSTED_HEADER_WHITELIST.has(normalized) ? normalized : void 0;
537
+ }
486
538
  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();
539
+ const proxyTrusted = ctx.app?.proxy === true;
540
+ if (proxyTrusted) {
541
+ const trustedHeader = getTrustedHeaderName();
542
+ if (trustedHeader) {
543
+ const value = ctx.get(trustedHeader);
544
+ if (value) return value.split(",")[0].trim();
545
+ }
546
+ const forwarded = ctx.request.ips;
547
+ if (forwarded && forwarded.length > 0) {
548
+ return forwarded[0];
549
+ }
498
550
  }
499
551
  return ctx.ip;
500
552
  }
@@ -511,6 +563,43 @@ const REQUIRED_CONFIG_KEYS = [
511
563
  "OIDC_AUTHORIZATION_ENDPOINT"
512
564
  ];
513
565
  const LOGOUT_USERINFO_TIMEOUT_MS = 3e3;
566
+ const jwksCache = /* @__PURE__ */ new Map();
567
+ let jwksDisabledWarned = false;
568
+ function getJwks(uri) {
569
+ let jwks = jwksCache.get(uri);
570
+ if (!jwks) {
571
+ jwks = jose.createRemoteJWKSet(new URL(uri));
572
+ jwksCache.set(uri, jwks);
573
+ }
574
+ return jwks;
575
+ }
576
+ async function verifyIdToken(idToken, config2) {
577
+ const jwksUri = config2.OIDC_JWKS_URI;
578
+ const issuer = config2.OIDC_ISSUER;
579
+ if (!jwksUri) {
580
+ if (!jwksDisabledWarned) {
581
+ jwksDisabledWarned = true;
582
+ strapi.log.warn(
583
+ "[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."
584
+ );
585
+ }
586
+ return null;
587
+ }
588
+ try {
589
+ const jwks = getJwks(jwksUri);
590
+ const { payload } = await jose.jwtVerify(idToken, jwks, {
591
+ issuer: issuer || void 0,
592
+ audience: config2.OIDC_CLIENT_ID
593
+ });
594
+ return payload;
595
+ } catch (e) {
596
+ 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) {
597
+ const msg = e instanceof Error ? e.message : String(e);
598
+ throw new OidcError("id_token_invalid", msg, e);
599
+ }
600
+ throw e;
601
+ }
602
+ }
514
603
  function configValidation() {
515
604
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
516
605
  const missing = REQUIRED_CONFIG_KEYS.filter((key) => !config2[key]);
@@ -524,11 +613,10 @@ async function oidcSignIn(ctx) {
524
613
  const { code_verifier: codeVerifier, code_challenge: codeChallenge } = await pkceChallenge__default.default();
525
614
  const state = node_crypto.randomBytes(32).toString("base64url");
526
615
  const nonce = node_crypto.randomBytes(32).toString("base64url");
527
- const isProduction = strapi.config.get("environment") === "production";
528
616
  const cookieOptions = {
529
617
  httpOnly: true,
530
618
  maxAge: 6e5,
531
- secure: isProduction && ctx.request.secure,
619
+ secure: shouldMarkSecure(strapi, ctx),
532
620
  sameSite: "lax"
533
621
  };
534
622
  ctx.cookies.set("oidc_code_verifier", codeVerifier, cookieOptions);
@@ -561,14 +649,16 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
561
649
  }
562
650
  const tokenData = await response.json();
563
651
  if (tokenData.id_token) {
652
+ const verifiedPayload = await verifyIdToken(tokenData.id_token, config2);
564
653
  try {
565
- const payloadB64 = tokenData.id_token.split(".")[1];
566
- const idTokenPayload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
654
+ const idTokenPayload = verifiedPayload ?? JSON.parse(
655
+ Buffer.from(tokenData.id_token.split(".")[1], "base64url").toString("utf8")
656
+ );
567
657
  if (idTokenPayload.nonce !== expectedNonce) {
568
658
  throw new OidcError("nonce_mismatch", errorMessages.NONCE_MISMATCH);
569
659
  }
570
660
  } catch (e) {
571
- if (e instanceof OidcError && e.kind === "nonce_mismatch") throw e;
661
+ if (e instanceof OidcError) throw e;
572
662
  throw new OidcError("id_token_parse_failed", errorMessages.ID_TOKEN_PARSE_FAILED, e);
573
663
  }
574
664
  }
@@ -714,6 +804,13 @@ async function handleUserAuthentication(userService, oauthService2, roleService2
714
804
  if (!email || !isValidEmail(email)) {
715
805
  throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
716
806
  }
807
+ if (config2.OIDC_REQUIRE_EMAIL_VERIFIED !== false) {
808
+ const emailVerified = userResponseData.email_verified;
809
+ const isVerified = emailVerified === true || emailVerified === "true";
810
+ if (!isVerified) {
811
+ throw new OidcError("email_not_verified", errorMessages.EMAIL_NOT_VERIFIED);
812
+ }
813
+ }
717
814
  await whitelistService2.checkWhitelistForEmail(email);
718
815
  const resolved = await resolveRoles(userResponseData, config2, roleService2);
719
816
  const { user, userCreated, rolesUpdated } = await ensureUser(
@@ -740,7 +837,7 @@ function classifyOidcError(e, userInfo) {
740
837
  const dispatch = OIDC_ERROR_DISPATCH[kind];
741
838
  const msg = e instanceof Error ? e.message : String(e);
742
839
  let params;
743
- if (kind === "id_token_parse_failed" || kind === "unknown") {
840
+ if (kind === "id_token_parse_failed" || kind === "id_token_invalid" || kind === "unknown") {
744
841
  params = { error: msg };
745
842
  } else if (kind === "user_creation_failed" && userInfo?.email) {
746
843
  params = { email: userInfo.email, error: msg };
@@ -835,8 +932,7 @@ async function oidcSignInCallback(ctx) {
835
932
  try {
836
933
  const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
837
934
  userInfo = exchangeResult.userInfo;
838
- const isProduction = strapi.config.get("environment") === "production";
839
- const secureFlag = isProduction && ctx.request.secure;
935
+ const secureFlag = shouldMarkSecure(strapi, ctx);
840
936
  ctx.cookies.set("oidc_access_token", exchangeResult.accessToken, {
841
937
  httpOnly: true,
842
938
  maxAge: 3e5,
@@ -1006,16 +1102,34 @@ async function register(ctx) {
1006
1102
  return;
1007
1103
  }
1008
1104
  const rawEmails = Array.isArray(email) ? email : email.split(",");
1009
- const emailList = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
1105
+ const normalized = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
1106
+ const rejectedEmails = [];
1107
+ const validEmails = [];
1108
+ for (const e of normalized) {
1109
+ if (isValidEmail(e)) {
1110
+ validEmails.push(e);
1111
+ } else {
1112
+ rejectedEmails.push(e);
1113
+ }
1114
+ }
1115
+ if (validEmails.length === 0) {
1116
+ ctx.status = 400;
1117
+ ctx.body = { error: "No valid email addresses supplied", rejectedEmails };
1118
+ return;
1119
+ }
1010
1120
  const whitelistService2 = getWhitelistService();
1011
- const matchedExistingUsersCount = await whitelistService2.countAdminUsersByEmails(emailList);
1012
- for (const singleEmail of emailList) {
1121
+ let acceptedCount = 0;
1122
+ let alreadyWhitelistedCount = 0;
1123
+ for (const singleEmail of validEmails) {
1013
1124
  const alreadyWhitelisted = await whitelistService2.hasUser(singleEmail);
1014
- if (!alreadyWhitelisted) {
1125
+ if (alreadyWhitelisted) {
1126
+ alreadyWhitelistedCount++;
1127
+ } else {
1015
1128
  await whitelistService2.registerUser(singleEmail);
1129
+ acceptedCount++;
1016
1130
  }
1017
1131
  }
1018
- ctx.body = { matchedExistingUsersCount };
1132
+ ctx.body = { acceptedCount, alreadyWhitelistedCount, rejectedEmails };
1019
1133
  }
1020
1134
  async function removeEmail(ctx) {
1021
1135
  const { email } = ctx.params;
@@ -1071,7 +1185,7 @@ async function syncUsers(ctx) {
1071
1185
  await whitelistService2.registerUser(email);
1072
1186
  }
1073
1187
  }
1074
- ctx.body = { matchedExistingUsersCount: 0 };
1188
+ ctx.body = {};
1075
1189
  }
1076
1190
  const whitelist = {
1077
1191
  info,
@@ -1092,6 +1206,8 @@ const AUDIT_ACTIONS = [
1092
1206
  "nonce_mismatch",
1093
1207
  "token_exchange_failed",
1094
1208
  "whitelist_rejected",
1209
+ "email_not_verified",
1210
+ "id_token_invalid",
1095
1211
  "logout",
1096
1212
  "session_expired",
1097
1213
  "user_created"
@@ -1291,6 +1407,22 @@ const controllers = {
1291
1407
  const rateLimitMap = /* @__PURE__ */ new Map();
1292
1408
  const RATE_LIMIT_WINDOW = 6e4;
1293
1409
  const MAX_REQUESTS = 1e3;
1410
+ const MAX_MAP_SIZE = 1e4;
1411
+ const PRUNE_THRESHOLD = 1e3;
1412
+ function pruneExpiredEntries(now) {
1413
+ const windowStart = now - RATE_LIMIT_WINDOW;
1414
+ for (const [key, stamps] of rateLimitMap) {
1415
+ if (stamps.length === 0 || stamps[stamps.length - 1] <= windowStart) {
1416
+ rateLimitMap.delete(key);
1417
+ }
1418
+ }
1419
+ }
1420
+ function evictOldestEntry() {
1421
+ const oldest = rateLimitMap.keys().next().value;
1422
+ if (oldest !== void 0) {
1423
+ rateLimitMap.delete(oldest);
1424
+ }
1425
+ }
1294
1426
  function getRateLimitKey(ctx) {
1295
1427
  const ip = getClientIp(ctx);
1296
1428
  const ua = ctx.request.header["user-agent"] ?? "";
@@ -1301,6 +1433,9 @@ function rateLimitMiddleware(ctx, next) {
1301
1433
  const key = getRateLimitKey(ctx);
1302
1434
  const now = Date.now();
1303
1435
  const windowStart = now - RATE_LIMIT_WINDOW;
1436
+ if (rateLimitMap.size > PRUNE_THRESHOLD) {
1437
+ pruneExpiredEntries(now);
1438
+ }
1304
1439
  const requestStamps = (rateLimitMap.get(key) ?? []).filter((ts) => ts > windowStart);
1305
1440
  if (requestStamps.length >= MAX_REQUESTS) {
1306
1441
  ctx.status = 429;
@@ -1308,6 +1443,9 @@ function rateLimitMiddleware(ctx, next) {
1308
1443
  return;
1309
1444
  }
1310
1445
  requestStamps.push(now);
1446
+ if (!rateLimitMap.has(key) && rateLimitMap.size >= MAX_MAP_SIZE) {
1447
+ evictOldestEntry();
1448
+ }
1311
1449
  rateLimitMap.set(key, requestStamps);
1312
1450
  return next();
1313
1451
  }
@@ -1351,7 +1489,7 @@ const routes = {
1351
1489
  config: { auth: false, middlewares: [rateLimitMiddleware] }
1352
1490
  },
1353
1491
  {
1354
- method: "GET",
1492
+ method: "POST",
1355
1493
  path: "/logout",
1356
1494
  handler: "oidc.logout",
1357
1495
  config: { auth: false }
@@ -1433,53 +1571,63 @@ const routes = {
1433
1571
  // API-token-authenticated routes for programmatic whitelist management.
1434
1572
  // Accessible at /strapi-plugin-oidc/... using a Strapi API token
1435
1573
  // (full-access or custom) in the Authorization: Bearer <token> header.
1574
+ // Custom tokens must be granted one or more of the semantic scopes below.
1436
1575
  "content-api": {
1437
1576
  type: "content-api",
1438
1577
  routes: [
1439
1578
  {
1440
1579
  method: "GET",
1441
1580
  path: "/whitelist",
1442
- handler: "whitelist.info"
1581
+ handler: "whitelist.info",
1582
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.read"] } }
1443
1583
  },
1444
1584
  {
1445
1585
  method: "POST",
1446
1586
  path: "/whitelist",
1447
- handler: "whitelist.register"
1587
+ handler: "whitelist.register",
1588
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.write"] } }
1448
1589
  },
1449
1590
  {
1450
1591
  method: "POST",
1451
1592
  path: "/whitelist/import",
1452
- handler: "whitelist.importUsers"
1593
+ handler: "whitelist.importUsers",
1594
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.write"] } }
1453
1595
  },
1454
1596
  {
1455
1597
  method: "DELETE",
1456
1598
  path: "/whitelist/:email",
1457
- handler: "whitelist.removeEmail"
1599
+ handler: "whitelist.removeEmail",
1600
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.delete"] } }
1458
1601
  },
1459
1602
  {
1460
1603
  method: "DELETE",
1461
1604
  path: "/whitelist",
1462
- handler: "whitelist.deleteAll"
1605
+ handler: "whitelist.deleteAll",
1606
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.delete"] } }
1463
1607
  },
1464
1608
  {
1465
1609
  method: "GET",
1466
1610
  path: "/whitelist/export",
1467
- handler: "whitelist.exportWhitelist"
1611
+ handler: "whitelist.exportWhitelist",
1612
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.read"] } }
1468
1613
  },
1469
1614
  {
1470
1615
  method: "GET",
1471
1616
  path: "/audit-logs",
1472
- handler: "auditLog.find"
1617
+ handler: "auditLog.find",
1618
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.read"] } }
1473
1619
  },
1474
1620
  {
1475
1621
  method: "GET",
1476
1622
  path: "/audit-logs/export",
1477
- handler: "auditLog.export"
1623
+ handler: "auditLog.export",
1624
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.read"] } }
1478
1625
  },
1479
1626
  {
1480
1627
  method: "DELETE",
1481
1628
  path: "/audit-logs",
1482
- handler: "auditLog.clearAll"
1629
+ handler: "auditLog.clearAll",
1630
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.delete"] } }
1483
1631
  }
1484
1632
  ]
1485
1633
  }
@@ -1731,13 +1879,12 @@ function oauthService({ strapi: strapi2 }) {
1731
1879
  type: rememberMe ? "refresh" : "session"
1732
1880
  }
1733
1881
  );
1734
- const isProduction = strapi2.config.get("environment") === "production";
1735
1882
  const domain = strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain");
1736
1883
  const path = strapi2.config.get("admin.auth.cookie.path", "/admin");
1737
1884
  const sameSite = strapi2.config.get("admin.auth.cookie.sameSite", "lax");
1738
1885
  const cookieOptions = {
1739
1886
  httpOnly: true,
1740
- secure: isProduction && ctx.request.secure,
1887
+ secure: shouldMarkSecure(strapi2, ctx),
1741
1888
  overwrite: true,
1742
1889
  domain,
1743
1890
  path,
@@ -1856,14 +2003,6 @@ function whitelistService({ strapi: strapi2 }) {
1856
2003
  },
1857
2004
  async deleteAllUsers() {
1858
2005
  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
2006
  }
1868
2007
  };
1869
2008
  }
@@ -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";
@@ -93,6 +94,16 @@ async function bootstrap({ strapi: strapi2 }) {
93
94
  { section: "plugins", displayName: "Update", uid: "update", pluginName: "strapi-plugin-oidc" }
94
95
  ];
95
96
  await strapi2.admin.services.permission.actionProvider.registerMany(actions);
97
+ const contentApiScopeUids = [
98
+ "plugin::strapi-plugin-oidc.whitelist.read",
99
+ "plugin::strapi-plugin-oidc.whitelist.write",
100
+ "plugin::strapi-plugin-oidc.whitelist.delete",
101
+ "plugin::strapi-plugin-oidc.audit.read",
102
+ "plugin::strapi-plugin-oidc.audit.delete"
103
+ ];
104
+ for (const uid of contentApiScopeUids) {
105
+ strapi2.contentAPI.permissions.providers.action.register(uid, { uid });
106
+ }
96
107
  const enforceOIDCConfig = getEnforceOIDCConfig(strapi2);
97
108
  if (enforceOIDCConfig !== null) {
98
109
  try {
@@ -156,7 +167,12 @@ const config = {
156
167
  // null = use DB setting; true/false = override DB (useful for lockout recovery)
157
168
  AUDIT_LOG_RETENTION_DAYS: 90,
158
169
  OIDC_GROUP_FIELD: "groups",
159
- OIDC_GROUP_ROLE_MAP: "{}"
170
+ OIDC_GROUP_ROLE_MAP: "{}",
171
+ OIDC_REQUIRE_EMAIL_VERIFIED: true,
172
+ OIDC_TRUSTED_IP_HEADER: "",
173
+ OIDC_JWKS_URI: "",
174
+ OIDC_ISSUER: "",
175
+ OIDC_FORCE_SECURE_COOKIES: false
160
176
  },
161
177
  validator() {
162
178
  }
@@ -205,11 +221,21 @@ const contentTypes = {
205
221
  whitelists,
206
222
  "audit-log": auditLog$1
207
223
  };
208
- function getExpiredCookieOptions(strapi2, ctx) {
224
+ function shouldMarkSecure(strapi2, ctx) {
209
225
  const isProduction = strapi2.config.get("environment") === "production";
226
+ if (!isProduction) return false;
227
+ const config2 = strapi2.config.get("plugin::strapi-plugin-oidc") ?? {};
228
+ if (config2.OIDC_FORCE_SECURE_COOKIES === true) return true;
229
+ if (ctx.request.secure) return true;
230
+ const proxyTrusted = ctx.app?.proxy === true;
231
+ if (proxyTrusted && typeof ctx.get === "function" && ctx.get("x-forwarded-proto") === "https")
232
+ return true;
233
+ return false;
234
+ }
235
+ function getExpiredCookieOptions(strapi2, ctx) {
210
236
  return {
211
237
  httpOnly: true,
212
- secure: isProduction && ctx.request.secure,
238
+ secure: shouldMarkSecure(strapi2, ctx),
213
239
  path: strapi2.config.get("admin.auth.cookie.path", "/admin"),
214
240
  domain: strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain"),
215
241
  sameSite: strapi2.config.get("admin.auth.cookie.sameSite", "lax"),
@@ -236,7 +262,9 @@ const errorCodes = {
236
262
  NONCE_MISMATCH: "NONCE_MISMATCH",
237
263
  ROLE_UPDATE_FAILED: "ROLE_UPDATE_FAILED",
238
264
  USER_CREATION_FAILED: "USER_CREATION_FAILED",
239
- WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED"
265
+ WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED",
266
+ EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED",
267
+ ID_TOKEN_INVALID: "ID_TOKEN_INVALID"
240
268
  };
241
269
  const ERROR_DETAIL_TEMPLATES = {
242
270
  token_exchange_failed: "Token exchange failed with HTTP status {status}",
@@ -246,6 +274,8 @@ const ERROR_DETAIL_TEMPLATES = {
246
274
  id_token_parse_failed: "ID token parse failed: {error}",
247
275
  sign_in_unknown: "Unknown sign-in error: {error}",
248
276
  invalid_email: "Invalid email address received from OIDC provider",
277
+ email_not_verified: "Email address has not been verified by the OIDC provider",
278
+ id_token_invalid: "ID token verification failed: {error}",
249
279
  whitelist_not_present: "Email not present in whitelist",
250
280
  session_manager_unsupported: "sessionManager is not supported. Please upgrade to Strapi v5.24.1 or later.",
251
281
  missing_config: "Missing required config keys: {keys}"
@@ -265,6 +295,8 @@ const errorMessages = {
265
295
  ID_TOKEN_PARSE_FAILED: "Failed to parse ID token",
266
296
  NONCE_MISMATCH: "Nonce mismatch",
267
297
  INVALID_EMAIL: "Invalid email address received from OIDC provider",
298
+ EMAIL_NOT_VERIFIED: "Email address has not been verified by the OIDC provider",
299
+ ID_TOKEN_INVALID: "ID token verification failed",
268
300
  WHITELIST_NOT_PRESENT: "Not present in whitelist",
269
301
  SESSION_MANAGER_UNSUPPORTED: "sessionManager is not supported. Please upgrade to Strapi v5.24.1 or later.",
270
302
  MISSING_CONFIG: (keys) => `Missing required config keys: ${keys}`
@@ -362,6 +394,8 @@ const en = {
362
394
  "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
395
  "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
364
396
  "auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
397
+ "auditlog.action.email_not_verified": "The OIDC provider did not confirm the user's email address as verified. Access was denied.",
398
+ "auditlog.action.id_token_invalid": "The ID token failed signature, issuer, audience, or expiry validation. Access was denied.",
365
399
  "auth.page.authenticating.title": "Authenticating...",
366
400
  "auth.page.authenticating.noscript.heading": "JavaScript Required",
367
401
  "auth.page.authenticating.noscript.body": "JavaScript must be enabled for authentication to complete.",
@@ -471,24 +505,42 @@ const OIDC_ERROR_DISPATCH = {
471
505
  code: errorCodes.TOKEN_EXCHANGE_FAILED,
472
506
  key: "sign_in_unknown"
473
507
  },
508
+ email_not_verified: {
509
+ action: "email_not_verified",
510
+ code: errorCodes.EMAIL_NOT_VERIFIED,
511
+ key: "email_not_verified"
512
+ },
513
+ id_token_invalid: {
514
+ action: "id_token_invalid",
515
+ code: errorCodes.ID_TOKEN_INVALID,
516
+ key: "id_token_invalid"
517
+ },
474
518
  unknown: {
475
519
  action: "login_failure",
476
520
  code: errorCodes.TOKEN_EXCHANGE_FAILED,
477
521
  key: "sign_in_unknown"
478
522
  }
479
523
  };
524
+ const TRUSTED_HEADER_WHITELIST = /* @__PURE__ */ new Set(["cf-connecting-ip"]);
525
+ function getTrustedHeaderName() {
526
+ const config2 = strapi.config.get("plugin::strapi-plugin-oidc") ?? {};
527
+ const raw = config2.OIDC_TRUSTED_IP_HEADER;
528
+ if (typeof raw !== "string" || !raw) return void 0;
529
+ const normalized = raw.trim().toLowerCase();
530
+ return TRUSTED_HEADER_WHITELIST.has(normalized) ? normalized : void 0;
531
+ }
480
532
  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();
533
+ const proxyTrusted = ctx.app?.proxy === true;
534
+ if (proxyTrusted) {
535
+ const trustedHeader = getTrustedHeaderName();
536
+ if (trustedHeader) {
537
+ const value = ctx.get(trustedHeader);
538
+ if (value) return value.split(",")[0].trim();
539
+ }
540
+ const forwarded = ctx.request.ips;
541
+ if (forwarded && forwarded.length > 0) {
542
+ return forwarded[0];
543
+ }
492
544
  }
493
545
  return ctx.ip;
494
546
  }
@@ -505,6 +557,43 @@ const REQUIRED_CONFIG_KEYS = [
505
557
  "OIDC_AUTHORIZATION_ENDPOINT"
506
558
  ];
507
559
  const LOGOUT_USERINFO_TIMEOUT_MS = 3e3;
560
+ const jwksCache = /* @__PURE__ */ new Map();
561
+ let jwksDisabledWarned = false;
562
+ function getJwks(uri) {
563
+ let jwks = jwksCache.get(uri);
564
+ if (!jwks) {
565
+ jwks = createRemoteJWKSet(new URL(uri));
566
+ jwksCache.set(uri, jwks);
567
+ }
568
+ return jwks;
569
+ }
570
+ async function verifyIdToken(idToken, config2) {
571
+ const jwksUri = config2.OIDC_JWKS_URI;
572
+ const issuer = config2.OIDC_ISSUER;
573
+ if (!jwksUri) {
574
+ if (!jwksDisabledWarned) {
575
+ jwksDisabledWarned = true;
576
+ strapi.log.warn(
577
+ "[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."
578
+ );
579
+ }
580
+ return null;
581
+ }
582
+ try {
583
+ const jwks = getJwks(jwksUri);
584
+ const { payload } = await jwtVerify(idToken, jwks, {
585
+ issuer: issuer || void 0,
586
+ audience: config2.OIDC_CLIENT_ID
587
+ });
588
+ return payload;
589
+ } catch (e) {
590
+ 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);
592
+ throw new OidcError("id_token_invalid", msg, e);
593
+ }
594
+ throw e;
595
+ }
596
+ }
508
597
  function configValidation() {
509
598
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
510
599
  const missing = REQUIRED_CONFIG_KEYS.filter((key) => !config2[key]);
@@ -518,11 +607,10 @@ async function oidcSignIn(ctx) {
518
607
  const { code_verifier: codeVerifier, code_challenge: codeChallenge } = await pkceChallenge();
519
608
  const state = randomBytes(32).toString("base64url");
520
609
  const nonce = randomBytes(32).toString("base64url");
521
- const isProduction = strapi.config.get("environment") === "production";
522
610
  const cookieOptions = {
523
611
  httpOnly: true,
524
612
  maxAge: 6e5,
525
- secure: isProduction && ctx.request.secure,
613
+ secure: shouldMarkSecure(strapi, ctx),
526
614
  sameSite: "lax"
527
615
  };
528
616
  ctx.cookies.set("oidc_code_verifier", codeVerifier, cookieOptions);
@@ -555,14 +643,16 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
555
643
  }
556
644
  const tokenData = await response.json();
557
645
  if (tokenData.id_token) {
646
+ const verifiedPayload = await verifyIdToken(tokenData.id_token, config2);
558
647
  try {
559
- const payloadB64 = tokenData.id_token.split(".")[1];
560
- const idTokenPayload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
648
+ const idTokenPayload = verifiedPayload ?? JSON.parse(
649
+ Buffer.from(tokenData.id_token.split(".")[1], "base64url").toString("utf8")
650
+ );
561
651
  if (idTokenPayload.nonce !== expectedNonce) {
562
652
  throw new OidcError("nonce_mismatch", errorMessages.NONCE_MISMATCH);
563
653
  }
564
654
  } catch (e) {
565
- if (e instanceof OidcError && e.kind === "nonce_mismatch") throw e;
655
+ if (e instanceof OidcError) throw e;
566
656
  throw new OidcError("id_token_parse_failed", errorMessages.ID_TOKEN_PARSE_FAILED, e);
567
657
  }
568
658
  }
@@ -708,6 +798,13 @@ async function handleUserAuthentication(userService, oauthService2, roleService2
708
798
  if (!email || !isValidEmail(email)) {
709
799
  throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
710
800
  }
801
+ if (config2.OIDC_REQUIRE_EMAIL_VERIFIED !== false) {
802
+ const emailVerified = userResponseData.email_verified;
803
+ const isVerified = emailVerified === true || emailVerified === "true";
804
+ if (!isVerified) {
805
+ throw new OidcError("email_not_verified", errorMessages.EMAIL_NOT_VERIFIED);
806
+ }
807
+ }
711
808
  await whitelistService2.checkWhitelistForEmail(email);
712
809
  const resolved = await resolveRoles(userResponseData, config2, roleService2);
713
810
  const { user, userCreated, rolesUpdated } = await ensureUser(
@@ -734,7 +831,7 @@ function classifyOidcError(e, userInfo) {
734
831
  const dispatch = OIDC_ERROR_DISPATCH[kind];
735
832
  const msg = e instanceof Error ? e.message : String(e);
736
833
  let params;
737
- if (kind === "id_token_parse_failed" || kind === "unknown") {
834
+ if (kind === "id_token_parse_failed" || kind === "id_token_invalid" || kind === "unknown") {
738
835
  params = { error: msg };
739
836
  } else if (kind === "user_creation_failed" && userInfo?.email) {
740
837
  params = { email: userInfo.email, error: msg };
@@ -829,8 +926,7 @@ async function oidcSignInCallback(ctx) {
829
926
  try {
830
927
  const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
831
928
  userInfo = exchangeResult.userInfo;
832
- const isProduction = strapi.config.get("environment") === "production";
833
- const secureFlag = isProduction && ctx.request.secure;
929
+ const secureFlag = shouldMarkSecure(strapi, ctx);
834
930
  ctx.cookies.set("oidc_access_token", exchangeResult.accessToken, {
835
931
  httpOnly: true,
836
932
  maxAge: 3e5,
@@ -1000,16 +1096,34 @@ async function register(ctx) {
1000
1096
  return;
1001
1097
  }
1002
1098
  const rawEmails = Array.isArray(email) ? email : email.split(",");
1003
- const emailList = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
1099
+ const normalized = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
1100
+ const rejectedEmails = [];
1101
+ const validEmails = [];
1102
+ for (const e of normalized) {
1103
+ if (isValidEmail(e)) {
1104
+ validEmails.push(e);
1105
+ } else {
1106
+ rejectedEmails.push(e);
1107
+ }
1108
+ }
1109
+ if (validEmails.length === 0) {
1110
+ ctx.status = 400;
1111
+ ctx.body = { error: "No valid email addresses supplied", rejectedEmails };
1112
+ return;
1113
+ }
1004
1114
  const whitelistService2 = getWhitelistService();
1005
- const matchedExistingUsersCount = await whitelistService2.countAdminUsersByEmails(emailList);
1006
- for (const singleEmail of emailList) {
1115
+ let acceptedCount = 0;
1116
+ let alreadyWhitelistedCount = 0;
1117
+ for (const singleEmail of validEmails) {
1007
1118
  const alreadyWhitelisted = await whitelistService2.hasUser(singleEmail);
1008
- if (!alreadyWhitelisted) {
1119
+ if (alreadyWhitelisted) {
1120
+ alreadyWhitelistedCount++;
1121
+ } else {
1009
1122
  await whitelistService2.registerUser(singleEmail);
1123
+ acceptedCount++;
1010
1124
  }
1011
1125
  }
1012
- ctx.body = { matchedExistingUsersCount };
1126
+ ctx.body = { acceptedCount, alreadyWhitelistedCount, rejectedEmails };
1013
1127
  }
1014
1128
  async function removeEmail(ctx) {
1015
1129
  const { email } = ctx.params;
@@ -1065,7 +1179,7 @@ async function syncUsers(ctx) {
1065
1179
  await whitelistService2.registerUser(email);
1066
1180
  }
1067
1181
  }
1068
- ctx.body = { matchedExistingUsersCount: 0 };
1182
+ ctx.body = {};
1069
1183
  }
1070
1184
  const whitelist = {
1071
1185
  info,
@@ -1086,6 +1200,8 @@ const AUDIT_ACTIONS = [
1086
1200
  "nonce_mismatch",
1087
1201
  "token_exchange_failed",
1088
1202
  "whitelist_rejected",
1203
+ "email_not_verified",
1204
+ "id_token_invalid",
1089
1205
  "logout",
1090
1206
  "session_expired",
1091
1207
  "user_created"
@@ -1285,6 +1401,22 @@ const controllers = {
1285
1401
  const rateLimitMap = /* @__PURE__ */ new Map();
1286
1402
  const RATE_LIMIT_WINDOW = 6e4;
1287
1403
  const MAX_REQUESTS = 1e3;
1404
+ const MAX_MAP_SIZE = 1e4;
1405
+ const PRUNE_THRESHOLD = 1e3;
1406
+ function pruneExpiredEntries(now) {
1407
+ const windowStart = now - RATE_LIMIT_WINDOW;
1408
+ for (const [key, stamps] of rateLimitMap) {
1409
+ if (stamps.length === 0 || stamps[stamps.length - 1] <= windowStart) {
1410
+ rateLimitMap.delete(key);
1411
+ }
1412
+ }
1413
+ }
1414
+ function evictOldestEntry() {
1415
+ const oldest = rateLimitMap.keys().next().value;
1416
+ if (oldest !== void 0) {
1417
+ rateLimitMap.delete(oldest);
1418
+ }
1419
+ }
1288
1420
  function getRateLimitKey(ctx) {
1289
1421
  const ip = getClientIp(ctx);
1290
1422
  const ua = ctx.request.header["user-agent"] ?? "";
@@ -1295,6 +1427,9 @@ function rateLimitMiddleware(ctx, next) {
1295
1427
  const key = getRateLimitKey(ctx);
1296
1428
  const now = Date.now();
1297
1429
  const windowStart = now - RATE_LIMIT_WINDOW;
1430
+ if (rateLimitMap.size > PRUNE_THRESHOLD) {
1431
+ pruneExpiredEntries(now);
1432
+ }
1298
1433
  const requestStamps = (rateLimitMap.get(key) ?? []).filter((ts) => ts > windowStart);
1299
1434
  if (requestStamps.length >= MAX_REQUESTS) {
1300
1435
  ctx.status = 429;
@@ -1302,6 +1437,9 @@ function rateLimitMiddleware(ctx, next) {
1302
1437
  return;
1303
1438
  }
1304
1439
  requestStamps.push(now);
1440
+ if (!rateLimitMap.has(key) && rateLimitMap.size >= MAX_MAP_SIZE) {
1441
+ evictOldestEntry();
1442
+ }
1305
1443
  rateLimitMap.set(key, requestStamps);
1306
1444
  return next();
1307
1445
  }
@@ -1345,7 +1483,7 @@ const routes = {
1345
1483
  config: { auth: false, middlewares: [rateLimitMiddleware] }
1346
1484
  },
1347
1485
  {
1348
- method: "GET",
1486
+ method: "POST",
1349
1487
  path: "/logout",
1350
1488
  handler: "oidc.logout",
1351
1489
  config: { auth: false }
@@ -1427,53 +1565,63 @@ const routes = {
1427
1565
  // API-token-authenticated routes for programmatic whitelist management.
1428
1566
  // Accessible at /strapi-plugin-oidc/... using a Strapi API token
1429
1567
  // (full-access or custom) in the Authorization: Bearer <token> header.
1568
+ // Custom tokens must be granted one or more of the semantic scopes below.
1430
1569
  "content-api": {
1431
1570
  type: "content-api",
1432
1571
  routes: [
1433
1572
  {
1434
1573
  method: "GET",
1435
1574
  path: "/whitelist",
1436
- handler: "whitelist.info"
1575
+ handler: "whitelist.info",
1576
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.read"] } }
1437
1577
  },
1438
1578
  {
1439
1579
  method: "POST",
1440
1580
  path: "/whitelist",
1441
- handler: "whitelist.register"
1581
+ handler: "whitelist.register",
1582
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.write"] } }
1442
1583
  },
1443
1584
  {
1444
1585
  method: "POST",
1445
1586
  path: "/whitelist/import",
1446
- handler: "whitelist.importUsers"
1587
+ handler: "whitelist.importUsers",
1588
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.write"] } }
1447
1589
  },
1448
1590
  {
1449
1591
  method: "DELETE",
1450
1592
  path: "/whitelist/:email",
1451
- handler: "whitelist.removeEmail"
1593
+ handler: "whitelist.removeEmail",
1594
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.delete"] } }
1452
1595
  },
1453
1596
  {
1454
1597
  method: "DELETE",
1455
1598
  path: "/whitelist",
1456
- handler: "whitelist.deleteAll"
1599
+ handler: "whitelist.deleteAll",
1600
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.delete"] } }
1457
1601
  },
1458
1602
  {
1459
1603
  method: "GET",
1460
1604
  path: "/whitelist/export",
1461
- handler: "whitelist.exportWhitelist"
1605
+ handler: "whitelist.exportWhitelist",
1606
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.read"] } }
1462
1607
  },
1463
1608
  {
1464
1609
  method: "GET",
1465
1610
  path: "/audit-logs",
1466
- handler: "auditLog.find"
1611
+ handler: "auditLog.find",
1612
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.read"] } }
1467
1613
  },
1468
1614
  {
1469
1615
  method: "GET",
1470
1616
  path: "/audit-logs/export",
1471
- handler: "auditLog.export"
1617
+ handler: "auditLog.export",
1618
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.read"] } }
1472
1619
  },
1473
1620
  {
1474
1621
  method: "DELETE",
1475
1622
  path: "/audit-logs",
1476
- handler: "auditLog.clearAll"
1623
+ handler: "auditLog.clearAll",
1624
+ config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.delete"] } }
1477
1625
  }
1478
1626
  ]
1479
1627
  }
@@ -1725,13 +1873,12 @@ function oauthService({ strapi: strapi2 }) {
1725
1873
  type: rememberMe ? "refresh" : "session"
1726
1874
  }
1727
1875
  );
1728
- const isProduction = strapi2.config.get("environment") === "production";
1729
1876
  const domain = strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain");
1730
1877
  const path = strapi2.config.get("admin.auth.cookie.path", "/admin");
1731
1878
  const sameSite = strapi2.config.get("admin.auth.cookie.sameSite", "lax");
1732
1879
  const cookieOptions = {
1733
1880
  httpOnly: true,
1734
- secure: isProduction && ctx.request.secure,
1881
+ secure: shouldMarkSecure(strapi2, ctx),
1735
1882
  overwrite: true,
1736
1883
  domain,
1737
1884
  path,
@@ -1850,14 +1997,6 @@ function whitelistService({ strapi: strapi2 }) {
1850
1997
  },
1851
1998
  async deleteAllUsers() {
1852
1999
  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
2000
  }
1862
2001
  };
1863
2002
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-oidc",
3
- "version": "1.7.6",
3
+ "version": "1.8.0",
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",
@@ -50,6 +50,7 @@
50
50
  "@strapi/icons": "^2.2.0",
51
51
  "@strapi/utils": "^5.41.1",
52
52
  "generate-password": "^1.7.1",
53
+ "jose": "^6.2.2",
53
54
  "lucide-react": "^1.8.0",
54
55
  "pkce-challenge": "^6.0.0",
55
56
  "react-intl": "^6.8.9"