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.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");
|
|
@@ -43,8 +44,44 @@ const getRoleService = () => strapi.plugin(PLUGIN_NAME).service("role");
|
|
|
43
44
|
const getWhitelistService = () => strapi.plugin(PLUGIN_NAME).service("whitelist");
|
|
44
45
|
const getAuditLogService = () => strapi.plugin(PLUGIN_NAME).service("auditLog");
|
|
45
46
|
const getAdminUserService = () => strapi.service("admin::user");
|
|
47
|
+
const DISCOVERY_TIMEOUT_MS = 5e3;
|
|
48
|
+
const FIELD_MAP = [
|
|
49
|
+
["issuer", "OIDC_ISSUER"],
|
|
50
|
+
["authorization_endpoint", "OIDC_AUTHORIZATION_ENDPOINT"],
|
|
51
|
+
["token_endpoint", "OIDC_TOKEN_ENDPOINT"],
|
|
52
|
+
["userinfo_endpoint", "OIDC_USERINFO_ENDPOINT"],
|
|
53
|
+
["end_session_endpoint", "OIDC_END_SESSION_ENDPOINT"],
|
|
54
|
+
["jwks_uri", "OIDC_JWKS_URI"]
|
|
55
|
+
];
|
|
56
|
+
async function applyDiscovery(strapi2) {
|
|
57
|
+
const config2 = strapi2.config.get("plugin::strapi-plugin-oidc");
|
|
58
|
+
const discoveryUrl = config2.OIDC_DISCOVERY_URL;
|
|
59
|
+
if (!discoveryUrl) return;
|
|
60
|
+
let doc;
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(discoveryUrl, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) });
|
|
63
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
64
|
+
doc = await res.json();
|
|
65
|
+
} catch (e) {
|
|
66
|
+
strapi2.log.error(
|
|
67
|
+
`[strapi-plugin-oidc] Failed to fetch OIDC discovery document from ${discoveryUrl}: ${e instanceof Error ? e.message : String(e)}`
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const updates = {};
|
|
72
|
+
for (const [docField, configKey] of FIELD_MAP) {
|
|
73
|
+
if (doc[docField]) {
|
|
74
|
+
updates[configKey] = doc[docField];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (Object.keys(updates).length > 0) {
|
|
78
|
+
strapi2.config.set("plugin::strapi-plugin-oidc", { ...config2, ...updates });
|
|
79
|
+
strapi2.log.info(`[strapi-plugin-oidc] Discovery applied: ${Object.keys(updates).join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
46
82
|
const AUTH_ROUTES = ["login", "register", "register-admin", "forgot-password", "reset-password"];
|
|
47
83
|
async function bootstrap({ strapi: strapi2 }) {
|
|
84
|
+
await applyDiscovery(strapi2);
|
|
48
85
|
const adminUrl = strapi2.config.get("admin.url", "/admin");
|
|
49
86
|
const tokenRefreshPath = `${adminUrl}/token/refresh`;
|
|
50
87
|
const enforceOidcMiddleware = async (ctx, next) => {
|
|
@@ -99,6 +136,16 @@ async function bootstrap({ strapi: strapi2 }) {
|
|
|
99
136
|
{ section: "plugins", displayName: "Update", uid: "update", pluginName: "strapi-plugin-oidc" }
|
|
100
137
|
];
|
|
101
138
|
await strapi2.admin.services.permission.actionProvider.registerMany(actions);
|
|
139
|
+
const contentApiScopeUids = [
|
|
140
|
+
"plugin::strapi-plugin-oidc.whitelist.read",
|
|
141
|
+
"plugin::strapi-plugin-oidc.whitelist.write",
|
|
142
|
+
"plugin::strapi-plugin-oidc.whitelist.delete",
|
|
143
|
+
"plugin::strapi-plugin-oidc.audit.read",
|
|
144
|
+
"plugin::strapi-plugin-oidc.audit.delete"
|
|
145
|
+
];
|
|
146
|
+
for (const uid of contentApiScopeUids) {
|
|
147
|
+
strapi2.contentAPI.permissions.providers.action.register(uid, { uid });
|
|
148
|
+
}
|
|
102
149
|
const enforceOIDCConfig = getEnforceOIDCConfig(strapi2);
|
|
103
150
|
if (enforceOIDCConfig !== null) {
|
|
104
151
|
try {
|
|
@@ -146,23 +193,29 @@ function destroy() {
|
|
|
146
193
|
const config = {
|
|
147
194
|
default: {
|
|
148
195
|
REMEMBER_ME: false,
|
|
196
|
+
OIDC_DISCOVERY_URL: "",
|
|
149
197
|
OIDC_REDIRECT_URI: "http://localhost:1337/strapi-plugin-oidc/oidc/callback",
|
|
150
198
|
OIDC_CLIENT_ID: "",
|
|
151
199
|
OIDC_CLIENT_SECRET: "",
|
|
152
200
|
OIDC_SCOPE: "openid profile email",
|
|
153
|
-
OIDC_AUTHORIZATION_ENDPOINT: "",
|
|
154
|
-
OIDC_TOKEN_ENDPOINT: "",
|
|
155
|
-
OIDC_USERINFO_ENDPOINT: "",
|
|
156
|
-
OIDC_GRANT_TYPE: "authorization_code",
|
|
157
201
|
OIDC_FAMILY_NAME_FIELD: "family_name",
|
|
158
202
|
OIDC_GIVEN_NAME_FIELD: "given_name",
|
|
159
|
-
OIDC_END_SESSION_ENDPOINT: "",
|
|
160
203
|
OIDC_SSO_BUTTON_TEXT: "Login via SSO",
|
|
161
204
|
OIDC_ENFORCE: null,
|
|
162
205
|
// null = use DB setting; true/false = override DB (useful for lockout recovery)
|
|
163
206
|
AUDIT_LOG_RETENTION_DAYS: 90,
|
|
164
207
|
OIDC_GROUP_FIELD: "groups",
|
|
165
|
-
OIDC_GROUP_ROLE_MAP: "{}"
|
|
208
|
+
OIDC_GROUP_ROLE_MAP: "{}",
|
|
209
|
+
OIDC_REQUIRE_EMAIL_VERIFIED: true,
|
|
210
|
+
OIDC_TRUSTED_IP_HEADER: "",
|
|
211
|
+
OIDC_FORCE_SECURE_COOKIES: false,
|
|
212
|
+
// Populated at bootstrap from OIDC_DISCOVERY_URL — not user-configurable directly
|
|
213
|
+
OIDC_AUTHORIZATION_ENDPOINT: "",
|
|
214
|
+
OIDC_TOKEN_ENDPOINT: "",
|
|
215
|
+
OIDC_USERINFO_ENDPOINT: "",
|
|
216
|
+
OIDC_END_SESSION_ENDPOINT: "",
|
|
217
|
+
OIDC_JWKS_URI: "",
|
|
218
|
+
OIDC_ISSUER: ""
|
|
166
219
|
},
|
|
167
220
|
validator() {
|
|
168
221
|
}
|
|
@@ -211,11 +264,20 @@ const contentTypes = {
|
|
|
211
264
|
whitelists,
|
|
212
265
|
"audit-log": auditLog$1
|
|
213
266
|
};
|
|
214
|
-
function
|
|
267
|
+
function shouldMarkSecure(strapi2, ctx) {
|
|
215
268
|
const isProduction = strapi2.config.get("environment") === "production";
|
|
269
|
+
if (!isProduction) return false;
|
|
270
|
+
const config2 = strapi2.config.get("plugin::strapi-plugin-oidc") ?? {};
|
|
271
|
+
if (config2.OIDC_FORCE_SECURE_COOKIES === true) return true;
|
|
272
|
+
if (ctx.request.secure) return true;
|
|
273
|
+
const proxyTrusted = ctx.app?.proxy === true;
|
|
274
|
+
if (proxyTrusted && ctx.get("x-forwarded-proto") === "https") return true;
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
function getExpiredCookieOptions(strapi2, ctx) {
|
|
216
278
|
return {
|
|
217
279
|
httpOnly: true,
|
|
218
|
-
secure:
|
|
280
|
+
secure: shouldMarkSecure(strapi2, ctx),
|
|
219
281
|
path: strapi2.config.get("admin.auth.cookie.path", "/admin"),
|
|
220
282
|
domain: strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain"),
|
|
221
283
|
sameSite: strapi2.config.get("admin.auth.cookie.sameSite", "lax"),
|
|
@@ -242,7 +304,9 @@ const errorCodes = {
|
|
|
242
304
|
NONCE_MISMATCH: "NONCE_MISMATCH",
|
|
243
305
|
ROLE_UPDATE_FAILED: "ROLE_UPDATE_FAILED",
|
|
244
306
|
USER_CREATION_FAILED: "USER_CREATION_FAILED",
|
|
245
|
-
WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED"
|
|
307
|
+
WHITELIST_CHECK_FAILED: "WHITELIST_CHECK_FAILED",
|
|
308
|
+
EMAIL_NOT_VERIFIED: "EMAIL_NOT_VERIFIED",
|
|
309
|
+
ID_TOKEN_INVALID: "ID_TOKEN_INVALID"
|
|
246
310
|
};
|
|
247
311
|
const ERROR_DETAIL_TEMPLATES = {
|
|
248
312
|
token_exchange_failed: "Token exchange failed with HTTP status {status}",
|
|
@@ -252,6 +316,8 @@ const ERROR_DETAIL_TEMPLATES = {
|
|
|
252
316
|
id_token_parse_failed: "ID token parse failed: {error}",
|
|
253
317
|
sign_in_unknown: "Unknown sign-in error: {error}",
|
|
254
318
|
invalid_email: "Invalid email address received from OIDC provider",
|
|
319
|
+
email_not_verified: "Email address has not been verified by the OIDC provider",
|
|
320
|
+
id_token_invalid: "ID token verification failed: {error}",
|
|
255
321
|
whitelist_not_present: "Email not present in whitelist",
|
|
256
322
|
session_manager_unsupported: "sessionManager is not supported. Please upgrade to Strapi v5.24.1 or later.",
|
|
257
323
|
missing_config: "Missing required config keys: {keys}"
|
|
@@ -271,6 +337,8 @@ const errorMessages = {
|
|
|
271
337
|
ID_TOKEN_PARSE_FAILED: "Failed to parse ID token",
|
|
272
338
|
NONCE_MISMATCH: "Nonce mismatch",
|
|
273
339
|
INVALID_EMAIL: "Invalid email address received from OIDC provider",
|
|
340
|
+
EMAIL_NOT_VERIFIED: "Email address has not been verified by the OIDC provider",
|
|
341
|
+
ID_TOKEN_INVALID: "ID token verification failed",
|
|
274
342
|
WHITELIST_NOT_PRESENT: "Not present in whitelist",
|
|
275
343
|
SESSION_MANAGER_UNSUPPORTED: "sessionManager is not supported. Please upgrade to Strapi v5.24.1 or later.",
|
|
276
344
|
MISSING_CONFIG: (keys) => `Missing required config keys: ${keys}`
|
|
@@ -337,7 +405,6 @@ const en = {
|
|
|
337
405
|
"auditlog.table.ip": "IP",
|
|
338
406
|
"auditlog.table.details": "Details",
|
|
339
407
|
"auditlog.table.empty": "No audit log entries",
|
|
340
|
-
"auditlog.loading": "Loading…",
|
|
341
408
|
"auditlog.clear": "Clear Logs",
|
|
342
409
|
"auditlog.clear.title": "Clear All Logs",
|
|
343
410
|
"auditlog.clear.description": "This will permanently delete all {count, plural, one {# audit log entry} other {# audit log entries}}. This action cannot be undone.",
|
|
@@ -368,6 +435,8 @@ const en = {
|
|
|
368
435
|
"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
436
|
"auditlog.action.token_exchange_failed": "The authorisation code could not be exchanged for tokens. The OIDC provider rejected the request.",
|
|
370
437
|
"auditlog.action.whitelist_rejected": "The user's email address is not on the whitelist. Access was denied.",
|
|
438
|
+
"auditlog.action.email_not_verified": "The OIDC provider did not confirm the user's email address as verified. Access was denied.",
|
|
439
|
+
"auditlog.action.id_token_invalid": "The ID token failed signature, issuer, audience, or expiry validation. Access was denied.",
|
|
371
440
|
"auth.page.authenticating.title": "Authenticating...",
|
|
372
441
|
"auth.page.authenticating.noscript.heading": "JavaScript Required",
|
|
373
442
|
"auth.page.authenticating.noscript.body": "JavaScript must be enabled for authentication to complete.",
|
|
@@ -390,7 +459,6 @@ const locales = Object.fromEntries(
|
|
|
390
459
|
return [code ?? "", mod.default];
|
|
391
460
|
})
|
|
392
461
|
);
|
|
393
|
-
Object.keys(locales).filter(Boolean);
|
|
394
462
|
const DEFAULT_LOCALE = "en";
|
|
395
463
|
function parseAcceptLanguage(header) {
|
|
396
464
|
return header.split(",").map((part) => {
|
|
@@ -477,40 +545,99 @@ const OIDC_ERROR_DISPATCH = {
|
|
|
477
545
|
code: errorCodes.TOKEN_EXCHANGE_FAILED,
|
|
478
546
|
key: "sign_in_unknown"
|
|
479
547
|
},
|
|
548
|
+
email_not_verified: {
|
|
549
|
+
action: "email_not_verified",
|
|
550
|
+
code: errorCodes.EMAIL_NOT_VERIFIED,
|
|
551
|
+
key: "email_not_verified"
|
|
552
|
+
},
|
|
553
|
+
id_token_invalid: {
|
|
554
|
+
action: "id_token_invalid",
|
|
555
|
+
code: errorCodes.ID_TOKEN_INVALID,
|
|
556
|
+
key: "id_token_invalid"
|
|
557
|
+
},
|
|
480
558
|
unknown: {
|
|
481
559
|
action: "login_failure",
|
|
482
560
|
code: errorCodes.TOKEN_EXCHANGE_FAILED,
|
|
483
561
|
key: "sign_in_unknown"
|
|
484
562
|
}
|
|
485
563
|
};
|
|
564
|
+
const TRUSTED_IP_HEADER = "cf-connecting-ip";
|
|
565
|
+
function getTrustedHeaderName() {
|
|
566
|
+
const config2 = strapi.config.get("plugin::strapi-plugin-oidc") ?? {};
|
|
567
|
+
const raw = config2.OIDC_TRUSTED_IP_HEADER;
|
|
568
|
+
if (typeof raw !== "string" || !raw) return void 0;
|
|
569
|
+
const normalized = raw.trim().toLowerCase();
|
|
570
|
+
return normalized === TRUSTED_IP_HEADER ? normalized : void 0;
|
|
571
|
+
}
|
|
486
572
|
function getClientIp(ctx) {
|
|
487
|
-
const
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
573
|
+
const proxyTrusted = ctx.app?.proxy === true;
|
|
574
|
+
if (proxyTrusted) {
|
|
575
|
+
const trustedHeader = getTrustedHeaderName();
|
|
576
|
+
if (trustedHeader) {
|
|
577
|
+
const value = ctx.get(trustedHeader);
|
|
578
|
+
if (value) return value.split(",")[0].trim();
|
|
579
|
+
}
|
|
580
|
+
const forwarded = ctx.request.ips;
|
|
581
|
+
if (forwarded && forwarded.length > 0) {
|
|
582
|
+
return forwarded[0];
|
|
583
|
+
}
|
|
498
584
|
}
|
|
499
585
|
return ctx.ip;
|
|
500
586
|
}
|
|
587
|
+
function toMessage(e) {
|
|
588
|
+
return e instanceof Error ? e.message : String(e);
|
|
589
|
+
}
|
|
501
590
|
const REQUIRED_CONFIG_KEYS = [
|
|
591
|
+
"OIDC_DISCOVERY_URL",
|
|
502
592
|
"OIDC_CLIENT_ID",
|
|
503
593
|
"OIDC_CLIENT_SECRET",
|
|
504
594
|
"OIDC_REDIRECT_URI",
|
|
505
595
|
"OIDC_SCOPE",
|
|
506
|
-
"OIDC_TOKEN_ENDPOINT",
|
|
507
|
-
"OIDC_USERINFO_ENDPOINT",
|
|
508
|
-
"OIDC_GRANT_TYPE",
|
|
509
596
|
"OIDC_FAMILY_NAME_FIELD",
|
|
510
597
|
"OIDC_GIVEN_NAME_FIELD",
|
|
598
|
+
// Populated at bootstrap from OIDC_DISCOVERY_URL — checked here as a runtime safety net
|
|
599
|
+
"OIDC_TOKEN_ENDPOINT",
|
|
600
|
+
"OIDC_USERINFO_ENDPOINT",
|
|
511
601
|
"OIDC_AUTHORIZATION_ENDPOINT"
|
|
512
602
|
];
|
|
513
|
-
const LOGOUT_USERINFO_TIMEOUT_MS =
|
|
603
|
+
const LOGOUT_USERINFO_TIMEOUT_MS = 1500;
|
|
604
|
+
const jwksCache = /* @__PURE__ */ new Map();
|
|
605
|
+
let jwksDisabledWarned = false;
|
|
606
|
+
function getJwks(uri) {
|
|
607
|
+
let jwks = jwksCache.get(uri);
|
|
608
|
+
if (!jwks) {
|
|
609
|
+
jwks = jose.createRemoteJWKSet(new URL(uri));
|
|
610
|
+
jwksCache.set(uri, jwks);
|
|
611
|
+
}
|
|
612
|
+
return jwks;
|
|
613
|
+
}
|
|
614
|
+
async function verifyIdToken(idToken, config2) {
|
|
615
|
+
const jwksUri = config2.OIDC_JWKS_URI;
|
|
616
|
+
const issuer = config2.OIDC_ISSUER;
|
|
617
|
+
if (!jwksUri) {
|
|
618
|
+
if (!jwksDisabledWarned) {
|
|
619
|
+
jwksDisabledWarned = true;
|
|
620
|
+
strapi.log.warn(
|
|
621
|
+
"[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."
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const jwks = getJwks(jwksUri);
|
|
628
|
+
const { payload } = await jose.jwtVerify(idToken, jwks, {
|
|
629
|
+
issuer: issuer || void 0,
|
|
630
|
+
audience: config2.OIDC_CLIENT_ID
|
|
631
|
+
});
|
|
632
|
+
return payload;
|
|
633
|
+
} catch (e) {
|
|
634
|
+
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) {
|
|
635
|
+
const msg = toMessage(e);
|
|
636
|
+
throw new OidcError("id_token_invalid", msg, e);
|
|
637
|
+
}
|
|
638
|
+
throw e;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
514
641
|
function configValidation() {
|
|
515
642
|
const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
|
|
516
643
|
const missing = REQUIRED_CONFIG_KEYS.filter((key) => !config2[key]);
|
|
@@ -524,11 +651,10 @@ async function oidcSignIn(ctx) {
|
|
|
524
651
|
const { code_verifier: codeVerifier, code_challenge: codeChallenge } = await pkceChallenge__default.default();
|
|
525
652
|
const state = node_crypto.randomBytes(32).toString("base64url");
|
|
526
653
|
const nonce = node_crypto.randomBytes(32).toString("base64url");
|
|
527
|
-
const isProduction = strapi.config.get("environment") === "production";
|
|
528
654
|
const cookieOptions = {
|
|
529
655
|
httpOnly: true,
|
|
530
656
|
maxAge: 6e5,
|
|
531
|
-
secure:
|
|
657
|
+
secure: shouldMarkSecure(strapi, ctx),
|
|
532
658
|
sameSite: "lax"
|
|
533
659
|
};
|
|
534
660
|
ctx.cookies.set("oidc_code_verifier", codeVerifier, cookieOptions);
|
|
@@ -561,14 +687,16 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
|
|
|
561
687
|
}
|
|
562
688
|
const tokenData = await response.json();
|
|
563
689
|
if (tokenData.id_token) {
|
|
690
|
+
const verifiedPayload = await verifyIdToken(tokenData.id_token, config2);
|
|
564
691
|
try {
|
|
565
|
-
const
|
|
566
|
-
|
|
692
|
+
const idTokenPayload = verifiedPayload ?? JSON.parse(
|
|
693
|
+
Buffer.from(tokenData.id_token.split(".")[1], "base64url").toString("utf8")
|
|
694
|
+
);
|
|
567
695
|
if (idTokenPayload.nonce !== expectedNonce) {
|
|
568
696
|
throw new OidcError("nonce_mismatch", errorMessages.NONCE_MISMATCH);
|
|
569
697
|
}
|
|
570
698
|
} catch (e) {
|
|
571
|
-
if (e instanceof OidcError
|
|
699
|
+
if (e instanceof OidcError) throw e;
|
|
572
700
|
throw new OidcError("id_token_parse_failed", errorMessages.ID_TOKEN_PARSE_FAILED, e);
|
|
573
701
|
}
|
|
574
702
|
}
|
|
@@ -695,7 +823,7 @@ async function ensureUser(userService, oauthService2, email, userResponseData, c
|
|
|
695
823
|
);
|
|
696
824
|
return { user, userCreated: true, rolesUpdated: true };
|
|
697
825
|
} catch (e) {
|
|
698
|
-
const msg =
|
|
826
|
+
const msg = toMessage(e);
|
|
699
827
|
throw new OidcError("user_creation_failed", msg, e);
|
|
700
828
|
}
|
|
701
829
|
}
|
|
@@ -714,6 +842,13 @@ async function handleUserAuthentication(userService, oauthService2, roleService2
|
|
|
714
842
|
if (!email || !isValidEmail(email)) {
|
|
715
843
|
throw new OidcError("invalid_email", errorMessages.INVALID_EMAIL);
|
|
716
844
|
}
|
|
845
|
+
if (config2.OIDC_REQUIRE_EMAIL_VERIFIED !== false) {
|
|
846
|
+
const emailVerified = userResponseData.email_verified;
|
|
847
|
+
const isVerified = emailVerified === true || emailVerified === "true";
|
|
848
|
+
if (!isVerified) {
|
|
849
|
+
throw new OidcError("email_not_verified", errorMessages.EMAIL_NOT_VERIFIED);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
717
852
|
await whitelistService2.checkWhitelistForEmail(email);
|
|
718
853
|
const resolved = await resolveRoles(userResponseData, config2, roleService2);
|
|
719
854
|
const { user, userCreated, rolesUpdated } = await ensureUser(
|
|
@@ -738,9 +873,9 @@ async function handleUserAuthentication(userService, oauthService2, roleService2
|
|
|
738
873
|
function classifyOidcError(e, userInfo) {
|
|
739
874
|
const kind = e instanceof OidcError ? e.kind : "unknown";
|
|
740
875
|
const dispatch = OIDC_ERROR_DISPATCH[kind];
|
|
741
|
-
const msg =
|
|
876
|
+
const msg = toMessage(e);
|
|
742
877
|
let params;
|
|
743
|
-
if (kind === "id_token_parse_failed" || kind === "unknown") {
|
|
878
|
+
if (kind === "id_token_parse_failed" || kind === "id_token_invalid" || kind === "unknown") {
|
|
744
879
|
params = { error: msg };
|
|
745
880
|
} else if (kind === "user_creation_failed" && userInfo?.email) {
|
|
746
881
|
params = { email: userInfo.email, error: msg };
|
|
@@ -787,7 +922,7 @@ async function logSuccessfulAuth(auditLog2, ctx, user, userCreated, rolesUpdated
|
|
|
787
922
|
}
|
|
788
923
|
async function handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx) {
|
|
789
924
|
const errorInfo = classifyOidcError(e, userInfo);
|
|
790
|
-
const message =
|
|
925
|
+
const message = toMessage(e);
|
|
791
926
|
await auditLog2.log({
|
|
792
927
|
action: errorInfo.action,
|
|
793
928
|
email: userInfo?.email,
|
|
@@ -828,15 +963,14 @@ async function oidcSignInCallback(ctx) {
|
|
|
828
963
|
client_id: config2.OIDC_CLIENT_ID,
|
|
829
964
|
client_secret: config2.OIDC_CLIENT_SECRET,
|
|
830
965
|
redirect_uri: config2.OIDC_REDIRECT_URI,
|
|
831
|
-
grant_type:
|
|
966
|
+
grant_type: "authorization_code",
|
|
832
967
|
code_verifier: codeVerifier ?? ""
|
|
833
968
|
});
|
|
834
969
|
let userInfo;
|
|
835
970
|
try {
|
|
836
971
|
const exchangeResult = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce ?? "");
|
|
837
972
|
userInfo = exchangeResult.userInfo;
|
|
838
|
-
const
|
|
839
|
-
const secureFlag = isProduction && ctx.request.secure;
|
|
973
|
+
const secureFlag = shouldMarkSecure(strapi, ctx);
|
|
840
974
|
ctx.cookies.set("oidc_access_token", exchangeResult.accessToken, {
|
|
841
975
|
httpOnly: true,
|
|
842
976
|
maxAge: 3e5,
|
|
@@ -873,13 +1007,13 @@ async function oidcSignInCallback(ctx) {
|
|
|
873
1007
|
await handleCallbackError(e, userInfo, auditLog2, oauthService2, ctx);
|
|
874
1008
|
}
|
|
875
1009
|
}
|
|
876
|
-
async function
|
|
1010
|
+
async function isProviderSessionExpired(userinfoEndpoint, accessToken) {
|
|
877
1011
|
try {
|
|
878
1012
|
const response = await fetch(userinfoEndpoint, {
|
|
879
1013
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
880
1014
|
signal: AbortSignal.timeout(LOGOUT_USERINFO_TIMEOUT_MS)
|
|
881
1015
|
});
|
|
882
|
-
return response.ok;
|
|
1016
|
+
return !response.ok;
|
|
883
1017
|
} catch {
|
|
884
1018
|
return false;
|
|
885
1019
|
}
|
|
@@ -899,14 +1033,14 @@ async function logout(ctx) {
|
|
|
899
1033
|
}
|
|
900
1034
|
const logAudit = (action) => userEmail ? auditLog2.log({ action, email: userEmail, ip: getClientIp(ctx) }) : Promise.resolve();
|
|
901
1035
|
if (logoutUrl && accessToken) {
|
|
902
|
-
const
|
|
903
|
-
if (
|
|
904
|
-
logAudit("
|
|
905
|
-
|
|
906
|
-
return ctx.redirect(logoutUrl);
|
|
1036
|
+
const expired = await isProviderSessionExpired(config2.OIDC_USERINFO_ENDPOINT, accessToken);
|
|
1037
|
+
if (expired) {
|
|
1038
|
+
await logAudit("session_expired");
|
|
1039
|
+
return ctx.redirect(loginUrl);
|
|
907
1040
|
}
|
|
908
|
-
|
|
909
|
-
|
|
1041
|
+
logAudit("logout").catch(() => {
|
|
1042
|
+
});
|
|
1043
|
+
return ctx.redirect(logoutUrl);
|
|
910
1044
|
}
|
|
911
1045
|
await logAudit("logout");
|
|
912
1046
|
ctx.redirect(logoutUrl || loginUrl);
|
|
@@ -1006,16 +1140,34 @@ async function register(ctx) {
|
|
|
1006
1140
|
return;
|
|
1007
1141
|
}
|
|
1008
1142
|
const rawEmails = Array.isArray(email) ? email : email.split(",");
|
|
1009
|
-
const
|
|
1143
|
+
const normalized = rawEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean);
|
|
1144
|
+
const rejectedEmails = [];
|
|
1145
|
+
const validEmails = [];
|
|
1146
|
+
for (const e of normalized) {
|
|
1147
|
+
if (isValidEmail(e)) {
|
|
1148
|
+
validEmails.push(e);
|
|
1149
|
+
} else {
|
|
1150
|
+
rejectedEmails.push(e);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (validEmails.length === 0) {
|
|
1154
|
+
ctx.status = 400;
|
|
1155
|
+
ctx.body = { error: "No valid email addresses supplied", rejectedEmails };
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1010
1158
|
const whitelistService2 = getWhitelistService();
|
|
1011
|
-
|
|
1012
|
-
|
|
1159
|
+
let acceptedCount = 0;
|
|
1160
|
+
let alreadyWhitelistedCount = 0;
|
|
1161
|
+
for (const singleEmail of validEmails) {
|
|
1013
1162
|
const alreadyWhitelisted = await whitelistService2.hasUser(singleEmail);
|
|
1014
|
-
if (
|
|
1163
|
+
if (alreadyWhitelisted) {
|
|
1164
|
+
alreadyWhitelistedCount++;
|
|
1165
|
+
} else {
|
|
1015
1166
|
await whitelistService2.registerUser(singleEmail);
|
|
1167
|
+
acceptedCount++;
|
|
1016
1168
|
}
|
|
1017
1169
|
}
|
|
1018
|
-
ctx.body = {
|
|
1170
|
+
ctx.body = { acceptedCount, alreadyWhitelistedCount, rejectedEmails };
|
|
1019
1171
|
}
|
|
1020
1172
|
async function removeEmail(ctx) {
|
|
1021
1173
|
const { email } = ctx.params;
|
|
@@ -1046,13 +1198,9 @@ async function importUsers(ctx) {
|
|
|
1046
1198
|
const whitelistService2 = getWhitelistService();
|
|
1047
1199
|
const existing = await whitelistService2.getUsers();
|
|
1048
1200
|
const existingEmails = new Set(existing.map((u) => u.email));
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
await whitelistService2.registerUser(email);
|
|
1053
|
-
importedCount++;
|
|
1054
|
-
}
|
|
1055
|
-
ctx.body = { importedCount };
|
|
1201
|
+
const toImport = deduped.filter((email) => !existingEmails.has(email));
|
|
1202
|
+
await Promise.all(toImport.map((email) => whitelistService2.registerUser(email)));
|
|
1203
|
+
ctx.body = { importedCount: toImport.length };
|
|
1056
1204
|
}
|
|
1057
1205
|
async function syncUsers(ctx) {
|
|
1058
1206
|
const { users: rawUsers } = ctx.request.body;
|
|
@@ -1061,17 +1209,11 @@ async function syncUsers(ctx) {
|
|
|
1061
1209
|
const currentUsers = await whitelistService2.getUsers();
|
|
1062
1210
|
const syncEmailSet = new Set(emails);
|
|
1063
1211
|
const currentUsersByEmail = new Map(currentUsers.map((u) => [u.email, u]));
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
}
|
|
1069
|
-
for (const email of emails) {
|
|
1070
|
-
if (!currentUsersByEmail.has(email)) {
|
|
1071
|
-
await whitelistService2.registerUser(email);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
ctx.body = { matchedExistingUsersCount: 0 };
|
|
1212
|
+
await Promise.all([
|
|
1213
|
+
...currentUsers.filter((u) => !syncEmailSet.has(u.email)).map((u) => whitelistService2.removeUser(u.email)),
|
|
1214
|
+
...emails.filter((email) => !currentUsersByEmail.has(email)).map((email) => whitelistService2.registerUser(email))
|
|
1215
|
+
]);
|
|
1216
|
+
ctx.body = {};
|
|
1075
1217
|
}
|
|
1076
1218
|
const whitelist = {
|
|
1077
1219
|
info,
|
|
@@ -1092,6 +1234,8 @@ const AUDIT_ACTIONS = [
|
|
|
1092
1234
|
"nonce_mismatch",
|
|
1093
1235
|
"token_exchange_failed",
|
|
1094
1236
|
"whitelist_rejected",
|
|
1237
|
+
"email_not_verified",
|
|
1238
|
+
"id_token_invalid",
|
|
1095
1239
|
"logout",
|
|
1096
1240
|
"session_expired",
|
|
1097
1241
|
"user_created"
|
|
@@ -1291,6 +1435,22 @@ const controllers = {
|
|
|
1291
1435
|
const rateLimitMap = /* @__PURE__ */ new Map();
|
|
1292
1436
|
const RATE_LIMIT_WINDOW = 6e4;
|
|
1293
1437
|
const MAX_REQUESTS = 1e3;
|
|
1438
|
+
const MAX_MAP_SIZE = 1e4;
|
|
1439
|
+
const PRUNE_THRESHOLD = 1e3;
|
|
1440
|
+
function pruneExpiredEntries(now) {
|
|
1441
|
+
const windowStart = now - RATE_LIMIT_WINDOW;
|
|
1442
|
+
for (const [key, stamps] of rateLimitMap) {
|
|
1443
|
+
if (stamps.length === 0 || stamps[stamps.length - 1] <= windowStart) {
|
|
1444
|
+
rateLimitMap.delete(key);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
function evictOldestEntry() {
|
|
1449
|
+
const oldest = rateLimitMap.keys().next().value;
|
|
1450
|
+
if (oldest !== void 0) {
|
|
1451
|
+
rateLimitMap.delete(oldest);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1294
1454
|
function getRateLimitKey(ctx) {
|
|
1295
1455
|
const ip = getClientIp(ctx);
|
|
1296
1456
|
const ua = ctx.request.header["user-agent"] ?? "";
|
|
@@ -1301,6 +1461,9 @@ function rateLimitMiddleware(ctx, next) {
|
|
|
1301
1461
|
const key = getRateLimitKey(ctx);
|
|
1302
1462
|
const now = Date.now();
|
|
1303
1463
|
const windowStart = now - RATE_LIMIT_WINDOW;
|
|
1464
|
+
if (rateLimitMap.size > PRUNE_THRESHOLD) {
|
|
1465
|
+
pruneExpiredEntries(now);
|
|
1466
|
+
}
|
|
1304
1467
|
const requestStamps = (rateLimitMap.get(key) ?? []).filter((ts) => ts > windowStart);
|
|
1305
1468
|
if (requestStamps.length >= MAX_REQUESTS) {
|
|
1306
1469
|
ctx.status = 429;
|
|
@@ -1308,6 +1471,9 @@ function rateLimitMiddleware(ctx, next) {
|
|
|
1308
1471
|
return;
|
|
1309
1472
|
}
|
|
1310
1473
|
requestStamps.push(now);
|
|
1474
|
+
if (!rateLimitMap.has(key) && rateLimitMap.size >= MAX_MAP_SIZE) {
|
|
1475
|
+
evictOldestEntry();
|
|
1476
|
+
}
|
|
1311
1477
|
rateLimitMap.set(key, requestStamps);
|
|
1312
1478
|
return next();
|
|
1313
1479
|
}
|
|
@@ -1356,6 +1522,12 @@ const routes = {
|
|
|
1356
1522
|
handler: "oidc.logout",
|
|
1357
1523
|
config: { auth: false }
|
|
1358
1524
|
},
|
|
1525
|
+
{
|
|
1526
|
+
method: "POST",
|
|
1527
|
+
path: "/logout",
|
|
1528
|
+
handler: "oidc.logout",
|
|
1529
|
+
config: { auth: false }
|
|
1530
|
+
},
|
|
1359
1531
|
{
|
|
1360
1532
|
method: "GET",
|
|
1361
1533
|
path: "/whitelist",
|
|
@@ -1433,53 +1605,63 @@ const routes = {
|
|
|
1433
1605
|
// API-token-authenticated routes for programmatic whitelist management.
|
|
1434
1606
|
// Accessible at /strapi-plugin-oidc/... using a Strapi API token
|
|
1435
1607
|
// (full-access or custom) in the Authorization: Bearer <token> header.
|
|
1608
|
+
// Custom tokens must be granted one or more of the semantic scopes below.
|
|
1436
1609
|
"content-api": {
|
|
1437
1610
|
type: "content-api",
|
|
1438
1611
|
routes: [
|
|
1439
1612
|
{
|
|
1440
1613
|
method: "GET",
|
|
1441
1614
|
path: "/whitelist",
|
|
1442
|
-
handler: "whitelist.info"
|
|
1615
|
+
handler: "whitelist.info",
|
|
1616
|
+
config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.read"] } }
|
|
1443
1617
|
},
|
|
1444
1618
|
{
|
|
1445
1619
|
method: "POST",
|
|
1446
1620
|
path: "/whitelist",
|
|
1447
|
-
handler: "whitelist.register"
|
|
1621
|
+
handler: "whitelist.register",
|
|
1622
|
+
config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.write"] } }
|
|
1448
1623
|
},
|
|
1449
1624
|
{
|
|
1450
1625
|
method: "POST",
|
|
1451
1626
|
path: "/whitelist/import",
|
|
1452
|
-
handler: "whitelist.importUsers"
|
|
1627
|
+
handler: "whitelist.importUsers",
|
|
1628
|
+
config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.write"] } }
|
|
1453
1629
|
},
|
|
1454
1630
|
{
|
|
1455
1631
|
method: "DELETE",
|
|
1456
1632
|
path: "/whitelist/:email",
|
|
1457
|
-
handler: "whitelist.removeEmail"
|
|
1633
|
+
handler: "whitelist.removeEmail",
|
|
1634
|
+
config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.delete"] } }
|
|
1458
1635
|
},
|
|
1459
1636
|
{
|
|
1460
1637
|
method: "DELETE",
|
|
1461
1638
|
path: "/whitelist",
|
|
1462
|
-
handler: "whitelist.deleteAll"
|
|
1639
|
+
handler: "whitelist.deleteAll",
|
|
1640
|
+
config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.delete"] } }
|
|
1463
1641
|
},
|
|
1464
1642
|
{
|
|
1465
1643
|
method: "GET",
|
|
1466
1644
|
path: "/whitelist/export",
|
|
1467
|
-
handler: "whitelist.exportWhitelist"
|
|
1645
|
+
handler: "whitelist.exportWhitelist",
|
|
1646
|
+
config: { auth: { scope: ["plugin::strapi-plugin-oidc.whitelist.read"] } }
|
|
1468
1647
|
},
|
|
1469
1648
|
{
|
|
1470
1649
|
method: "GET",
|
|
1471
1650
|
path: "/audit-logs",
|
|
1472
|
-
handler: "auditLog.find"
|
|
1651
|
+
handler: "auditLog.find",
|
|
1652
|
+
config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.read"] } }
|
|
1473
1653
|
},
|
|
1474
1654
|
{
|
|
1475
1655
|
method: "GET",
|
|
1476
1656
|
path: "/audit-logs/export",
|
|
1477
|
-
handler: "auditLog.export"
|
|
1657
|
+
handler: "auditLog.export",
|
|
1658
|
+
config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.read"] } }
|
|
1478
1659
|
},
|
|
1479
1660
|
{
|
|
1480
1661
|
method: "DELETE",
|
|
1481
1662
|
path: "/audit-logs",
|
|
1482
|
-
handler: "auditLog.clearAll"
|
|
1663
|
+
handler: "auditLog.clearAll",
|
|
1664
|
+
config: { auth: { scope: ["plugin::strapi-plugin-oidc.audit.delete"] } }
|
|
1483
1665
|
}
|
|
1484
1666
|
]
|
|
1485
1667
|
}
|
|
@@ -1648,9 +1830,10 @@ function oauthService({ strapi: strapi2 }) {
|
|
|
1648
1830
|
}
|
|
1649
1831
|
const modelDef = strapi2.getModel("admin::user");
|
|
1650
1832
|
const sanitizedEntity = await strapiUtils__default.default.sanitize.sanitizers.defaultSanitizeOutput(
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1833
|
+
{
|
|
1834
|
+
schema: modelDef,
|
|
1835
|
+
getModel: (uid2) => strapi2.getModel(uid2)
|
|
1836
|
+
},
|
|
1654
1837
|
user
|
|
1655
1838
|
);
|
|
1656
1839
|
eventHub?.emit(ENTRY_CREATE ?? "entry.create", {
|
|
@@ -1731,13 +1914,12 @@ function oauthService({ strapi: strapi2 }) {
|
|
|
1731
1914
|
type: rememberMe ? "refresh" : "session"
|
|
1732
1915
|
}
|
|
1733
1916
|
);
|
|
1734
|
-
const isProduction = strapi2.config.get("environment") === "production";
|
|
1735
1917
|
const domain = strapi2.config.get("admin.auth.cookie.domain") || strapi2.config.get("admin.auth.domain");
|
|
1736
1918
|
const path = strapi2.config.get("admin.auth.cookie.path", "/admin");
|
|
1737
1919
|
const sameSite = strapi2.config.get("admin.auth.cookie.sameSite", "lax");
|
|
1738
1920
|
const cookieOptions = {
|
|
1739
1921
|
httpOnly: true,
|
|
1740
|
-
secure:
|
|
1922
|
+
secure: shouldMarkSecure(strapi2, ctx),
|
|
1741
1923
|
overwrite: true,
|
|
1742
1924
|
domain,
|
|
1743
1925
|
path,
|
|
@@ -1856,14 +2038,6 @@ function whitelistService({ strapi: strapi2 }) {
|
|
|
1856
2038
|
},
|
|
1857
2039
|
async deleteAllUsers() {
|
|
1858
2040
|
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
2041
|
}
|
|
1868
2042
|
};
|
|
1869
2043
|
}
|