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.
package/README.md CHANGED
@@ -31,32 +31,45 @@ module.exports = ({ env }) => ({
31
31
  'strapi-plugin-oidc': {
32
32
  enabled: true,
33
33
  config: {
34
- // Required — find these in your provider's OIDC discovery document
34
+ // Required
35
+ OIDC_DISCOVERY_URL: env('OIDC_DISCOVERY_URL'), // https://your-provider/.well-known/openid-configuration
35
36
  OIDC_CLIENT_ID: env('OIDC_CLIENT_ID'),
36
37
  OIDC_CLIENT_SECRET: env('OIDC_CLIENT_SECRET'),
37
38
  OIDC_REDIRECT_URI: env('OIDC_REDIRECT_URI'), // https://your-strapi.com/strapi-plugin-oidc/oidc/callback
38
- OIDC_AUTHORIZATION_ENDPOINT: env('OIDC_AUTHORIZATION_ENDPOINT'),
39
- OIDC_TOKEN_ENDPOINT: env('OIDC_TOKEN_ENDPOINT'),
40
- OIDC_USERINFO_ENDPOINT: env('OIDC_USERINFO_ENDPOINT'),
41
39
 
42
40
  // Optional — defaults shown
43
- OIDC_SCOPE: 'openid profile email',
44
- OIDC_GRANT_TYPE: 'authorization_code',
41
+ OIDC_SCOPE: 'openid profile email', // space-separated scopes
45
42
  OIDC_FAMILY_NAME_FIELD: 'family_name',
46
43
  OIDC_GIVEN_NAME_FIELD: 'given_name',
47
- OIDC_END_SESSION_ENDPOINT: '', // Provider end-session URL for RP-initiated logout
48
44
  OIDC_SSO_BUTTON_TEXT: 'Login via SSO',
49
45
  OIDC_ENFORCE: null, // null = use Admin UI toggle; true/false = override in config
50
46
  REMEMBER_ME: false, // Persist session across browser restarts
51
47
  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
48
  OIDC_GROUP_FIELD: 'groups', // OIDC claim field containing group membership
53
49
  OIDC_GROUP_ROLE_MAP: '{}', // JSON map of group names to Strapi role names
50
+ OIDC_REQUIRE_EMAIL_VERIFIED: true, // Reject logins when provider does not report email_verified=true (set false to disable)
51
+ OIDC_TRUSTED_IP_HEADER: '', // Optional: 'cf-connecting-ip' for Cloudflare; read only when Strapi trusts the proxy
52
+ OIDC_FORCE_SECURE_COOKIES: false, // Set true when behind a trusted HTTPS proxy that Strapi can't auto-detect
54
53
  },
55
54
  },
56
55
  });
57
56
  ```
58
57
 
59
- All required values come from your provider's OIDC discovery document, typically available at `https://your-provider/.well-known/openid-configuration`.
58
+ `OIDC_DISCOVERY_URL` is the URL of your provider's OpenID Connect discovery document (`/.well-known/openid-configuration`). The plugin fetches it at startup and automatically configures all endpoints, JWKS URI, and issuer.
59
+
60
+ ### Security features
61
+
62
+ - **ID token verification** — Enabled automatically when the discovery document includes a `jwks_uri`. Validates signature, issuer, audience, and expiry via [`jose`](https://github.com/panva/jose)
63
+ - **Email verification** — `OIDC_REQUIRE_EMAIL_VERIFIED: true` (default) rejects unverified emails
64
+ - **CSRF protection** — OIDC state/nonce and POST-only logout endpoint
65
+ - **Rate limiting** — 1 000 req/min per IP+UA (in-process; use a reverse-proxy-level limiter for multi-node)
66
+ - **Secure cookies** — `OIDC_FORCE_SECURE_COOKIES` ensures cookies are marked Secure
67
+
68
+ ### Client IP attribution and reverse proxies
69
+
70
+ 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.
71
+
72
+ Set `OIDC_TRUSTED_IP_HEADER: 'cf-connecting-ip'` when behind Cloudflare. The header is only honoured when `server.proxy: true` is set.
60
73
 
61
74
  ## Login
62
75
 
@@ -64,27 +77,27 @@ Navigate to `/strapi-plugin-oidc/oidc` to start the OIDC flow, or click the **Lo
64
77
 
65
78
  ## Logout
66
79
 
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.
80
+ When the discovery document includes an `end_session_endpoint`, 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.
81
+
82
+ The logout endpoint is `POST /strapi-plugin-oidc/logout`. Using POST instead of GET prevents CSRF-forced-logout attacks.
68
83
 
69
84
  ## Admin Settings
70
85
 
71
86
  Manage the plugin under **Settings → OIDC Plugin**.
72
87
 
73
- **Default Roles** — Select which Strapi admin role(s) are assigned to new users on first login.
88
+ **Default Roles** — Strapi admin role(s) assigned to new users on first login.
74
89
 
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:
90
+ **Whitelist** — Restrict access to specific email addresses. When empty, any authenticated OIDC user gets an account. Supports:
76
91
 
77
- - Adding individual emails with optional role overrides
78
- - JSON import / export (see [format](#import-format) below)
92
+ - Individual emails with optional role overrides
93
+ - JSON import / export
79
94
  - Bulk delete with confirmation
80
- - Unsaved changes are held in the UI until **Save Changes** is clicked
81
95
 
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).
96
+ **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
97
 
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.
98
+ **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
99
 
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.
100
+ 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
101
 
89
102
  ## Group-to-Role Mapping
90
103
 
@@ -118,18 +131,26 @@ Role names are the **display names** shown in **Settings → Roles** (e.g. `"Edi
118
131
 
119
132
  ### Role assignment precedence
120
133
 
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)
134
+ 1. **OIDC groups match `OIDC_GROUP_ROLE_MAP`** → mapped Strapi roles
135
+ 2. **No match or no mapping** → default OIDC roles (new users only)
123
136
 
124
137
  ### Role updates on subsequent logins
125
138
 
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.
139
+ - **New users** — Roles assigned on first login (group-mapped or default).
140
+ - **Existing users with group match** — Roles updated to reflect current mapping.
141
+ - **Existing users without group match** — Roles left unchanged. Manually-assigned roles are never overwritten.
129
142
 
130
143
  ## Whitelist API
131
144
 
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>`.
145
+ The whitelist can be managed programmatically using a Strapi **API token**. All endpoints are under `/api/strapi-plugin-oidc` and require `Authorization: Bearer <token>`.
146
+
147
+ **Full-access tokens** can call all routes. **Custom tokens** must be granted one of the following scopes (Settings → API Tokens → Custom → plugin permissions):
148
+
149
+ | Scope | Routes |
150
+ | --------------------------------------------- | ----------------------------------------------- |
151
+ | `plugin::strapi-plugin-oidc.whitelist.read` | `GET /whitelist`, `GET /whitelist/export` |
152
+ | `plugin::strapi-plugin-oidc.whitelist.write` | `POST /whitelist`, `POST /whitelist/import` |
153
+ | `plugin::strapi-plugin-oidc.whitelist.delete` | `DELETE /whitelist`, `DELETE /whitelist/:email` |
133
154
 
134
155
  | Method | Path | Description |
135
156
  | -------- | ------------------------------------------ | ---------------------- |
@@ -185,7 +206,14 @@ curl -X DELETE -H "Authorization: Bearer <token>" \
185
206
 
186
207
  ## Audit Log API
187
208
 
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>`.
209
+ Audit log entries can be fetched programmatically using a Strapi **API token**. Endpoints are under `/api/strapi-plugin-oidc` and require `Authorization: Bearer <token>`.
210
+
211
+ **Full-access tokens** can call all routes. **Custom tokens** must be granted one of the following scopes:
212
+
213
+ | Scope | Routes |
214
+ | ----------------------------------------- | ------------------------------------------- |
215
+ | `plugin::strapi-plugin-oidc.audit.read` | `GET /audit-logs`, `GET /audit-logs/export` |
216
+ | `plugin::strapi-plugin-oidc.audit.delete` | `DELETE /audit-logs` |
189
217
 
190
218
  | Method | Path | Description |
191
219
  | -------- | ------------------------------------------- | ----------------------------------- |
@@ -246,18 +274,20 @@ curl -H "Authorization: Bearer <token>" -G \
246
274
 
247
275
  ### Recorded actions
248
276
 
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 |
277
+ | Action | Trigger |
278
+ | ----------------------- | ----------------------------------------------------------------- |
279
+ | `login_success` | Successful OIDC authentication |
280
+ | `user_created` | New Strapi admin user created during login |
281
+ | `login_failure` | Unexpected error during the OIDC login flow |
282
+ | `missing_code` | Callback received without an authorisation code |
283
+ | `state_mismatch` | CSRF state cookie does not match callback parameter |
284
+ | `nonce_mismatch` | ID token nonce does not match the session nonce |
285
+ | `token_exchange_failed` | Provider returned an error during token exchange |
286
+ | `whitelist_rejected` | Email not present in the active whitelist |
287
+ | `email_not_verified` | Provider did not report `email_verified=true` |
288
+ | `id_token_invalid` | ID token failed signature, issuer, audience, or expiry validation |
289
+ | `logout` | User logged out via `/logout` |
290
+ | `session_expired` | Logout attempted but provider session already stale |
261
291
 
262
292
  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
293
 
@@ -280,16 +310,16 @@ This plugin is a hard fork of [`strapi-plugin-sso`](https://github.com/yasudaclo
280
310
 
281
311
  ### Changes from the original:
282
312
 
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.
313
+ - OIDC-only (removed other SSO methods)
314
+ - Redesigned whitelist and role management UI using native Strapi components
315
+ - OIDC enforcement via admin toggle or `OIDC_ENFORCE` config
316
+ - RP-initiated logout with smart session detection
317
+ - Migrated to Vitest with e2e coverage
318
+ - Config variable names aligned with OIDC discovery document field names
319
+ - **Login via SSO** button always injected; text configurable via `OIDC_SSO_BUTTON_TEXT`
320
+ - Whitelist REST API with JSON import/export, bulk delete, delete by email
321
+ - Hardened OIDC flow: server-generated state and nonce, PKCE, Bearer token auth for userinfo, generic error messages on failure
322
+ - Audit log: records all auth events to a queryable table with UI, JSON/NDJSON export, and REST API
293
323
 
294
324
  ## License
295
325
 
@@ -1,4 +1,7 @@
1
- import { useRef, useEffect } from "react";
1
+ import React, { useRef, useEffect, useState } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { jsx } from "react/jsx-runtime";
4
+ import { DesignSystemProvider, darkTheme, lightTheme, Loader } from "@strapi/design-system";
2
5
  const __variableDynamicImportRuntimeHelper = (glob, path, segs) => {
3
6
  const v = glob[path];
4
7
  if (v) {
@@ -32,6 +35,42 @@ function Initializer({ setPlugin }) {
32
35
  }, []);
33
36
  return null;
34
37
  }
38
+ const LOGOUT_EVENT = "strapi-oidc:logout";
39
+ function Overlay({ bg }) {
40
+ const [active, setActive] = useState(false);
41
+ useEffect(() => {
42
+ const handler = () => setActive(true);
43
+ window.addEventListener(LOGOUT_EVENT, handler);
44
+ return () => window.removeEventListener(LOGOUT_EVENT, handler);
45
+ }, []);
46
+ if (!active) return null;
47
+ return /* @__PURE__ */ jsx(
48
+ "div",
49
+ {
50
+ style: {
51
+ position: "fixed",
52
+ inset: 0,
53
+ zIndex: 1e4,
54
+ display: "flex",
55
+ alignItems: "center",
56
+ justifyContent: "center",
57
+ background: bg,
58
+ backdropFilter: "blur(2px)"
59
+ },
60
+ children: /* @__PURE__ */ jsx(Loader, {})
61
+ }
62
+ );
63
+ }
64
+ function resolveTheme() {
65
+ const stored = window.localStorage.getItem("STRAPI_THEME") ?? "system";
66
+ const isDark = stored === "dark" || stored === "system" && (window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false);
67
+ return isDark ? darkTheme : lightTheme;
68
+ }
69
+ function LogoutOverlay() {
70
+ const theme = resolveTheme();
71
+ const bg = theme === darkTheme ? "rgba(24, 24, 38, 0.85)" : "rgba(255, 255, 255, 0.85)";
72
+ return /* @__PURE__ */ jsx(DesignSystemProvider, { theme, children: /* @__PURE__ */ jsx(Overlay, { bg }) });
73
+ }
35
74
  const en = {
36
75
  "global.plugins.strapi-plugin-oidc": "OIDC Plugin",
37
76
  "page.title": "Configure OIDC default role(s) and access controls.",
@@ -94,7 +133,6 @@ const en = {
94
133
  "auditlog.table.ip": "IP",
95
134
  "auditlog.table.details": "Details",
96
135
  "auditlog.table.empty": "No audit log entries",
97
- "auditlog.loading": "Loading…",
98
136
  "auditlog.clear": "Clear Logs",
99
137
  "auditlog.clear.title": "Clear All Logs",
100
138
  "auditlog.clear.description": "This will permanently delete all {count, plural, one {# audit log entry} other {# audit log entries}}. This action cannot be undone.",
@@ -125,6 +163,8 @@ const en = {
125
163
  "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
164
  "auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
127
165
  "auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
166
+ "auditlog.action.email_not_verified": "The OIDC provider did not confirm the user's email address as verified. Access was denied.",
167
+ "auditlog.action.id_token_invalid": "The ID token failed signature, issuer, audience, or expiry validation. Access was denied.",
128
168
  "auth.page.authenticating.title": "Authenticating...",
129
169
  "auth.page.authenticating.noscript.heading": "JavaScript Required",
130
170
  "auth.page.authenticating.noscript.body": "JavaScript must be enabled for authentication to complete.",
@@ -165,7 +205,7 @@ const index = {
165
205
  defaultMessage: "Configuration"
166
206
  },
167
207
  Component: async () => {
168
- return await import("./index-pieFAsgM.mjs");
208
+ return await import("./index-BqWd-Iiq.mjs");
169
209
  },
170
210
  permissions: [{ action: "plugin::strapi-plugin-oidc.read", subject: null }]
171
211
  }
@@ -177,6 +217,9 @@ const index = {
177
217
  });
178
218
  },
179
219
  bootstrap() {
220
+ const overlayContainer = document.createElement("div");
221
+ document.body.appendChild(overlayContainer);
222
+ createRoot(overlayContainer).render(React.createElement(LogoutOverlay));
180
223
  const defaultButtonText = t("login.sso");
181
224
  const isAuthRoute = (path) => /\/auth\/(login|register|forgot-password|reset-password)/.test(path);
182
225
  let ssoButtonInjected = false;
@@ -236,6 +279,7 @@ const index = {
236
279
  if (!isAuthRoute(window.location.pathname)) return;
237
280
  injectSSOButton(buttonText);
238
281
  if (enforced) removeEnforcedElements();
282
+ if (ssoButtonInjected && !enforced) loginObserver?.disconnect();
239
283
  };
240
284
  tick();
241
285
  loginObserver = new MutationObserver(tick);
@@ -256,23 +300,27 @@ const index = {
256
300
  }
257
301
  };
258
302
  applySettings();
303
+ if (window.__strapiOidcFetchPatched) return;
304
+ window.__strapiOidcFetchPatched = true;
259
305
  const originalFetch = window.fetch;
260
306
  window.fetch = async (...args) => {
261
307
  const url = typeof args[0] === "string" ? args[0] : args[0].url;
262
308
  const isLogout = url?.endsWith("/admin/logout") && args[1]?.method?.toUpperCase() === "POST";
263
- const response = await originalFetch(...args);
264
- if (isLogout && response.ok) {
309
+ if (isLogout) {
310
+ window.dispatchEvent(new CustomEvent(LOGOUT_EVENT));
265
311
  window.localStorage.removeItem("jwtToken");
266
312
  window.localStorage.removeItem("isLoggedIn");
267
313
  window.sessionStorage.removeItem("jwtToken");
268
314
  window.sessionStorage.removeItem("isLoggedIn");
269
315
  document.cookie = "jwtToken=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
270
316
  document.cookie = "jwtToken=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/admin";
317
+ originalFetch(...args).catch(() => {
318
+ });
271
319
  window.location.href = "/strapi-plugin-oidc/logout";
272
320
  return new Promise(() => {
273
321
  });
274
322
  }
275
- return response;
323
+ return originalFetch(...args);
276
324
  };
277
325
  },
278
326
  async registerTrads({ locales }) {