strapi-plugin-oidc 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,17 +34,31 @@ 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
39
  // Optional — defaults shown
40
- OIDC_SCOPES: 'openid profile email',
40
+ OIDC_SCOPE: 'openid profile email',
41
41
  OIDC_GRANT_TYPE: 'authorization_code',
42
- OIDC_FAMILY_NAME_FIELD: 'family_name',
43
- OIDC_GIVEN_NAME_FIELD: 'given_name',
44
- OIDC_LOGOUT_URL: '', // Provider logout URL; omit to redirect to Strapi login
42
+ OIDC_FAMILY_NAME_FIELD: 'family_name', // claim name for the user's last name
43
+ OIDC_GIVEN_NAME_FIELD: 'given_name', // claim name for the user's first name
45
44
  OIDC_SSO_BUTTON_TEXT: 'Login via SSO',
46
45
  OIDC_ENFORCE: null, // null = use Admin UI toggle; true/false = override in config
47
46
  REMEMBER_ME: false, // Persist session across browser restarts
47
+
48
+ // Optional — RP-Initiated Logout (GET /strapi-plugin-oidc/logout)
49
+ // Set OIDC_END_SESSION_ENDPOINT to redirect OIDC sessions to your provider's
50
+ // end-session page on logout. Without it, logout only clears the local session.
51
+ // Find this URL in your provider's /.well-known/openid-configuration as end_session_endpoint.
52
+ OIDC_END_SESSION_ENDPOINT: env('OIDC_END_SESSION_ENDPOINT', ''),
53
+ OIDC_POST_LOGOUT_REDIRECT_URI: env('OIDC_POST_LOGOUT_REDIRECT_URI', ''), // where to land after the provider logs the user out
54
+
55
+ // Optional — Backchannel Logout (POST /strapi-plugin-oidc/logout)
56
+ // When configured, your provider can notify Strapi when a user logs out elsewhere
57
+ // (e.g. from another app or directly from the provider UI), revoking their Strapi session.
58
+ // Set the logout URI in your provider to: https://your-strapi.com/strapi-plugin-oidc/logout
59
+ // Both values are required together — find them in your provider's /.well-known/openid-configuration.
60
+ OIDC_ISSUER: env('OIDC_ISSUER', ''), // validates the iss claim; required for backchannel logout
61
+ OIDC_JWKS_URI: env('OIDC_JWKS_URI', ''), // verifies logout token signatures; required for backchannel logout
48
62
  },
49
63
  },
50
64
  });
@@ -92,7 +92,8 @@ const en = {
92
92
  "unsaved.title": "Unsaved Changes",
93
93
  "unsaved.description": "You have unsaved changes that will be lost if you leave. Do you want to continue?",
94
94
  "unsaved.confirm": "Leave",
95
- "unsaved.cancel": "Stay"
95
+ "unsaved.cancel": "Stay",
96
+ "whitelist.table.roles.default": "(Default)"
96
97
  };
97
98
  function getTrad(id) {
98
99
  const pluginIdWithId = `${pluginId}.${id}`;
@@ -123,7 +124,7 @@ const index = {
123
124
  defaultMessage: "Configuration"
124
125
  },
125
126
  Component: async () => {
126
- return await Promise.resolve().then(() => require("./index-BnFRueNv.js"));
127
+ return await Promise.resolve().then(() => require("./index-DWm8oOJF.js"));
127
128
  },
128
129
  permissions: [{ action: "plugin::strapi-plugin-oidc.read", subject: null }]
129
130
  }
@@ -7,7 +7,7 @@ const react = require("react");
7
7
  const designSystem = require("@strapi/design-system");
8
8
  const icons = require("@strapi/icons");
9
9
  const reactIntl = require("react-intl");
10
- const index = require("./index-RMgj1w0B.js");
10
+ const index = require("./index-CqgjBmJ5.js");
11
11
  const styled = require("styled-components");
12
12
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
13
13
  const styled__default = /* @__PURE__ */ _interopDefault(styled);
@@ -231,7 +231,7 @@ function Whitelist({
231
231
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { children: user.email }),
232
232
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { children: userRolesNames ? /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, alignItems: "center", children: [
233
233
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: userRolesNames }),
234
- isDefault && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral500", children: "(Default)" })
234
+ isDefault && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral500", children: formatMessage(index.getTrad("whitelist.table.roles.default")) })
235
235
  ] }) : "-" }),
236
236
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { children: /* @__PURE__ */ jsxRuntime.jsx(LocalizedDate, { date: user.createdAt }) }),
237
237
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { style: { paddingRight: 0 }, children: /* @__PURE__ */ jsxRuntime.jsx(
@@ -5,7 +5,7 @@ import { useState, useRef, useCallback, useEffect, memo } from "react";
5
5
  import { Typography, Flex, Box, MultiSelect, MultiSelectOption, Button, Dialog, Field, Divider, Thead, Tr, Th, Tbody, Td, IconButton, Pagination, PreviousLink, PageLink, NextLink, Table, Alert } from "@strapi/design-system";
6
6
  import { Download, Upload, Trash, WarningCircle, Plus, Information } from "@strapi/icons";
7
7
  import { useIntl } from "react-intl";
8
- import { g as getTrad } from "./index-ZRaWWFUL.mjs";
8
+ import { g as getTrad } from "./index-Dzf0bJC1.mjs";
9
9
  import styled from "styled-components";
10
10
  function Role({ oidcRoles, roles, onChangeRole }) {
11
11
  const { formatMessage } = useIntl();
@@ -227,7 +227,7 @@ function Whitelist({
227
227
  /* @__PURE__ */ jsx(Td, { children: user.email }),
228
228
  /* @__PURE__ */ jsx(Td, { children: userRolesNames ? /* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", children: [
229
229
  /* @__PURE__ */ jsx("span", { children: userRolesNames }),
230
- isDefault && /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral500", children: "(Default)" })
230
+ isDefault && /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral500", children: formatMessage(getTrad("whitelist.table.roles.default")) })
231
231
  ] }) : "-" }),
232
232
  /* @__PURE__ */ jsx(Td, { children: /* @__PURE__ */ jsx(LocalizedDate, { date: user.createdAt }) }),
233
233
  /* @__PURE__ */ jsx(Td, { style: { paddingRight: 0 }, children: /* @__PURE__ */ jsx(
@@ -91,7 +91,8 @@ const en = {
91
91
  "unsaved.title": "Unsaved Changes",
92
92
  "unsaved.description": "You have unsaved changes that will be lost if you leave. Do you want to continue?",
93
93
  "unsaved.confirm": "Leave",
94
- "unsaved.cancel": "Stay"
94
+ "unsaved.cancel": "Stay",
95
+ "whitelist.table.roles.default": "(Default)"
95
96
  };
96
97
  function getTrad(id) {
97
98
  const pluginIdWithId = `${pluginId}.${id}`;
@@ -122,7 +123,7 @@ const index = {
122
123
  defaultMessage: "Configuration"
123
124
  },
124
125
  Component: async () => {
125
- return await import("./index-CY4s-vtv.mjs");
126
+ return await import("./index-Dz3WlTpL.mjs");
126
127
  },
127
128
  permissions: [{ action: "plugin::strapi-plugin-oidc.read", subject: null }]
128
129
  }
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
- const index = require("./index-RMgj1w0B.js");
3
+ const index = require("./index-CqgjBmJ5.js");
4
4
  exports.default = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "./index-ZRaWWFUL.mjs";
1
+ import { i } from "./index-Dzf0bJC1.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -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");
@@ -161,14 +162,20 @@ const config = {
161
162
  OIDC_REDIRECT_URI: "http://localhost:1337/strapi-plugin-oidc/oidc/callback",
162
163
  OIDC_CLIENT_ID: "",
163
164
  OIDC_CLIENT_SECRET: "",
164
- OIDC_SCOPES: "openid profile email",
165
+ OIDC_SCOPE: "openid profile email",
165
166
  OIDC_AUTHORIZATION_ENDPOINT: "",
166
167
  OIDC_TOKEN_ENDPOINT: "",
167
- OIDC_USER_INFO_ENDPOINT: "",
168
+ OIDC_USERINFO_ENDPOINT: "",
168
169
  OIDC_GRANT_TYPE: "authorization_code",
169
170
  OIDC_FAMILY_NAME_FIELD: "family_name",
170
171
  OIDC_GIVEN_NAME_FIELD: "given_name",
171
- OIDC_LOGOUT_URL: "",
172
+ OIDC_END_SESSION_ENDPOINT: "",
173
+ OIDC_POST_LOGOUT_REDIRECT_URI: "",
174
+ // Where to land after the provider has logged the user out (RP-Initiated Logout)
175
+ OIDC_ISSUER: "",
176
+ // Provider issuer URL — used to validate iss claim in backchannel logout tokens
177
+ OIDC_JWKS_URI: "",
178
+ // Provider JWKS endpoint — required for backchannel logout token signature verification
172
179
  OIDC_SSO_BUTTON_TEXT: "Login via SSO",
173
180
  OIDC_ENFORCE: null
174
181
  // null = use DB setting; true/false = override DB (useful for lockout recovery)
@@ -221,15 +228,17 @@ function getExpiredCookieOptions(strapi2, ctx) {
221
228
  function clearAuthCookies(strapi2, ctx) {
222
229
  const options2 = getExpiredCookieOptions(strapi2, ctx);
223
230
  ctx.cookies.set("strapi_admin_refresh", "", options2);
224
- ctx.cookies.set("oidc_authenticated", "", { ...options2, path: "/" });
231
+ const rootPathOptions = { ...options2, path: "/" };
232
+ ctx.cookies.set("oidc_authenticated", "", rootPathOptions);
233
+ ctx.cookies.set("oidc_id_token", "", rootPathOptions);
225
234
  }
226
235
  const REQUIRED_CONFIG_KEYS = [
227
236
  "OIDC_CLIENT_ID",
228
237
  "OIDC_CLIENT_SECRET",
229
238
  "OIDC_REDIRECT_URI",
230
- "OIDC_SCOPES",
239
+ "OIDC_SCOPE",
231
240
  "OIDC_TOKEN_ENDPOINT",
232
- "OIDC_USER_INFO_ENDPOINT",
241
+ "OIDC_USERINFO_ENDPOINT",
233
242
  "OIDC_GRANT_TYPE",
234
243
  "OIDC_FAMILY_NAME_FIELD",
235
244
  "OIDC_GIVEN_NAME_FIELD",
@@ -245,7 +254,7 @@ function configValidation() {
245
254
  );
246
255
  }
247
256
  async function oidcSignIn(ctx) {
248
- const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI, OIDC_SCOPES, OIDC_AUTHORIZATION_ENDPOINT } = configValidation();
257
+ const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI, OIDC_SCOPE, OIDC_AUTHORIZATION_ENDPOINT } = configValidation();
249
258
  const { code_verifier: codeVerifier, code_challenge: codeChallenge } = await pkceChallenge__default.default();
250
259
  const state = node_crypto.randomBytes(32).toString("base64url");
251
260
  const nonce = node_crypto.randomBytes(32).toString("base64url");
@@ -264,7 +273,7 @@ async function oidcSignIn(ctx) {
264
273
  params.append("response_type", "code");
265
274
  params.append("client_id", OIDC_CLIENT_ID);
266
275
  params.append("redirect_uri", OIDC_REDIRECT_URI);
267
- params.append("scope", OIDC_SCOPES);
276
+ params.append("scope", OIDC_SCOPE);
268
277
  params.append("code_challenge", codeChallenge);
269
278
  params.append("code_challenge_method", "S256");
270
279
  params.append("state", state);
@@ -297,13 +306,14 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
297
306
  throw new Error("Failed to parse ID token");
298
307
  }
299
308
  }
300
- const userResponse = await fetch(config2.OIDC_USER_INFO_ENDPOINT, {
309
+ const userResponse = await fetch(config2.OIDC_USERINFO_ENDPOINT, {
301
310
  headers: { Authorization: `Bearer ${tokenData.access_token}` }
302
311
  });
303
312
  if (!userResponse.ok) {
304
313
  throw new Error("Failed to fetch user info");
305
314
  }
306
- return userResponse.json();
315
+ const userInfo = await userResponse.json();
316
+ return { userInfo, idToken: tokenData.id_token };
307
317
  }
308
318
  async function registerNewUser(userService, oauthService2, roleService2, email, userResponseData, whitelistUser, config2, ctx) {
309
319
  let roles2 = [];
@@ -313,7 +323,9 @@ async function registerNewUser(userService, oauthService2, roleService2, email,
313
323
  const oidcRoles = await roleService2.oidcRoles();
314
324
  roles2 = oidcRoles?.roles || [];
315
325
  }
316
- const defaultLocale = oauthService2.localeFindByHeader(ctx.request.headers);
326
+ const defaultLocale = oauthService2.localeFindByHeader(
327
+ ctx.request.headers
328
+ );
317
329
  const activateUser = await oauthService2.createUser(
318
330
  email,
319
331
  userResponseData[config2.OIDC_FAMILY_NAME_FIELD],
@@ -365,9 +377,13 @@ async function oidcSignInCallback(ctx) {
365
377
  params.append("client_secret", config2.OIDC_CLIENT_SECRET);
366
378
  params.append("redirect_uri", config2.OIDC_REDIRECT_URI);
367
379
  params.append("grant_type", config2.OIDC_GRANT_TYPE);
368
- params.append("code_verifier", codeVerifier);
380
+ params.append("code_verifier", codeVerifier ?? "");
369
381
  try {
370
- const userResponseData = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce);
382
+ const { userInfo: userResponseData, idToken } = await exchangeTokenAndFetchUserInfo(
383
+ config2,
384
+ params,
385
+ oidcNonce ?? ""
386
+ );
371
387
  const { activateUser, jwtToken } = await handleUserAuthentication(
372
388
  userService,
373
389
  oauthService2,
@@ -377,6 +393,15 @@ async function oidcSignInCallback(ctx) {
377
393
  config2,
378
394
  ctx
379
395
  );
396
+ if (idToken) {
397
+ const isProduction = strapi.config.get("environment") === "production";
398
+ ctx.cookies.set("oidc_id_token", idToken, {
399
+ httpOnly: true,
400
+ secure: isProduction && ctx.request.secure,
401
+ path: "/",
402
+ sameSite: "lax"
403
+ });
404
+ }
380
405
  const nonce = node_crypto.randomUUID();
381
406
  const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
382
407
  ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
@@ -388,20 +413,91 @@ async function oidcSignInCallback(ctx) {
388
413
  }
389
414
  async function logout(ctx) {
390
415
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
391
- const logoutUrl = config2.OIDC_LOGOUT_URL;
416
+ const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
392
417
  const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
418
+ const idToken = ctx.cookies.get("oidc_id_token");
393
419
  clearAuthCookies(strapi, ctx);
394
420
  if (logoutUrl && isOidcSession) {
395
- ctx.redirect(logoutUrl);
421
+ const url = new URL(logoutUrl);
422
+ if (idToken) url.searchParams.set("id_token_hint", idToken);
423
+ if (config2.OIDC_POST_LOGOUT_REDIRECT_URI) {
424
+ url.searchParams.set("post_logout_redirect_uri", config2.OIDC_POST_LOGOUT_REDIRECT_URI);
425
+ }
426
+ ctx.redirect(url.toString());
396
427
  } else {
397
428
  const adminPanelUrl = strapi.config.get("admin.url", "/admin");
398
429
  ctx.redirect(`${adminPanelUrl}/auth/login`);
399
430
  }
400
431
  }
432
+ const jwksCache = /* @__PURE__ */ new Map();
433
+ function getJWKS(uri) {
434
+ const cached = jwksCache.get(uri);
435
+ if (cached) return cached;
436
+ const jwks = jose.createRemoteJWKSet(new URL(uri));
437
+ jwksCache.set(uri, jwks);
438
+ return jwks;
439
+ }
440
+ async function backchannelLogout(ctx) {
441
+ const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
442
+ const logoutToken = ctx.request.body?.logout_token;
443
+ if (!logoutToken) {
444
+ ctx.status = 400;
445
+ ctx.body = { error: "Missing logout_token" };
446
+ return;
447
+ }
448
+ if (!config2.OIDC_JWKS_URI || !config2.OIDC_ISSUER) {
449
+ ctx.status = 501;
450
+ ctx.body = {
451
+ error: "OIDC_JWKS_URI and OIDC_ISSUER must both be configured to enable backchannel logout"
452
+ };
453
+ return;
454
+ }
455
+ try {
456
+ const JWKS = getJWKS(config2.OIDC_JWKS_URI);
457
+ const verifyOptions = {
458
+ issuer: config2.OIDC_ISSUER,
459
+ audience: config2.OIDC_CLIENT_ID || void 0
460
+ };
461
+ const { payload } = await jose.jwtVerify(logoutToken, JWKS, verifyOptions);
462
+ if ("nonce" in payload) {
463
+ ctx.status = 400;
464
+ ctx.body = { error: "logout_token must not contain nonce" };
465
+ return;
466
+ }
467
+ const events = payload.events;
468
+ if (!events?.["http://schemas.openid.net/event/backchannel-logout"]) {
469
+ ctx.status = 400;
470
+ ctx.body = { error: "logout_token missing backchannel-logout event" };
471
+ return;
472
+ }
473
+ if (!payload.sub && !("sid" in payload)) {
474
+ ctx.status = 400;
475
+ ctx.body = { error: "logout_token must contain sub or sid" };
476
+ return;
477
+ }
478
+ if (payload.sub) {
479
+ const userService = strapi.service("admin::user");
480
+ const user = await userService.findOneByEmail(payload.sub);
481
+ if (user) {
482
+ const sessionManager = strapi.sessionManager;
483
+ if (sessionManager) {
484
+ await sessionManager("admin").invalidateRefreshToken(String(user.id));
485
+ }
486
+ }
487
+ }
488
+ ctx.status = 200;
489
+ ctx.body = "";
490
+ } catch (e) {
491
+ strapi.log.error("Backchannel logout failed:", e);
492
+ ctx.status = 400;
493
+ ctx.body = { error: "Invalid logout_token" };
494
+ }
495
+ }
401
496
  const oidc = {
402
497
  oidcSignIn,
403
498
  oidcSignInCallback,
404
- logout
499
+ logout,
500
+ backchannelLogout
405
501
  };
406
502
  async function find(ctx) {
407
503
  const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
@@ -658,6 +754,12 @@ const routes = {
658
754
  handler: "oidc.logout",
659
755
  config: { auth: false }
660
756
  },
757
+ {
758
+ method: "POST",
759
+ path: "/logout",
760
+ handler: "oidc.backchannelLogout",
761
+ config: { auth: false }
762
+ },
661
763
  {
662
764
  method: "GET",
663
765
  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";
@@ -155,14 +156,20 @@ const config = {
155
156
  OIDC_REDIRECT_URI: "http://localhost:1337/strapi-plugin-oidc/oidc/callback",
156
157
  OIDC_CLIENT_ID: "",
157
158
  OIDC_CLIENT_SECRET: "",
158
- OIDC_SCOPES: "openid profile email",
159
+ OIDC_SCOPE: "openid profile email",
159
160
  OIDC_AUTHORIZATION_ENDPOINT: "",
160
161
  OIDC_TOKEN_ENDPOINT: "",
161
- OIDC_USER_INFO_ENDPOINT: "",
162
+ OIDC_USERINFO_ENDPOINT: "",
162
163
  OIDC_GRANT_TYPE: "authorization_code",
163
164
  OIDC_FAMILY_NAME_FIELD: "family_name",
164
165
  OIDC_GIVEN_NAME_FIELD: "given_name",
165
- OIDC_LOGOUT_URL: "",
166
+ OIDC_END_SESSION_ENDPOINT: "",
167
+ OIDC_POST_LOGOUT_REDIRECT_URI: "",
168
+ // Where to land after the provider has logged the user out (RP-Initiated Logout)
169
+ OIDC_ISSUER: "",
170
+ // Provider issuer URL — used to validate iss claim in backchannel logout tokens
171
+ OIDC_JWKS_URI: "",
172
+ // Provider JWKS endpoint — required for backchannel logout token signature verification
166
173
  OIDC_SSO_BUTTON_TEXT: "Login via SSO",
167
174
  OIDC_ENFORCE: null
168
175
  // null = use DB setting; true/false = override DB (useful for lockout recovery)
@@ -215,15 +222,17 @@ function getExpiredCookieOptions(strapi2, ctx) {
215
222
  function clearAuthCookies(strapi2, ctx) {
216
223
  const options2 = getExpiredCookieOptions(strapi2, ctx);
217
224
  ctx.cookies.set("strapi_admin_refresh", "", options2);
218
- ctx.cookies.set("oidc_authenticated", "", { ...options2, path: "/" });
225
+ const rootPathOptions = { ...options2, path: "/" };
226
+ ctx.cookies.set("oidc_authenticated", "", rootPathOptions);
227
+ ctx.cookies.set("oidc_id_token", "", rootPathOptions);
219
228
  }
220
229
  const REQUIRED_CONFIG_KEYS = [
221
230
  "OIDC_CLIENT_ID",
222
231
  "OIDC_CLIENT_SECRET",
223
232
  "OIDC_REDIRECT_URI",
224
- "OIDC_SCOPES",
233
+ "OIDC_SCOPE",
225
234
  "OIDC_TOKEN_ENDPOINT",
226
- "OIDC_USER_INFO_ENDPOINT",
235
+ "OIDC_USERINFO_ENDPOINT",
227
236
  "OIDC_GRANT_TYPE",
228
237
  "OIDC_FAMILY_NAME_FIELD",
229
238
  "OIDC_GIVEN_NAME_FIELD",
@@ -239,7 +248,7 @@ function configValidation() {
239
248
  );
240
249
  }
241
250
  async function oidcSignIn(ctx) {
242
- const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI, OIDC_SCOPES, OIDC_AUTHORIZATION_ENDPOINT } = configValidation();
251
+ const { OIDC_CLIENT_ID, OIDC_REDIRECT_URI, OIDC_SCOPE, OIDC_AUTHORIZATION_ENDPOINT } = configValidation();
243
252
  const { code_verifier: codeVerifier, code_challenge: codeChallenge } = await pkceChallenge();
244
253
  const state = randomBytes(32).toString("base64url");
245
254
  const nonce = randomBytes(32).toString("base64url");
@@ -258,7 +267,7 @@ async function oidcSignIn(ctx) {
258
267
  params.append("response_type", "code");
259
268
  params.append("client_id", OIDC_CLIENT_ID);
260
269
  params.append("redirect_uri", OIDC_REDIRECT_URI);
261
- params.append("scope", OIDC_SCOPES);
270
+ params.append("scope", OIDC_SCOPE);
262
271
  params.append("code_challenge", codeChallenge);
263
272
  params.append("code_challenge_method", "S256");
264
273
  params.append("state", state);
@@ -291,13 +300,14 @@ async function exchangeTokenAndFetchUserInfo(config2, params, expectedNonce) {
291
300
  throw new Error("Failed to parse ID token");
292
301
  }
293
302
  }
294
- const userResponse = await fetch(config2.OIDC_USER_INFO_ENDPOINT, {
303
+ const userResponse = await fetch(config2.OIDC_USERINFO_ENDPOINT, {
295
304
  headers: { Authorization: `Bearer ${tokenData.access_token}` }
296
305
  });
297
306
  if (!userResponse.ok) {
298
307
  throw new Error("Failed to fetch user info");
299
308
  }
300
- return userResponse.json();
309
+ const userInfo = await userResponse.json();
310
+ return { userInfo, idToken: tokenData.id_token };
301
311
  }
302
312
  async function registerNewUser(userService, oauthService2, roleService2, email, userResponseData, whitelistUser, config2, ctx) {
303
313
  let roles2 = [];
@@ -307,7 +317,9 @@ async function registerNewUser(userService, oauthService2, roleService2, email,
307
317
  const oidcRoles = await roleService2.oidcRoles();
308
318
  roles2 = oidcRoles?.roles || [];
309
319
  }
310
- const defaultLocale = oauthService2.localeFindByHeader(ctx.request.headers);
320
+ const defaultLocale = oauthService2.localeFindByHeader(
321
+ ctx.request.headers
322
+ );
311
323
  const activateUser = await oauthService2.createUser(
312
324
  email,
313
325
  userResponseData[config2.OIDC_FAMILY_NAME_FIELD],
@@ -359,9 +371,13 @@ async function oidcSignInCallback(ctx) {
359
371
  params.append("client_secret", config2.OIDC_CLIENT_SECRET);
360
372
  params.append("redirect_uri", config2.OIDC_REDIRECT_URI);
361
373
  params.append("grant_type", config2.OIDC_GRANT_TYPE);
362
- params.append("code_verifier", codeVerifier);
374
+ params.append("code_verifier", codeVerifier ?? "");
363
375
  try {
364
- const userResponseData = await exchangeTokenAndFetchUserInfo(config2, params, oidcNonce);
376
+ const { userInfo: userResponseData, idToken } = await exchangeTokenAndFetchUserInfo(
377
+ config2,
378
+ params,
379
+ oidcNonce ?? ""
380
+ );
365
381
  const { activateUser, jwtToken } = await handleUserAuthentication(
366
382
  userService,
367
383
  oauthService2,
@@ -371,6 +387,15 @@ async function oidcSignInCallback(ctx) {
371
387
  config2,
372
388
  ctx
373
389
  );
390
+ if (idToken) {
391
+ const isProduction = strapi.config.get("environment") === "production";
392
+ ctx.cookies.set("oidc_id_token", idToken, {
393
+ httpOnly: true,
394
+ secure: isProduction && ctx.request.secure,
395
+ path: "/",
396
+ sameSite: "lax"
397
+ });
398
+ }
374
399
  const nonce = randomUUID();
375
400
  const html = oauthService2.renderSignUpSuccess(jwtToken, activateUser, nonce);
376
401
  ctx.set("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
@@ -382,20 +407,91 @@ async function oidcSignInCallback(ctx) {
382
407
  }
383
408
  async function logout(ctx) {
384
409
  const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
385
- const logoutUrl = config2.OIDC_LOGOUT_URL;
410
+ const logoutUrl = config2.OIDC_END_SESSION_ENDPOINT;
386
411
  const isOidcSession = !!ctx.cookies.get("oidc_authenticated");
412
+ const idToken = ctx.cookies.get("oidc_id_token");
387
413
  clearAuthCookies(strapi, ctx);
388
414
  if (logoutUrl && isOidcSession) {
389
- ctx.redirect(logoutUrl);
415
+ const url = new URL(logoutUrl);
416
+ if (idToken) url.searchParams.set("id_token_hint", idToken);
417
+ if (config2.OIDC_POST_LOGOUT_REDIRECT_URI) {
418
+ url.searchParams.set("post_logout_redirect_uri", config2.OIDC_POST_LOGOUT_REDIRECT_URI);
419
+ }
420
+ ctx.redirect(url.toString());
390
421
  } else {
391
422
  const adminPanelUrl = strapi.config.get("admin.url", "/admin");
392
423
  ctx.redirect(`${adminPanelUrl}/auth/login`);
393
424
  }
394
425
  }
426
+ const jwksCache = /* @__PURE__ */ new Map();
427
+ function getJWKS(uri) {
428
+ const cached = jwksCache.get(uri);
429
+ if (cached) return cached;
430
+ const jwks = createRemoteJWKSet(new URL(uri));
431
+ jwksCache.set(uri, jwks);
432
+ return jwks;
433
+ }
434
+ async function backchannelLogout(ctx) {
435
+ const config2 = strapi.config.get("plugin::strapi-plugin-oidc");
436
+ const logoutToken = ctx.request.body?.logout_token;
437
+ if (!logoutToken) {
438
+ ctx.status = 400;
439
+ ctx.body = { error: "Missing logout_token" };
440
+ return;
441
+ }
442
+ if (!config2.OIDC_JWKS_URI || !config2.OIDC_ISSUER) {
443
+ ctx.status = 501;
444
+ ctx.body = {
445
+ error: "OIDC_JWKS_URI and OIDC_ISSUER must both be configured to enable backchannel logout"
446
+ };
447
+ return;
448
+ }
449
+ try {
450
+ const JWKS = getJWKS(config2.OIDC_JWKS_URI);
451
+ const verifyOptions = {
452
+ issuer: config2.OIDC_ISSUER,
453
+ audience: config2.OIDC_CLIENT_ID || void 0
454
+ };
455
+ const { payload } = await jwtVerify(logoutToken, JWKS, verifyOptions);
456
+ if ("nonce" in payload) {
457
+ ctx.status = 400;
458
+ ctx.body = { error: "logout_token must not contain nonce" };
459
+ return;
460
+ }
461
+ const events = payload.events;
462
+ if (!events?.["http://schemas.openid.net/event/backchannel-logout"]) {
463
+ ctx.status = 400;
464
+ ctx.body = { error: "logout_token missing backchannel-logout event" };
465
+ return;
466
+ }
467
+ if (!payload.sub && !("sid" in payload)) {
468
+ ctx.status = 400;
469
+ ctx.body = { error: "logout_token must contain sub or sid" };
470
+ return;
471
+ }
472
+ if (payload.sub) {
473
+ const userService = strapi.service("admin::user");
474
+ const user = await userService.findOneByEmail(payload.sub);
475
+ if (user) {
476
+ const sessionManager = strapi.sessionManager;
477
+ if (sessionManager) {
478
+ await sessionManager("admin").invalidateRefreshToken(String(user.id));
479
+ }
480
+ }
481
+ }
482
+ ctx.status = 200;
483
+ ctx.body = "";
484
+ } catch (e) {
485
+ strapi.log.error("Backchannel logout failed:", e);
486
+ ctx.status = 400;
487
+ ctx.body = { error: "Invalid logout_token" };
488
+ }
489
+ }
395
490
  const oidc = {
396
491
  oidcSignIn,
397
492
  oidcSignInCallback,
398
- logout
493
+ logout,
494
+ backchannelLogout
399
495
  };
400
496
  async function find(ctx) {
401
497
  const roleService2 = strapi.plugin("strapi-plugin-oidc").service("role");
@@ -652,6 +748,12 @@ const routes = {
652
748
  handler: "oidc.logout",
653
749
  config: { auth: false }
654
750
  },
751
+ {
752
+ method: "POST",
753
+ path: "/logout",
754
+ handler: "oidc.backchannelLogout",
755
+ config: { auth: false }
756
+ },
655
757
  {
656
758
  method: "GET",
657
759
  path: "/whitelist",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-oidc",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "A Strapi plugin that provides OpenID Connect (OIDC) authentication functionality for the Strapi Admin Panel.",
5
5
  "strapi": {
6
6
  "displayName": "OIDC Plugin",
@@ -50,6 +50,7 @@
50
50
  "@strapi/icons": "^2.2.0",
51
51
  "@strapi/utils": "^5.41.1",
52
52
  "generate-password": "^1.7.1",
53
+ "jose": "^6.2.2",
53
54
  "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",