strapi-plugin-oidc 1.4.3 → 1.5.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 +22 -7
- package/dist/server/index.js +153 -14
- package/dist/server/index.mjs +153 -14
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -34,17 +34,29 @@ module.exports = ({ env }) => ({
|
|
|
34
34
|
OIDC_REDIRECT_URI: env('OIDC_REDIRECT_URI'), // https://your-strapi.com/strapi-plugin-oidc/oidc/callback
|
|
35
35
|
OIDC_AUTHORIZATION_ENDPOINT: env('OIDC_AUTHORIZATION_ENDPOINT'),
|
|
36
36
|
OIDC_TOKEN_ENDPOINT: env('OIDC_TOKEN_ENDPOINT'),
|
|
37
|
-
|
|
37
|
+
OIDC_USERINFO_ENDPOINT: env('OIDC_USERINFO_ENDPOINT'),
|
|
38
38
|
|
|
39
|
-
// Optional
|
|
40
|
-
|
|
39
|
+
// Optional
|
|
40
|
+
OIDC_SCOPE: 'openid profile email',
|
|
41
41
|
OIDC_GRANT_TYPE: 'authorization_code',
|
|
42
42
|
OIDC_FAMILY_NAME_FIELD: 'family_name',
|
|
43
43
|
OIDC_GIVEN_NAME_FIELD: 'given_name',
|
|
44
|
-
OIDC_LOGOUT_URL: '', // Provider logout URL; omit to redirect to Strapi login
|
|
45
44
|
OIDC_SSO_BUTTON_TEXT: 'Login via SSO',
|
|
46
|
-
OIDC_ENFORCE: null, // null = use Admin UI toggle; true/false = override
|
|
47
|
-
REMEMBER_ME: false,
|
|
45
|
+
OIDC_ENFORCE: null, // null = use Admin UI toggle; true/false = override
|
|
46
|
+
REMEMBER_ME: false,
|
|
47
|
+
|
|
48
|
+
// Optional — RP-Initiated Logout
|
|
49
|
+
// Redirects the browser to the provider's end-session page on logout.
|
|
50
|
+
// Both found in your provider's /.well-known/openid-configuration.
|
|
51
|
+
OIDC_END_SESSION_ENDPOINT: env('OIDC_END_SESSION_ENDPOINT', ''),
|
|
52
|
+
OIDC_POST_LOGOUT_REDIRECT_URI: env('OIDC_POST_LOGOUT_REDIRECT_URI', ''),
|
|
53
|
+
|
|
54
|
+
// Optional — Backchannel Logout
|
|
55
|
+
// Allows the provider to revoke Strapi sessions server-to-server.
|
|
56
|
+
// Set your provider's logout URI to: https://your-strapi.com/strapi-plugin-oidc/logout
|
|
57
|
+
// Both found in your provider's /.well-known/openid-configuration.
|
|
58
|
+
OIDC_ISSUER: env('OIDC_ISSUER', ''),
|
|
59
|
+
OIDC_JWKS_URI: env('OIDC_JWKS_URI', ''),
|
|
48
60
|
},
|
|
49
61
|
},
|
|
50
62
|
});
|
|
@@ -134,7 +146,6 @@ This plugin is a hard fork of [`strapi-plugin-sso`](https://github.com/yasudaclo
|
|
|
134
146
|
|
|
135
147
|
- Removed alternative SSO methods to simplify the plugin.
|
|
136
148
|
- Redesigned the Whitelist and Role management UI (switched to native Strapi cards, added pagination, etc.).
|
|
137
|
-
- Added an OIDC logout redirect URL.
|
|
138
149
|
- Added an option to "Enforce OIDC login" with an admin toggle (automatically disabled if the whitelist is empty).
|
|
139
150
|
- Migrated the testing framework to Vitest and added comprehensive test coverage for controllers and services.
|
|
140
151
|
- Cleaned up dead code and unused dependencies to improve maintainability.
|
|
@@ -146,4 +157,8 @@ This plugin is a hard fork of [`strapi-plugin-sso`](https://github.com/yasudaclo
|
|
|
146
157
|
- Bulk delete all entries with a confirmation dialog.
|
|
147
158
|
- Unsaved changes confirmation when navigating away from the settings page.
|
|
148
159
|
- Programmatic API for managing the whitelist via Strapi API tokens (list, register, import, delete, delete all).
|
|
160
|
+
- **RP-Initiated Logout** (OpenID Connect RP-Initiated Logout 1.0): on logout, Strapi redirects the browser to the provider's end-session endpoint with `id_token_hint` and `post_logout_redirect_uri`, cleanly terminating the SSO session. Configured via `OIDC_END_SESSION_ENDPOINT` and `OIDC_POST_LOGOUT_REDIRECT_URI`.
|
|
161
|
+
- **Backchannel Logout** (OIDC Back-Channel Logout 1.0): `POST /strapi-plugin-oidc/logout` accepts a signed logout token from the provider, validates it, and revokes the user's Strapi admin session — keeping Strapi in sync when a user logs out elsewhere. Configured via `OIDC_ISSUER` and `OIDC_JWKS_URI`.
|
|
162
|
+
- Renamed config keys to match OIDC discovery document field names: `OIDC_LOGOUT_URL` → `OIDC_END_SESSION_ENDPOINT`, `OIDC_USER_INFO_ENDPOINT` → `OIDC_USERINFO_ENDPOINT`, `OIDC_SCOPES` → `OIDC_SCOPE`.
|
|
163
|
+
- Security hardening: PKCE (`S256`), server-side `state` generation (CSRF protection), nonce validation (ID token replay prevention), `Authorization: Bearer` header for userinfo requests, generic error messages on callback failure.
|
|
149
164
|
- Added misc. quality of life improvements and bug fixes.
|
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
|
|
3
3
|
const node_crypto = require("node:crypto");
|
|
4
|
+
const jose = require("jose");
|
|
4
5
|
const pkceChallenge = require("pkce-challenge");
|
|
5
6
|
const strapiUtils = require("@strapi/utils");
|
|
6
7
|
const generator = require("generate-password");
|
|
@@ -24,6 +25,17 @@ function resolveEnforceOIDC(strapi2, dbValue) {
|
|
|
24
25
|
if (configValue !== null) return configValue;
|
|
25
26
|
return dbValue ?? false;
|
|
26
27
|
}
|
|
28
|
+
const revoked = /* @__PURE__ */ new Map();
|
|
29
|
+
const CLEANUP_DELAY_MS = 24 * 60 * 60 * 1e3;
|
|
30
|
+
function revokeUser(userId) {
|
|
31
|
+
revoked.set(userId, Date.now());
|
|
32
|
+
setTimeout(() => revoked.delete(userId), CLEANUP_DELAY_MS);
|
|
33
|
+
}
|
|
34
|
+
function isUserRevoked(userId, iat) {
|
|
35
|
+
const logoutTime = revoked.get(userId);
|
|
36
|
+
if (!logoutTime) return false;
|
|
37
|
+
return iat * 1e3 <= logoutTime;
|
|
38
|
+
}
|
|
27
39
|
async function bootstrap({ strapi: strapi2 }) {
|
|
28
40
|
const enforceOidcMiddleware = async (ctx, next) => {
|
|
29
41
|
const adminUrl = strapi2.config.get("admin.url", "/admin");
|
|
@@ -76,9 +88,36 @@ async function bootstrap({ strapi: strapi2 }) {
|
|
|
76
88
|
}
|
|
77
89
|
await next();
|
|
78
90
|
};
|
|
91
|
+
const denylistMiddleware = async (ctx, next) => {
|
|
92
|
+
const auth = ctx.request.headers.authorization;
|
|
93
|
+
if (auth?.startsWith("Bearer ")) {
|
|
94
|
+
try {
|
|
95
|
+
const payload = JSON.parse(
|
|
96
|
+
Buffer.from(auth.slice(7).split(".")[1], "base64url").toString()
|
|
97
|
+
);
|
|
98
|
+
if (payload.id && isUserRevoked(String(payload.id), payload.iat ?? 0)) {
|
|
99
|
+
ctx.status = 401;
|
|
100
|
+
ctx.body = {
|
|
101
|
+
data: null,
|
|
102
|
+
error: {
|
|
103
|
+
status: 401,
|
|
104
|
+
name: "UnauthorizedError",
|
|
105
|
+
message: "Session revoked",
|
|
106
|
+
details: {}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await next();
|
|
115
|
+
};
|
|
79
116
|
if (strapi2.server.app && Array.isArray(strapi2.server.app.middleware)) {
|
|
80
117
|
strapi2.server.app.middleware.unshift(enforceOidcMiddleware);
|
|
118
|
+
strapi2.server.app.middleware.unshift(denylistMiddleware);
|
|
81
119
|
} else {
|
|
120
|
+
strapi2.server.use(denylistMiddleware);
|
|
82
121
|
strapi2.server.use(enforceOidcMiddleware);
|
|
83
122
|
}
|
|
84
123
|
const actions = [
|
|
@@ -161,14 +200,20 @@ const config = {
|
|
|
161
200
|
OIDC_REDIRECT_URI: "http://localhost:1337/strapi-plugin-oidc/oidc/callback",
|
|
162
201
|
OIDC_CLIENT_ID: "",
|
|
163
202
|
OIDC_CLIENT_SECRET: "",
|
|
164
|
-
|
|
203
|
+
OIDC_SCOPE: "openid profile email",
|
|
165
204
|
OIDC_AUTHORIZATION_ENDPOINT: "",
|
|
166
205
|
OIDC_TOKEN_ENDPOINT: "",
|
|
167
|
-
|
|
206
|
+
OIDC_USERINFO_ENDPOINT: "",
|
|
168
207
|
OIDC_GRANT_TYPE: "authorization_code",
|
|
169
208
|
OIDC_FAMILY_NAME_FIELD: "family_name",
|
|
170
209
|
OIDC_GIVEN_NAME_FIELD: "given_name",
|
|
171
|
-
|
|
210
|
+
OIDC_END_SESSION_ENDPOINT: "",
|
|
211
|
+
OIDC_POST_LOGOUT_REDIRECT_URI: "",
|
|
212
|
+
// Where to land after the provider has logged the user out (RP-Initiated Logout)
|
|
213
|
+
OIDC_ISSUER: "",
|
|
214
|
+
// Provider issuer URL — used to validate iss claim in backchannel logout tokens
|
|
215
|
+
OIDC_JWKS_URI: "",
|
|
216
|
+
// Provider JWKS endpoint — required for backchannel logout token signature verification
|
|
172
217
|
OIDC_SSO_BUTTON_TEXT: "Login via SSO",
|
|
173
218
|
OIDC_ENFORCE: null
|
|
174
219
|
// null = use DB setting; true/false = override DB (useful for lockout recovery)
|
|
@@ -221,15 +266,17 @@ function getExpiredCookieOptions(strapi2, ctx) {
|
|
|
221
266
|
function clearAuthCookies(strapi2, ctx) {
|
|
222
267
|
const options2 = getExpiredCookieOptions(strapi2, ctx);
|
|
223
268
|
ctx.cookies.set("strapi_admin_refresh", "", options2);
|
|
224
|
-
|
|
269
|
+
const rootPathOptions = { ...options2, path: "/" };
|
|
270
|
+
ctx.cookies.set("oidc_authenticated", "", rootPathOptions);
|
|
271
|
+
ctx.cookies.set("oidc_id_token", "", rootPathOptions);
|
|
225
272
|
}
|
|
226
273
|
const REQUIRED_CONFIG_KEYS = [
|
|
227
274
|
"OIDC_CLIENT_ID",
|
|
228
275
|
"OIDC_CLIENT_SECRET",
|
|
229
276
|
"OIDC_REDIRECT_URI",
|
|
230
|
-
"
|
|
277
|
+
"OIDC_SCOPE",
|
|
231
278
|
"OIDC_TOKEN_ENDPOINT",
|
|
232
|
-
"
|
|
279
|
+
"OIDC_USERINFO_ENDPOINT",
|
|
233
280
|
"OIDC_GRANT_TYPE",
|
|
234
281
|
"OIDC_FAMILY_NAME_FIELD",
|
|
235
282
|
"OIDC_GIVEN_NAME_FIELD",
|
|
@@ -245,7 +292,7 @@ function configValidation() {
|
|
|
245
292
|
);
|
|
246
293
|
}
|
|
247
294
|
async function oidcSignIn(ctx) {
|
|
248
|
-
const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI,
|
|
295
|
+
const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI, OIDC_SCOPE, OIDC_AUTHORIZATION_ENDPOINT } = configValidation();
|
|
249
296
|
const { code_verifier: codeVerifier, code_challenge: codeChallenge } = await pkceChallenge__default.default();
|
|
250
297
|
const state = node_crypto.randomBytes(32).toString("base64url");
|
|
251
298
|
const nonce = node_crypto.randomBytes(32).toString("base64url");
|
|
@@ -264,7 +311,7 @@ async function oidcSignIn(ctx) {
|
|
|
264
311
|
params.append("response_type", "code");
|
|
265
312
|
params.append("client_id", OIDC_CLIENT_ID);
|
|
266
313
|
params.append("redirect_uri", OIDC_REDIRECT_URI);
|
|
267
|
-
params.append("scope",
|
|
314
|
+
params.append("scope", OIDC_SCOPE);
|
|
268
315
|
params.append("code_challenge", codeChallenge);
|
|
269
316
|
params.append("code_challenge_method", "S256");
|
|
270
317
|
params.append("state", state);
|
|
@@ -297,13 +344,14 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
|
|
|
297
344
|
throw new Error("Failed to parse ID token");
|
|
298
345
|
}
|
|
299
346
|
}
|
|
300
|
-
const userResponse = await fetch(config2.
|
|
347
|
+
const userResponse = await fetch(config2.OIDC_USERINFO_ENDPOINT, {
|
|
301
348
|
headers: { Authorization: `Bearer ${tokenData.access_token}` }
|
|
302
349
|
});
|
|
303
350
|
if (!userResponse.ok) {
|
|
304
351
|
throw new Error("Failed to fetch user info");
|
|
305
352
|
}
|
|
306
|
-
|
|
353
|
+
const userInfo = await userResponse.json();
|
|
354
|
+
return { userInfo, idToken: tokenData.id_token };
|
|
307
355
|
}
|
|
308
356
|
async function registerNewUser(userService, oauthService2, roleService2, email, userResponseData, whitelistUser, config2, ctx) {
|
|
309
357
|
let roles2 = [];
|
|
@@ -369,7 +417,11 @@ async function oidcSignInCallback(ctx) {
|
|
|
369
417
|
params.append("grant_type", config2.OIDC_GRANT_TYPE);
|
|
370
418
|
params.append("code_verifier", codeVerifier ?? "");
|
|
371
419
|
try {
|
|
372
|
-
const userResponseData = await exchangeTokenAndFetchUserInfo(
|
|
420
|
+
const { userInfo: userResponseData, idToken } = await exchangeTokenAndFetchUserInfo(
|
|
421
|
+
config2,
|
|
422
|
+
params,
|
|
423
|
+
oidcNonce ?? ""
|
|
424
|
+
);
|
|
373
425
|
const { activateUser, jwtToken } = await handleUserAuthentication(
|
|
374
426
|
userService,
|
|
375
427
|
oauthService2,
|
|
@@ -379,6 +431,15 @@ async function oidcSignInCallback(ctx) {
|
|
|
379
431
|
config2,
|
|
380
432
|
ctx
|
|
381
433
|
);
|
|
434
|
+
if (idToken) {
|
|
435
|
+
const isProduction = strapi.config.get("environment") === "production";
|
|
436
|
+
ctx.cookies.set("oidc_id_token", idToken, {
|
|
437
|
+
httpOnly: true,
|
|
438
|
+
secure: isProduction && ctx.request.secure,
|
|
439
|
+
path: "/",
|
|
440
|
+
sameSite: "lax"
|
|
441
|
+
});
|
|
442
|
+
}
|
|
382
443
|
const nonce = node_crypto.randomUUID();
|
|
383
444
|
const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
|
|
384
445
|
ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
|
|
@@ -390,20 +451,92 @@ async function oidcSignInCallback(ctx) {
|
|
|
390
451
|
}
|
|
391
452
|
async function logout(ctx) {
|
|
392
453
|
const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
|
|
393
|
-
const logoutUrl = config2.
|
|
454
|
+
const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
|
|
394
455
|
const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
|
|
456
|
+
const idToken = ctx.cookies.get("oidc_id_token");
|
|
395
457
|
clearAuthCookies(strapi, ctx);
|
|
396
458
|
if (logoutUrl && isOidcSession) {
|
|
397
|
-
|
|
459
|
+
const url = new URL(logoutUrl);
|
|
460
|
+
if (idToken) url.searchParams.set("id_token_hint", idToken);
|
|
461
|
+
if (config2.OIDC_POST_LOGOUT_REDIRECT_URI) {
|
|
462
|
+
url.searchParams.set("post_logout_redirect_uri", config2.OIDC_POST_LOGOUT_REDIRECT_URI);
|
|
463
|
+
}
|
|
464
|
+
ctx.redirect(url.toString());
|
|
398
465
|
} else {
|
|
399
466
|
const adminPanelUrl = strapi.config.get("admin.url", "/admin");
|
|
400
467
|
ctx.redirect(`${adminPanelUrl}/auth/login`);
|
|
401
468
|
}
|
|
402
469
|
}
|
|
470
|
+
const jwksCache = /* @__PURE__ */ new Map();
|
|
471
|
+
function getJWKS(uri) {
|
|
472
|
+
const cached = jwksCache.get(uri);
|
|
473
|
+
if (cached) return cached;
|
|
474
|
+
const jwks = jose.createRemoteJWKSet(new URL(uri));
|
|
475
|
+
jwksCache.set(uri, jwks);
|
|
476
|
+
return jwks;
|
|
477
|
+
}
|
|
478
|
+
async function backchannelLogout(ctx) {
|
|
479
|
+
const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
|
|
480
|
+
const logoutToken = ctx.request.body?.logout_token;
|
|
481
|
+
if (!logoutToken) {
|
|
482
|
+
ctx.status = 400;
|
|
483
|
+
ctx.body = { error: "Missing logout_token" };
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (!config2.OIDC_JWKS_URI || !config2.OIDC_ISSUER) {
|
|
487
|
+
ctx.status = 501;
|
|
488
|
+
ctx.body = {
|
|
489
|
+
error: "OIDC_JWKS_URI and OIDC_ISSUER must both be configured to enable backchannel logout"
|
|
490
|
+
};
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const JWKS = getJWKS(config2.OIDC_JWKS_URI);
|
|
495
|
+
const verifyOptions = {
|
|
496
|
+
issuer: config2.OIDC_ISSUER,
|
|
497
|
+
audience: config2.OIDC_CLIENT_ID || void 0
|
|
498
|
+
};
|
|
499
|
+
const { payload } = await jose.jwtVerify(logoutToken, JWKS, verifyOptions);
|
|
500
|
+
if ("nonce" in payload) {
|
|
501
|
+
ctx.status = 400;
|
|
502
|
+
ctx.body = { error: "logout_token must not contain nonce" };
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const events = payload.events;
|
|
506
|
+
if (!events?.["http://schemas.openid.net/event/backchannel-logout"]) {
|
|
507
|
+
ctx.status = 400;
|
|
508
|
+
ctx.body = { error: "logout_token missing backchannel-logout event" };
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (!payload.sub && !("sid" in payload)) {
|
|
512
|
+
ctx.status = 400;
|
|
513
|
+
ctx.body = { error: "logout_token must contain sub or sid" };
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (payload.sub) {
|
|
517
|
+
const userService = strapi.service("admin::user");
|
|
518
|
+
const user = await userService.findOneByEmail(payload.sub);
|
|
519
|
+
if (user) {
|
|
520
|
+
revokeUser(String(user.id));
|
|
521
|
+
const sessionManager = strapi.sessionManager;
|
|
522
|
+
if (sessionManager) {
|
|
523
|
+
await sessionManager("admin").invalidateRefreshToken(String(user.id));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
ctx.status = 200;
|
|
528
|
+
ctx.body = "";
|
|
529
|
+
} catch (e) {
|
|
530
|
+
strapi.log.error("Backchannel logout failed:", e);
|
|
531
|
+
ctx.status = 400;
|
|
532
|
+
ctx.body = { error: "Invalid logout_token" };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
403
535
|
const oidc = {
|
|
404
536
|
oidcSignIn,
|
|
405
537
|
oidcSignInCallback,
|
|
406
|
-
logout
|
|
538
|
+
logout,
|
|
539
|
+
backchannelLogout
|
|
407
540
|
};
|
|
408
541
|
async function find(ctx) {
|
|
409
542
|
const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
|
|
@@ -660,6 +793,12 @@ const routes = {
|
|
|
660
793
|
handler: "oidc.logout",
|
|
661
794
|
config: { auth: false }
|
|
662
795
|
},
|
|
796
|
+
{
|
|
797
|
+
method: "POST",
|
|
798
|
+
path: "/logout",
|
|
799
|
+
handler: "oidc.backchannelLogout",
|
|
800
|
+
config: { auth: false }
|
|
801
|
+
},
|
|
663
802
|
{
|
|
664
803
|
method: "GET",
|
|
665
804
|
path: "/whitelist",
|
package/dist/server/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID, randomBytes } from "node:crypto";
|
|
2
|
+
import { jwtVerify, createRemoteJWKSet } from "jose";
|
|
2
3
|
import pkceChallenge from "pkce-challenge";
|
|
3
4
|
import strapiUtils from "@strapi/utils";
|
|
4
5
|
import generator from "generate-password";
|
|
@@ -18,6 +19,17 @@ function resolveEnforceOIDC(strapi2, dbValue) {
|
|
|
18
19
|
if (configValue !== null) return configValue;
|
|
19
20
|
return dbValue ?? false;
|
|
20
21
|
}
|
|
22
|
+
const revoked = /* @__PURE__ */ new Map();
|
|
23
|
+
const CLEANUP_DELAY_MS = 24 * 60 * 60 * 1e3;
|
|
24
|
+
function revokeUser(userId) {
|
|
25
|
+
revoked.set(userId, Date.now());
|
|
26
|
+
setTimeout(() => revoked.delete(userId), CLEANUP_DELAY_MS);
|
|
27
|
+
}
|
|
28
|
+
function isUserRevoked(userId, iat) {
|
|
29
|
+
const logoutTime = revoked.get(userId);
|
|
30
|
+
if (!logoutTime) return false;
|
|
31
|
+
return iat * 1e3 <= logoutTime;
|
|
32
|
+
}
|
|
21
33
|
async function bootstrap({ strapi: strapi2 }) {
|
|
22
34
|
const enforceOidcMiddleware = async (ctx, next) => {
|
|
23
35
|
const adminUrl = strapi2.config.get("admin.url", "/admin");
|
|
@@ -70,9 +82,36 @@ async function bootstrap({ strapi: strapi2 }) {
|
|
|
70
82
|
}
|
|
71
83
|
await next();
|
|
72
84
|
};
|
|
85
|
+
const denylistMiddleware = async (ctx, next) => {
|
|
86
|
+
const auth = ctx.request.headers.authorization;
|
|
87
|
+
if (auth?.startsWith("Bearer ")) {
|
|
88
|
+
try {
|
|
89
|
+
const payload = JSON.parse(
|
|
90
|
+
Buffer.from(auth.slice(7).split(".")[1], "base64url").toString()
|
|
91
|
+
);
|
|
92
|
+
if (payload.id && isUserRevoked(String(payload.id), payload.iat ?? 0)) {
|
|
93
|
+
ctx.status = 401;
|
|
94
|
+
ctx.body = {
|
|
95
|
+
data: null,
|
|
96
|
+
error: {
|
|
97
|
+
status: 401,
|
|
98
|
+
name: "UnauthorizedError",
|
|
99
|
+
message: "Session revoked",
|
|
100
|
+
details: {}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await next();
|
|
109
|
+
};
|
|
73
110
|
if (strapi2.server.app && Array.isArray(strapi2.server.app.middleware)) {
|
|
74
111
|
strapi2.server.app.middleware.unshift(enforceOidcMiddleware);
|
|
112
|
+
strapi2.server.app.middleware.unshift(denylistMiddleware);
|
|
75
113
|
} else {
|
|
114
|
+
strapi2.server.use(denylistMiddleware);
|
|
76
115
|
strapi2.server.use(enforceOidcMiddleware);
|
|
77
116
|
}
|
|
78
117
|
const actions = [
|
|
@@ -155,14 +194,20 @@ const config = {
|
|
|
155
194
|
OIDC_REDIRECT_URI: "http://localhost:1337/strapi-plugin-oidc/oidc/callback",
|
|
156
195
|
OIDC_CLIENT_ID: "",
|
|
157
196
|
OIDC_CLIENT_SECRET: "",
|
|
158
|
-
|
|
197
|
+
OIDC_SCOPE: "openid profile email",
|
|
159
198
|
OIDC_AUTHORIZATION_ENDPOINT: "",
|
|
160
199
|
OIDC_TOKEN_ENDPOINT: "",
|
|
161
|
-
|
|
200
|
+
OIDC_USERINFO_ENDPOINT: "",
|
|
162
201
|
OIDC_GRANT_TYPE: "authorization_code",
|
|
163
202
|
OIDC_FAMILY_NAME_FIELD: "family_name",
|
|
164
203
|
OIDC_GIVEN_NAME_FIELD: "given_name",
|
|
165
|
-
|
|
204
|
+
OIDC_END_SESSION_ENDPOINT: "",
|
|
205
|
+
OIDC_POST_LOGOUT_REDIRECT_URI: "",
|
|
206
|
+
// Where to land after the provider has logged the user out (RP-Initiated Logout)
|
|
207
|
+
OIDC_ISSUER: "",
|
|
208
|
+
// Provider issuer URL — used to validate iss claim in backchannel logout tokens
|
|
209
|
+
OIDC_JWKS_URI: "",
|
|
210
|
+
// Provider JWKS endpoint — required for backchannel logout token signature verification
|
|
166
211
|
OIDC_SSO_BUTTON_TEXT: "Login via SSO",
|
|
167
212
|
OIDC_ENFORCE: null
|
|
168
213
|
// null = use DB setting; true/false = override DB (useful for lockout recovery)
|
|
@@ -215,15 +260,17 @@ function getExpiredCookieOptions(strapi2, ctx) {
|
|
|
215
260
|
function clearAuthCookies(strapi2, ctx) {
|
|
216
261
|
const options2 = getExpiredCookieOptions(strapi2, ctx);
|
|
217
262
|
ctx.cookies.set("strapi_admin_refresh", "", options2);
|
|
218
|
-
|
|
263
|
+
const rootPathOptions = { ...options2, path: "/" };
|
|
264
|
+
ctx.cookies.set("oidc_authenticated", "", rootPathOptions);
|
|
265
|
+
ctx.cookies.set("oidc_id_token", "", rootPathOptions);
|
|
219
266
|
}
|
|
220
267
|
const REQUIRED_CONFIG_KEYS = [
|
|
221
268
|
"OIDC_CLIENT_ID",
|
|
222
269
|
"OIDC_CLIENT_SECRET",
|
|
223
270
|
"OIDC_REDIRECT_URI",
|
|
224
|
-
"
|
|
271
|
+
"OIDC_SCOPE",
|
|
225
272
|
"OIDC_TOKEN_ENDPOINT",
|
|
226
|
-
"
|
|
273
|
+
"OIDC_USERINFO_ENDPOINT",
|
|
227
274
|
"OIDC_GRANT_TYPE",
|
|
228
275
|
"OIDC_FAMILY_NAME_FIELD",
|
|
229
276
|
"OIDC_GIVEN_NAME_FIELD",
|
|
@@ -239,7 +286,7 @@ function configValidation() {
|
|
|
239
286
|
);
|
|
240
287
|
}
|
|
241
288
|
async function oidcSignIn(ctx) {
|
|
242
|
-
const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI,
|
|
289
|
+
const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI, OIDC_SCOPE, OIDC_AUTHORIZATION_ENDPOINT } = configValidation();
|
|
243
290
|
const { code_verifier: codeVerifier, code_challenge: codeChallenge } = await pkceChallenge();
|
|
244
291
|
const state = randomBytes(32).toString("base64url");
|
|
245
292
|
const nonce = randomBytes(32).toString("base64url");
|
|
@@ -258,7 +305,7 @@ async function oidcSignIn(ctx) {
|
|
|
258
305
|
params.append("response_type", "code");
|
|
259
306
|
params.append("client_id", OIDC_CLIENT_ID);
|
|
260
307
|
params.append("redirect_uri", OIDC_REDIRECT_URI);
|
|
261
|
-
params.append("scope",
|
|
308
|
+
params.append("scope", OIDC_SCOPE);
|
|
262
309
|
params.append("code_challenge", codeChallenge);
|
|
263
310
|
params.append("code_challenge_method", "S256");
|
|
264
311
|
params.append("state", state);
|
|
@@ -291,13 +338,14 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
|
|
|
291
338
|
throw new Error("Failed to parse ID token");
|
|
292
339
|
}
|
|
293
340
|
}
|
|
294
|
-
const userResponse = await fetch(config2.
|
|
341
|
+
const userResponse = await fetch(config2.OIDC_USERINFO_ENDPOINT, {
|
|
295
342
|
headers: { Authorization: `Bearer ${tokenData.access_token}` }
|
|
296
343
|
});
|
|
297
344
|
if (!userResponse.ok) {
|
|
298
345
|
throw new Error("Failed to fetch user info");
|
|
299
346
|
}
|
|
300
|
-
|
|
347
|
+
const userInfo = await userResponse.json();
|
|
348
|
+
return { userInfo, idToken: tokenData.id_token };
|
|
301
349
|
}
|
|
302
350
|
async function registerNewUser(userService, oauthService2, roleService2, email, userResponseData, whitelistUser, config2, ctx) {
|
|
303
351
|
let roles2 = [];
|
|
@@ -363,7 +411,11 @@ async function oidcSignInCallback(ctx) {
|
|
|
363
411
|
params.append("grant_type", config2.OIDC_GRANT_TYPE);
|
|
364
412
|
params.append("code_verifier", codeVerifier ?? "");
|
|
365
413
|
try {
|
|
366
|
-
const userResponseData = await exchangeTokenAndFetchUserInfo(
|
|
414
|
+
const { userInfo: userResponseData, idToken } = await exchangeTokenAndFetchUserInfo(
|
|
415
|
+
config2,
|
|
416
|
+
params,
|
|
417
|
+
oidcNonce ?? ""
|
|
418
|
+
);
|
|
367
419
|
const { activateUser, jwtToken } = await handleUserAuthentication(
|
|
368
420
|
userService,
|
|
369
421
|
oauthService2,
|
|
@@ -373,6 +425,15 @@ async function oidcSignInCallback(ctx) {
|
|
|
373
425
|
config2,
|
|
374
426
|
ctx
|
|
375
427
|
);
|
|
428
|
+
if (idToken) {
|
|
429
|
+
const isProduction = strapi.config.get("environment") === "production";
|
|
430
|
+
ctx.cookies.set("oidc_id_token", idToken, {
|
|
431
|
+
httpOnly: true,
|
|
432
|
+
secure: isProduction && ctx.request.secure,
|
|
433
|
+
path: "/",
|
|
434
|
+
sameSite: "lax"
|
|
435
|
+
});
|
|
436
|
+
}
|
|
376
437
|
const nonce = randomUUID();
|
|
377
438
|
const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
|
|
378
439
|
ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
|
|
@@ -384,20 +445,92 @@ async function oidcSignInCallback(ctx) {
|
|
|
384
445
|
}
|
|
385
446
|
async function logout(ctx) {
|
|
386
447
|
const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
|
|
387
|
-
const logoutUrl = config2.
|
|
448
|
+
const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
|
|
388
449
|
const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
|
|
450
|
+
const idToken = ctx.cookies.get("oidc_id_token");
|
|
389
451
|
clearAuthCookies(strapi, ctx);
|
|
390
452
|
if (logoutUrl && isOidcSession) {
|
|
391
|
-
|
|
453
|
+
const url = new URL(logoutUrl);
|
|
454
|
+
if (idToken) url.searchParams.set("id_token_hint", idToken);
|
|
455
|
+
if (config2.OIDC_POST_LOGOUT_REDIRECT_URI) {
|
|
456
|
+
url.searchParams.set("post_logout_redirect_uri", config2.OIDC_POST_LOGOUT_REDIRECT_URI);
|
|
457
|
+
}
|
|
458
|
+
ctx.redirect(url.toString());
|
|
392
459
|
} else {
|
|
393
460
|
const adminPanelUrl = strapi.config.get("admin.url", "/admin");
|
|
394
461
|
ctx.redirect(`${adminPanelUrl}/auth/login`);
|
|
395
462
|
}
|
|
396
463
|
}
|
|
464
|
+
const jwksCache = /* @__PURE__ */ new Map();
|
|
465
|
+
function getJWKS(uri) {
|
|
466
|
+
const cached = jwksCache.get(uri);
|
|
467
|
+
if (cached) return cached;
|
|
468
|
+
const jwks = createRemoteJWKSet(new URL(uri));
|
|
469
|
+
jwksCache.set(uri, jwks);
|
|
470
|
+
return jwks;
|
|
471
|
+
}
|
|
472
|
+
async function backchannelLogout(ctx) {
|
|
473
|
+
const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
|
|
474
|
+
const logoutToken = ctx.request.body?.logout_token;
|
|
475
|
+
if (!logoutToken) {
|
|
476
|
+
ctx.status = 400;
|
|
477
|
+
ctx.body = { error: "Missing logout_token" };
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (!config2.OIDC_JWKS_URI || !config2.OIDC_ISSUER) {
|
|
481
|
+
ctx.status = 501;
|
|
482
|
+
ctx.body = {
|
|
483
|
+
error: "OIDC_JWKS_URI and OIDC_ISSUER must both be configured to enable backchannel logout"
|
|
484
|
+
};
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
const JWKS = getJWKS(config2.OIDC_JWKS_URI);
|
|
489
|
+
const verifyOptions = {
|
|
490
|
+
issuer: config2.OIDC_ISSUER,
|
|
491
|
+
audience: config2.OIDC_CLIENT_ID || void 0
|
|
492
|
+
};
|
|
493
|
+
const { payload } = await jwtVerify(logoutToken, JWKS, verifyOptions);
|
|
494
|
+
if ("nonce" in payload) {
|
|
495
|
+
ctx.status = 400;
|
|
496
|
+
ctx.body = { error: "logout_token must not contain nonce" };
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const events = payload.events;
|
|
500
|
+
if (!events?.["http://schemas.openid.net/event/backchannel-logout"]) {
|
|
501
|
+
ctx.status = 400;
|
|
502
|
+
ctx.body = { error: "logout_token missing backchannel-logout event" };
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (!payload.sub && !("sid" in payload)) {
|
|
506
|
+
ctx.status = 400;
|
|
507
|
+
ctx.body = { error: "logout_token must contain sub or sid" };
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (payload.sub) {
|
|
511
|
+
const userService = strapi.service("admin::user");
|
|
512
|
+
const user = await userService.findOneByEmail(payload.sub);
|
|
513
|
+
if (user) {
|
|
514
|
+
revokeUser(String(user.id));
|
|
515
|
+
const sessionManager = strapi.sessionManager;
|
|
516
|
+
if (sessionManager) {
|
|
517
|
+
await sessionManager("admin").invalidateRefreshToken(String(user.id));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
ctx.status = 200;
|
|
522
|
+
ctx.body = "";
|
|
523
|
+
} catch (e) {
|
|
524
|
+
strapi.log.error("Backchannel logout failed:", e);
|
|
525
|
+
ctx.status = 400;
|
|
526
|
+
ctx.body = { error: "Invalid logout_token" };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
397
529
|
const oidc = {
|
|
398
530
|
oidcSignIn,
|
|
399
531
|
oidcSignInCallback,
|
|
400
|
-
logout
|
|
532
|
+
logout,
|
|
533
|
+
backchannelLogout
|
|
401
534
|
};
|
|
402
535
|
async function find(ctx) {
|
|
403
536
|
const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
|
|
@@ -654,6 +787,12 @@ const routes = {
|
|
|
654
787
|
handler: "oidc.logout",
|
|
655
788
|
config: { auth: false }
|
|
656
789
|
},
|
|
790
|
+
{
|
|
791
|
+
method: "POST",
|
|
792
|
+
path: "/logout",
|
|
793
|
+
handler: "oidc.backchannelLogout",
|
|
794
|
+
config: { auth: false }
|
|
795
|
+
},
|
|
657
796
|
{
|
|
658
797
|
method: "GET",
|
|
659
798
|
path: "/whitelist",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strapi-plugin-oidc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
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
|
"pkce-challenge": "^6.0.0",
|
|
54
55
|
"react-intl": "^6.8.9"
|
|
55
56
|
},
|
|
@@ -81,6 +82,8 @@
|
|
|
81
82
|
"@eslint/eslintrc": "^3.3.5",
|
|
82
83
|
"@eslint/js": "^10.0.1",
|
|
83
84
|
"@strapi/sdk-plugin": "^6.0.1",
|
|
85
|
+
"@strapi/types": "^5.41.1",
|
|
86
|
+
"@types/koa": "^2.16.4",
|
|
84
87
|
"@types/node": "^25.5.2",
|
|
85
88
|
"@types/supertest": "^7.2.0",
|
|
86
89
|
"@vitest/coverage-v8": "^4.1.2",
|