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 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
- OIDC_USER_INFO_ENDPOINT: env('OIDC_USER_INFO_ENDPOINT'),
37
+ OIDC_USERINFO_ENDPOINT: env('OIDC_USERINFO_ENDPOINT'),
38
38
 
39
- // Optional — defaults shown
40
- OIDC_SCOPES: 'openid profile email',
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 in config
47
- REMEMBER_ME: false, // Persist session across browser restarts
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.
@@ -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
- OIDC_SCOPES: "openid profile email",
203
+ OIDC_SCOPE: "openid profile email",
165
204
  OIDC_AUTHORIZATION_ENDPOINT: "",
166
205
  OIDC_TOKEN_ENDPOINT: "",
167
- OIDC_USER_INFO_ENDPOINT: "",
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
- OIDC_LOGOUT_URL: "",
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
- ctx.cookies.set("oidc_authenticated", "", { ...options2, path: "/" });
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
- "OIDC_SCOPES",
277
+ "OIDC_SCOPE",
231
278
  "OIDC_TOKEN_ENDPOINT",
232
- "OIDC_USER_INFO_ENDPOINT",
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, OIDC_SCOPES, OIDC_AUTHORIZATION_ENDPOINT } = configValidation();
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", OIDC_SCOPES);
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.OIDC_USER_INFO_ENDPOINT, {
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
- return userResponse.json();
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(config2, params, oidcNonce ?? "");
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.OIDC_LOGOUT_URL;
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
- ctx.redirect(logoutUrl);
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",
@@ -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
- OIDC_SCOPES: "openid profile email",
197
+ OIDC_SCOPE: "openid profile email",
159
198
  OIDC_AUTHORIZATION_ENDPOINT: "",
160
199
  OIDC_TOKEN_ENDPOINT: "",
161
- OIDC_USER_INFO_ENDPOINT: "",
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
- OIDC_LOGOUT_URL: "",
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
- ctx.cookies.set("oidc_authenticated", "", { ...options2, path: "/" });
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
- "OIDC_SCOPES",
271
+ "OIDC_SCOPE",
225
272
  "OIDC_TOKEN_ENDPOINT",
226
- "OIDC_USER_INFO_ENDPOINT",
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, OIDC_SCOPES, OIDC_AUTHORIZATION_ENDPOINT } = configValidation();
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", OIDC_SCOPES);
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.OIDC_USER_INFO_ENDPOINT, {
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
- return userResponse.json();
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(config2, params, oidcNonce ?? "");
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.OIDC_LOGOUT_URL;
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
- ctx.redirect(logoutUrl);
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.4.3",
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",