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 +19 -5
- package/dist/admin/{index-RMgj1w0B.js → index-CqgjBmJ5.js} +3 -2
- package/dist/admin/{index-BnFRueNv.js → index-DWm8oOJF.js} +2 -2
- package/dist/admin/{index-CY4s-vtv.mjs → index-Dz3WlTpL.mjs} +2 -2
- package/dist/admin/{index-ZRaWWFUL.mjs → index-Dzf0bJC1.mjs} +3 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/server/index.js +118 -16
- package/dist/server/index.mjs +118 -16
- package/package.json +4 -1
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
|
-
|
|
37
|
+
OIDC_USERINFO_ENDPOINT: env('OIDC_USERINFO_ENDPOINT'),
|
|
38
38
|
|
|
39
39
|
// Optional — defaults shown
|
|
40
|
-
|
|
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-
|
|
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-
|
|
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: "
|
|
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-
|
|
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: "
|
|
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-
|
|
126
|
+
return await import("./index-Dz3WlTpL.mjs");
|
|
126
127
|
},
|
|
127
128
|
permissions: [{ action: "plugin::strapi-plugin-oidc.read", subject: null }]
|
|
128
129
|
}
|
package/dist/admin/index.js
CHANGED
package/dist/admin/index.mjs
CHANGED
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");
|
|
@@ -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
|
-
|
|
165
|
+
OIDC_SCOPE: "openid profile email",
|
|
165
166
|
OIDC_AUTHORIZATION_ENDPOINT: "",
|
|
166
167
|
OIDC_TOKEN_ENDPOINT: "",
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
239
|
+
"OIDC_SCOPE",
|
|
231
240
|
"OIDC_TOKEN_ENDPOINT",
|
|
232
|
-
"
|
|
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,
|
|
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",
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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",
|
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";
|
|
@@ -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
|
-
|
|
159
|
+
OIDC_SCOPE: "openid profile email",
|
|
159
160
|
OIDC_AUTHORIZATION_ENDPOINT: "",
|
|
160
161
|
OIDC_TOKEN_ENDPOINT: "",
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
233
|
+
"OIDC_SCOPE",
|
|
225
234
|
"OIDC_TOKEN_ENDPOINT",
|
|
226
|
-
"
|
|
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,
|
|
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",
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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",
|