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 +77 -41
- package/dist/admin/{index-pieFAsgM.mjs → index-8YTLPV3h.mjs} +3 -1
- package/dist/admin/{index-DRJ6Ty2J.mjs → index-B-K4X_N9.mjs} +8 -2
- package/dist/admin/{index-CrnGXADu.js → index-BSgVStns.js} +8 -2
- package/dist/admin/{index-D2rlNx1-.js → index-CgG_mHzZ.js} +3 -1
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +188 -49
- package/dist/server/index.mjs +188 -49
- package/package.json +2 -1
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
|
|
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
|
|
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
|
|
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** —
|
|
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
|
|
96
|
+
**Whitelist** — Restrict access to specific email addresses. When empty, any authenticated OIDC user gets an account. Supports:
|
|
76
97
|
|
|
77
|
-
-
|
|
78
|
-
- JSON import / export
|
|
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** —
|
|
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
|
|
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
|
-
|
|
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. **
|
|
122
|
-
2. **No
|
|
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
|
|
127
|
-
- **Existing users with
|
|
128
|
-
- **Existing users
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
260
|
-
| `
|
|
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
|
-
-
|
|
284
|
-
- Redesigned
|
|
285
|
-
-
|
|
286
|
-
-
|
|
287
|
-
- Migrated to Vitest with
|
|
288
|
-
- Config variable names aligned with OIDC discovery document field names
|
|
289
|
-
-
|
|
290
|
-
- Whitelist
|
|
291
|
-
- Hardened OIDC flow: server-generated state and nonce, PKCE, Bearer token auth for userinfo,
|
|
292
|
-
- Audit log: records all auth events to a queryable table with
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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"
|
package/dist/admin/index.js
CHANGED
package/dist/admin/index.mjs
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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:
|
|
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
|
|
566
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1012
|
-
|
|
1121
|
+
let acceptedCount = 0;
|
|
1122
|
+
let alreadyWhitelistedCount = 0;
|
|
1123
|
+
for (const singleEmail of validEmails) {
|
|
1013
1124
|
const alreadyWhitelisted = await whitelistService2.hasUser(singleEmail);
|
|
1014
|
-
if (
|
|
1125
|
+
if (alreadyWhitelisted) {
|
|
1126
|
+
alreadyWhitelistedCount++;
|
|
1127
|
+
} else {
|
|
1015
1128
|
await whitelistService2.registerUser(singleEmail);
|
|
1129
|
+
acceptedCount++;
|
|
1016
1130
|
}
|
|
1017
1131
|
}
|
|
1018
|
-
ctx.body = {
|
|
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 = {
|
|
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: "
|
|
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:
|
|
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
|
}
|
package/dist/server/index.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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:
|
|
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
|
|
560
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1006
|
-
|
|
1115
|
+
let acceptedCount = 0;
|
|
1116
|
+
let alreadyWhitelistedCount = 0;
|
|
1117
|
+
for (const singleEmail of validEmails) {
|
|
1007
1118
|
const alreadyWhitelisted = await whitelistService2.hasUser(singleEmail);
|
|
1008
|
-
if (
|
|
1119
|
+
if (alreadyWhitelisted) {
|
|
1120
|
+
alreadyWhitelistedCount++;
|
|
1121
|
+
} else {
|
|
1009
1122
|
await whitelistService2.registerUser(singleEmail);
|
|
1123
|
+
acceptedCount++;
|
|
1010
1124
|
}
|
|
1011
1125
|
}
|
|
1012
|
-
ctx.body = {
|
|
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 = {
|
|
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: "
|
|
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:
|
|
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.
|
|
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"
|