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