strapi-keycloak-passport
Version:
Keycloak authentication provider for the Strapi v5 administration panel.
1,556 lines (1,555 loc) • 84.4 kB
JavaScript
"use strict";
const axios = require("axios");
const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
const axios__default = /* @__PURE__ */ _interopDefault(axios);
const PLUGIN_ID = "strapi-keycloak-passport";
const PLUGIN_NAMESPACE = `plugin::${PLUGIN_ID}`;
const CONTENT_TYPES = Object.freeze({
ROLE_MAPPING: `${PLUGIN_NAMESPACE}.role-mapping`,
AUDIT_LOG: `${PLUGIN_NAMESPACE}.audit-log`
});
const SERVICES = Object.freeze({
KEYCLOAK: `${PLUGIN_NAMESPACE}.keycloakService`,
ADMIN_USER: `${PLUGIN_NAMESPACE}.adminUserService`,
ROLE_MAPPING: `${PLUGIN_NAMESPACE}.roleMappingService`,
TOKEN_CACHE: `${PLUGIN_NAMESPACE}.tokenCacheService`,
AUDIT_LOG: `${PLUGIN_NAMESPACE}.auditLogService`
});
const KEYCLOAK_PATHS = Object.freeze({
LEGACY_AUTH_PREFIX: "/auth",
MODERN_AUTH_PREFIX: ""
});
const KEYCLOAK_ENDPOINTS = Object.freeze({
TOKEN: "/realms/{realm}/protocol/openid-connect/token",
USERINFO: "/realms/{realm}/protocol/openid-connect/userinfo",
REALM_ROLES: "/admin/realms/{realm}/roles",
USER_REALM_ROLES: "/admin/realms/{realm}/users/{userId}/role-mappings/realm"
});
const TOKEN_CACHE = Object.freeze({
EXPIRY_BUFFER_MS: 60 * 1e3,
DEFAULT_TOKEN_LIFETIME_MS: 5 * 60 * 1e3,
MIN_CACHE_TIME_MS: 30 * 1e3
});
const HTTP = Object.freeze({
HEADERS: Object.freeze({
CONTENT_TYPE: "Content-Type",
AUTHORIZATION: "Authorization",
ACCEPT: "Accept"
}),
CONTENT_TYPES: Object.freeze({
FORM_URLENCODED: "application/x-www-form-urlencoded",
JSON: "application/json"
}),
GRANT_TYPES: Object.freeze({
PASSWORD: "password",
CLIENT_CREDENTIALS: "client_credentials",
REFRESH_TOKEN: "refresh_token"
})
});
const OAUTH_SCOPES = Object.freeze({
DEFAULT: "email profile openid",
ADMIN: ""
// Client credentials flow doesn't use scopes
});
const SESSION = Object.freeze({
REFRESH_COOKIE_NAME: "strapi_admin_refresh",
MAX_REFRESH_TOKEN_LIFESPAN: 30 * 24 * 60 * 60,
IDLE_REFRESH_TOKEN_LIFESPAN: 14 * 24 * 60 * 60,
MAX_SESSION_LIFESPAN: 1 * 24 * 60 * 60,
IDLE_SESSION_LIFESPAN: 2 * 60 * 60
});
const ROLE_DEFAULTS = Object.freeze({
DEFAULT_ROLE_ID: 1,
DEFAULT_EXCLUDED_ROLES: Object.freeze([
"uma_authorization",
"offline_access",
"default-roles-master"
])
});
const AUDIT_ACTIONS = Object.freeze({
LOGIN_SUCCESS: "LOGIN_SUCCESS",
LOGIN_FAILURE: "LOGIN_FAILURE",
LOGOUT: "LOGOUT",
USER_CREATED: "USER_CREATED",
USER_UPDATED: "USER_UPDATED",
ROLE_ASSIGNED: "ROLE_ASSIGNED",
ROLE_REMOVED: "ROLE_REMOVED",
MAPPING_CREATED: "MAPPING_CREATED",
MAPPING_UPDATED: "MAPPING_UPDATED",
MAPPING_DELETED: "MAPPING_DELETED",
TOKEN_REFRESHED: "TOKEN_REFRESHED"
});
const AUTH_STRATEGIES = Object.freeze({
SESSION: "session",
JWT: "jwt"
});
const ERROR_CODES = Object.freeze({
INVALID_PAYLOAD: "KC_INVALID_PAYLOAD",
INVALID_CONFIGURATION: "KC_INVALID_CONFIGURATION",
KEYCLOAK_CONNECTION_ERROR: "KC_CONNECTION_ERROR",
KEYCLOAK_TOKEN_ERROR: "KC_TOKEN_ERROR",
KEYCLOAK_USERINFO_ERROR: "KC_USERINFO_ERROR",
KEYCLOAK_ROLES_ERROR: "KC_ROLES_ERROR",
USER_SYNC_ERROR: "KC_USER_SYNC_ERROR",
ROLE_MAPPING_ERROR: "KC_ROLE_MAPPING_ERROR",
PERMISSION_DENIED: "KC_PERMISSION_DENIED",
INTERNAL_ERROR: "KC_INTERNAL_ERROR"
});
const PERMISSIONS = Object.freeze({
ACCESS: `${PLUGIN_NAMESPACE}.access`,
VIEW_MAPPINGS: `${PLUGIN_NAMESPACE}.view-role-mappings`,
MANAGE_MAPPINGS: `${PLUGIN_NAMESPACE}.manage-role-mappings`
});
const VALIDATION = Object.freeze({
MIN_ROLE_NAME_LENGTH: 1,
MAX_ROLE_NAME_LENGTH: 100,
URL_PATTERN: /^https?:\/\/.+/i,
REALM_NAME_PATTERN: /^[a-zA-Z0-9_-]+$/
});
const REQUIRED_CONFIG_KEYS = Object.freeze([
"KEYCLOAK_AUTH_URL",
"KEYCLOAK_REALM",
"KEYCLOAK_CLIENT_ID",
"KEYCLOAK_CLIENT_SECRET"
]);
const DEFAULT_CONFIG = Object.freeze({
KEYCLOAK_AUTH_URL: "",
KEYCLOAK_REALM: "",
KEYCLOAK_CLIENT_ID: "",
KEYCLOAK_CLIENT_SECRET: "",
KEYCLOAK_LEGACY_MODE: false,
roleConfigs: Object.freeze({
defaultRoleId: ROLE_DEFAULTS.DEFAULT_ROLE_ID,
excludedRoles: ROLE_DEFAULTS.DEFAULT_EXCLUDED_ROLES
})
});
class PluginError extends Error {
/**
* Creates a new PluginError instance.
*
* @param {string} code - Error code from ERROR_CODES constant
* @param {string} message - Human-readable error message
* @param {string} [safeMessage] - Client-safe message (defaults to message)
* @param {Object} [details] - Additional error details (internal only)
*/
constructor(code, message, safeMessage = null, details = null) {
super(message);
this.code = code;
this.name = "PluginError";
this.safeMessage = safeMessage || message;
this.details = details;
this.timestamp = Date.now();
if (Error.captureStackTrace) {
Error.captureStackTrace(this, PluginError);
}
}
/**
* Converts error to a client-safe JSON representation.
* Excludes sensitive internal details.
*
* @returns {Object} Sanitized error object
*/
toSafeJSON() {
return {
error: {
code: this.code,
message: this.safeMessage,
timestamp: this.timestamp
}
};
}
/**
* Converts error to full JSON representation for logging.
* Includes all details for debugging purposes.
*
* @returns {Object} Complete error object
*/
toJSON() {
return {
code: this.code,
name: this.name,
message: this.message,
safeMessage: this.safeMessage,
details: this.details,
timestamp: this.timestamp,
stack: this.stack
};
}
}
const createSanitizedError = (code, internalMessage, clientMessage, details = null) => {
return new PluginError(code, internalMessage, clientMessage, details);
};
const createKeycloakError = (operation, originalError) => {
const status = originalError?.response?.status || "unknown";
const responseData = originalError?.response?.data || originalError?.message;
return new PluginError(
ERROR_CODES.KEYCLOAK_CONNECTION_ERROR,
`Keycloak ${operation} failed [${status}]: ${JSON.stringify(responseData)}`,
"Unable to communicate with authentication server.",
{
operation,
status,
response: responseData
}
);
};
const extractSafeError = (error) => {
if (error instanceof PluginError) {
return error.toSafeJSON().error;
}
return {
code: ERROR_CODES.INTERNAL_ERROR,
message: "An unexpected error occurred.",
timestamp: Date.now()
};
};
const getConfig$1 = () => strapi.config.get(PLUGIN_NAMESPACE);
const getAdminUserService = () => strapi.plugin(PLUGIN_ID).service("adminUserService");
const getAuditLogService = () => strapi.plugin(PLUGIN_ID).service("auditLogService");
const getSessionManager = () => strapi.sessionManager ?? null;
const getJwtService = () => {
return strapi.admin?.services?.jwt ?? strapi.service?.("admin::jwt") ?? null;
};
const getAuthPrefix = () => {
const config2 = getConfig$1();
return config2.KEYCLOAK_LEGACY_MODE ? KEYCLOAK_PATHS.LEGACY_AUTH_PREFIX : KEYCLOAK_PATHS.MODERN_AUTH_PREFIX;
};
const buildEndpointUrl = (endpointTemplate) => {
const config2 = getConfig$1();
const authPrefix = getAuthPrefix();
const base = config2.KEYCLOAK_AUTH_URL.replace(/\/+$/, "");
const path = endpointTemplate.replace("{realm}", config2.KEYCLOAK_REALM);
return `${base}${authPrefix}${path}`;
};
const getBaseCookieOptions = (secureRequest) => {
const configuredSecure = strapi.config.get("admin.auth.cookie.secure");
const isProduction = process.env.NODE_ENV === "production";
const domain = strapi.config.get("admin.auth.cookie.domain") || strapi.config.get("admin.auth.domain");
const path = strapi.config.get("admin.auth.cookie.path", "/admin");
const sameSite = strapi.config.get("admin.auth.cookie.sameSite") ?? "lax";
let isSecure;
if (typeof configuredSecure === "boolean") {
isSecure = configuredSecure;
} else {
isSecure = isProduction && (secureRequest ?? false);
}
return {
httpOnly: true,
secure: isSecure,
overwrite: true,
domain,
path,
sameSite,
maxAge: void 0
};
};
const buildCookieOptions = (type, absoluteExpiresAtISO, secureRequest) => {
const baseOptions = getBaseCookieOptions(secureRequest);
if (type === "session") {
return baseOptions;
}
const idleSeconds = Number(
strapi.config.get(
"admin.auth.sessions.idleRefreshTokenLifespan",
SESSION.IDLE_REFRESH_TOKEN_LIFESPAN
)
);
const now = Date.now();
const idleExpiry = now + idleSeconds * 1e3;
const absoluteExpiry = absoluteExpiresAtISO ? new Date(absoluteExpiresAtISO).getTime() : idleExpiry;
const chosen = new Date(Math.min(idleExpiry, absoluteExpiry));
return {
...baseOptions,
expires: chosen,
maxAge: Math.max(0, chosen.getTime() - now)
};
};
const requestKeycloakToken = async (credentials) => {
const config2 = getConfig$1();
const tokenUrl = buildEndpointUrl(KEYCLOAK_ENDPOINTS.TOKEN);
try {
const response = await axios__default.default.post(
tokenUrl,
new URLSearchParams({
client_id: config2.KEYCLOAK_CLIENT_ID,
client_secret: config2.KEYCLOAK_CLIENT_SECRET,
username: credentials.email,
password: credentials.password,
grant_type: HTTP.GRANT_TYPES.PASSWORD,
scope: OAUTH_SCOPES.DEFAULT
}).toString(),
{
headers: {
[HTTP.HEADERS.CONTENT_TYPE]: HTTP.CONTENT_TYPES.FORM_URLENCODED
}
}
);
const tokenData = response.data;
if (!tokenData?.access_token) {
throw createSanitizedError(
ERROR_CODES.KEYCLOAK_TOKEN_ERROR,
"Keycloak returned empty access_token",
"Authentication failed."
);
}
return tokenData;
} catch (error) {
if (error instanceof PluginError) {
throw error;
}
strapi.log.error("Keycloak token request failed:", {
status: error.response?.status,
data: error.response?.data
});
throw createSanitizedError(
ERROR_CODES.KEYCLOAK_TOKEN_ERROR,
`Token request failed: ${error.response?.status || error.message}`,
"Invalid credentials."
);
}
};
const fetchUserInfo = async (accessToken) => {
const userInfoUrl = buildEndpointUrl(KEYCLOAK_ENDPOINTS.USERINFO);
try {
const response = await axios__default.default.get(userInfoUrl, {
headers: {
[HTTP.HEADERS.AUTHORIZATION]: `Bearer ${accessToken}`
}
});
return response.data || {};
} catch (error) {
strapi.log.error("Keycloak userinfo request failed:", {
status: error.response?.status,
data: error.response?.data
});
throw createSanitizedError(
ERROR_CODES.KEYCLOAK_USERINFO_ERROR,
`Userinfo request failed: ${error.response?.status || error.message}`,
"Failed to retrieve user information."
);
}
};
const applySessionStrategy = async (adminUser, ctx) => {
const sessionManager = getSessionManager();
const userId = String(adminUser.id);
const body = ctx.request.body ?? {};
const deviceId = body.deviceId || crypto.randomUUID();
const rememberMe = Boolean(body.rememberMe);
const { token: refreshToken, absoluteExpiresAt } = await sessionManager("admin").generateRefreshToken(userId, deviceId, {
type: rememberMe ? "refresh" : "session"
});
const cookieOptions = buildCookieOptions(
rememberMe ? "refresh" : "session",
absoluteExpiresAt,
ctx.request.secure
);
ctx.cookies.set(SESSION.REFRESH_COOKIE_NAME, refreshToken, cookieOptions);
const accessResult = await sessionManager("admin").generateAccessToken(refreshToken);
if ("error" in accessResult) {
throw createSanitizedError(
ERROR_CODES.INTERNAL_ERROR,
`Session manager failed to generate access token: ${JSON.stringify(accessResult.error)}`,
"Failed to generate access token."
);
}
return {
accessToken: accessResult.token,
strategy: AUTH_STRATEGIES.SESSION
};
};
const applyJwtStrategy = async (adminUser) => {
const jwtService = getJwtService();
if (!jwtService) {
throw createSanitizedError(
ERROR_CODES.INTERNAL_ERROR,
"Neither session manager nor JWT service is available on this Strapi instance.",
"Authentication infrastructure unavailable. Contact administrator."
);
}
const accessToken = await jwtService.sign({ id: adminUser.id });
return {
accessToken,
strategy: AUTH_STRATEGIES.JWT
};
};
const resolveTokenStrategy = async (adminUser, ctx) => {
const sessionManager = getSessionManager();
if (sessionManager) {
strapi.log.debug("Using session strategy for token generation.");
return applySessionStrategy(adminUser, ctx);
}
strapi.log.debug("Session manager unavailable — falling back to JWT strategy.");
return applyJwtStrategy(adminUser);
};
const buildLoginResponse = (accessToken, adminUser) => {
const sanitizeUser = strapi.admin?.services?.user?.sanitizeUser || ((u) => u);
return {
data: {
token: accessToken,
accessToken,
user: sanitizeUser(adminUser)
}
};
};
const handleLoginError = async (ctx, error, email) => {
const auditLogService2 = getAuditLogService();
const safeError = extractSafeError(error);
strapi.log.error(`Admin authentication failed [${safeError.code}]:`, error.message);
if (email) {
auditLogService2.logLoginFailure({ email, reason: safeError.message }, ctx);
}
ctx.badRequest("Invalid credentials", {
error: {
status: 400,
name: "ApplicationError",
code: safeError.code,
message: "Invalid credentials"
}
});
};
const extractCredentials = (ctx) => {
const rawBody = ctx.request?.body || {};
const email = rawBody.email;
const password = rawBody.password;
const isEmailValid = typeof email === "string" && email.trim().length > 0;
const isPasswordValid = typeof password === "string" && password.trim().length > 0;
if (!isEmailValid || !isPasswordValid) {
throw createSanitizedError(
ERROR_CODES.INVALID_PAYLOAD,
`Credentials validation failed: email=${isEmailValid}, password=${isPasswordValid}`,
"Missing or invalid email/password.",
{ hasEmail: isEmailValid, hasPassword: isPasswordValid }
);
}
return {
email: email.trim(),
password: password.trim()
};
};
const AuthOverrideController = {
/**
* Authenticates an admin user via Keycloak OAuth2 password grant.
* Replaces Strapi's default POST /admin/login endpoint.
*
* Flow:
* 1. Extract and validate credentials
* 2. Authenticate against Keycloak (password grant)
* 3. Fetch Keycloak user info
* 4. Find or create Strapi admin user and assign roles
* 5. Issue Strapi tokens (session or JWT, auto-detected)
* 6. Return token + user to client
*
* @async
* @param {KoaContext} ctx - Koa context
* @returns {Promise<void>}
*/
async login(ctx) {
let credentials = null;
try {
credentials = extractCredentials(ctx);
strapi.log.info(`Authenticating ${credentials.email} via Keycloak Passport...`);
const tokenPayload = await requestKeycloakToken(credentials);
const keycloakUserInfo = await fetchUserInfo(tokenPayload.access_token);
const adminUserService2 = getAdminUserService();
const adminUser = await adminUserService2.findOrCreate(keycloakUserInfo, ctx);
ctx.state.user = adminUser;
ctx.state.admin = adminUser;
const { accessToken, strategy } = await resolveTokenStrategy(adminUser, ctx);
if (ctx.session !== void 0) {
ctx.session = {
user: adminUser,
admin: { id: adminUser.id }
};
}
strapi.log.info(
`${credentials.email} authenticated via Keycloak (strategy: ${strategy}).`
);
const auditLogService2 = getAuditLogService();
auditLogService2.logLoginSuccess({
email: credentials.email,
keycloakUserId: keycloakUserInfo.sub,
userId: String(adminUser.id),
roles: adminUser.roles?.map((r) => r?.name).filter(Boolean) || []
}, ctx);
ctx.body = buildLoginResponse(accessToken, adminUser);
} catch (error) {
await handleLoginError(ctx, error, credentials?.email);
}
}
};
const bootstrap = async ({ strapi: strapi2 }) => {
strapi2.log.info("🚀 Strapi Keycloak Passport Plugin Bootstrapped");
try {
strapi2.log.info("🔍 Registering Keycloak Plugin Permissions...");
const actions = [
{
section: "plugins",
displayName: "Access Keycloak Plugin",
uid: "access",
pluginName: "strapi-keycloak-passport"
},
{
section: "plugins",
displayName: "View Role Mappings",
uid: "view-role-mappings",
pluginName: "strapi-keycloak-passport"
},
{
section: "plugins",
displayName: "Manage Role Mappings",
uid: "manage-role-mappings",
pluginName: "strapi-keycloak-passport"
}
];
await strapi2.admin.services.permission.actionProvider.registerMany(actions);
strapi2.log.info("✅ Keycloak Plugin permissions successfully registered.");
} catch (error) {
strapi2.log.error(
"❌ Failed to register Keycloak Plugin permissions:",
error
);
}
await ensureDefaultRoleMapping(strapi2);
overrideAdminRoutes(strapi2);
strapi2.log.info("🔒 Passport Keycloak Strategy Initialized");
};
function overrideAdminRoutes(strapi2) {
try {
strapi2.log.info("🛠 Applying Keycloak Authentication Middleware...");
strapi2.server.use(async (ctx, next) => {
const requestPath = ctx.request.path;
const requestMethod = ctx.request.method;
if (requestPath === "/admin/login" && requestMethod === "POST") {
await AuthOverrideController.login(ctx);
} else if ((requestPath.includes("auth/reset-password") || requestPath.includes("auth/forgot-password") || requestPath.includes("auth/register")) && requestMethod === "GET") {
return ctx.redirect("/admin/login");
} else {
await next();
}
});
strapi2.log.info(`
╔════════════════════════════════╗
║ 🛡️ PASSPORT APPLIED 🛡️ ║
╚════════════════════════════════╝
`);
strapi2.log.info("🚴 Admin login request rerouted to passport.");
strapi2.log.info("📒 Registration route blocked. 🚫");
strapi2.log.info("🕵️♂️ Reset password route blocked. 🚫");
} catch (error) {
strapi2.log.error("❌ Failed to register Keycloak Middleware:", error);
}
}
async function ensureDefaultRoleMapping(strapi2) {
try {
const DEFAULT_MAPPING = {
keycloakRole: "SUPER_ADMIN",
strapiRole: 1
};
const existingMapping = await strapi2.db.query("plugin::strapi-keycloak-passport.role-mapping").findOne({ where: { keycloakRole: DEFAULT_MAPPING.keycloakRole } });
if (!existingMapping) {
await strapi2.db.query("plugin::strapi-keycloak-passport.role-mapping").create({ data: DEFAULT_MAPPING });
strapi2.log.info(
`✅ Default Role Mapping Created: ${DEFAULT_MAPPING.keycloakRole} -> ${DEFAULT_MAPPING.strapiRole} (mapped to Super Admin Role)`
);
} else {
strapi2.log.info(
`✅ Default Role Mapping Already Exists: ${existingMapping.keycloakRole} -> ${existingMapping.strapiRole} (mapping to Super Admin Role)`
);
}
} catch (error) {
strapi2.log.error("❌ Failed to create default role mapping:", error);
}
}
const destroy = ({ strapi: strapi2 }) => {
};
const checkAdminPermission = (requiredPermission) => async (ctx, next) => {
try {
const adminUser = ctx.session.user;
if (!adminUser) {
return ctx.unauthorized("User is not authenticated.");
}
const [roleId] = adminUser.roles.map((role) => role.id);
const adminPermissions = await strapi.admin.services.permission.findMany({
where: {
role: roleId,
action: requiredPermission
}
});
if (adminPermissions.length === 0) {
return ctx.forbidden(`Access denied. Missing permission: ${requiredPermission}`);
}
await next();
} catch (error) {
strapi.log.error("🔴 Error checking admin permission:", error);
return ctx.internalServerError("Failed to verify permissions.");
}
};
function getDefaultExportFromCjs(x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
}
var jwtCookie = async (ctx, next) => {
if ((ctx.request.url === "/admin/login" || ctx.request.url === "/admin/renew-token") && ctx.method === "POST" && ctx.status === 200 && ctx.body?.data?.token) {
const token = ctx.body.data.token;
ctx.cookies.set("jwtToken", token, {
httpOnly: true
// ...process.env.NODE_ENV?.includes('prod') && {
// secure: true,
// rolling: true,
// sameSite: "none",
// }
});
}
await next();
};
const jwtCookie$1 = /* @__PURE__ */ getDefaultExportFromCjs(jwtCookie);
const middlewares = {
checkAdminPermission,
jwtCookie: jwtCookie$1
};
const register = ({ strapi: strapi2 }) => {
strapi2.log.info("🔄 Registering Strapi Keycloak Passport Plugin...");
strapi2.server.use(middlewares.jwtCookie);
};
const isValidUrl = (url) => {
if (!url || typeof url !== "string") {
return false;
}
return VALIDATION.URL_PATTERN.test(url.trim());
};
const isValidRealm = (realm) => {
if (!realm || typeof realm !== "string") {
return false;
}
return VALIDATION.REALM_NAME_PATTERN.test(realm.trim());
};
const normalizeUrl = (url) => {
if (!url || typeof url !== "string") {
return "";
}
return url.trim().replace(/\/+$/, "");
};
const config = {
/**
* Default configuration values.
* These are merged with user-provided configuration.
* @type {PluginConfig}
*/
default: {
KEYCLOAK_AUTH_URL: DEFAULT_CONFIG.KEYCLOAK_AUTH_URL,
KEYCLOAK_REALM: DEFAULT_CONFIG.KEYCLOAK_REALM,
KEYCLOAK_CLIENT_ID: DEFAULT_CONFIG.KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET: DEFAULT_CONFIG.KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_LEGACY_MODE: DEFAULT_CONFIG.KEYCLOAK_LEGACY_MODE,
roleConfigs: {
defaultRoleId: ROLE_DEFAULTS.DEFAULT_ROLE_ID,
excludedRoles: [...ROLE_DEFAULTS.DEFAULT_EXCLUDED_ROLES]
}
},
/**
* Configuration validator.
* Called during plugin initialization to ensure required values are present.
*
* @param {PluginConfig} config - Configuration to validate
* @throws {Error} If configuration is invalid
*/
validator(config2) {
const missingKeys = REQUIRED_CONFIG_KEYS.filter((key) => {
const value = config2[key];
return typeof value !== "string" || value.trim().length === 0;
});
if (missingKeys.length > 0) {
throw new Error(
`Missing required Keycloak Passport configuration: ${missingKeys.join(", ")}. Please configure these values in your plugin settings.`
);
}
if (!isValidUrl(config2.KEYCLOAK_AUTH_URL)) {
throw new Error(
`Invalid KEYCLOAK_AUTH_URL: "${config2.KEYCLOAK_AUTH_URL}". Must be a valid HTTP(S) URL (e.g., https://keycloak.example.com).`
);
}
if (!isValidRealm(config2.KEYCLOAK_REALM)) {
throw new Error(
`Invalid KEYCLOAK_REALM: "${config2.KEYCLOAK_REALM}". Must contain only letters, numbers, hyphens, and underscores.`
);
}
if (config2.roleConfigs) {
const { defaultRoleId, excludedRoles } = config2.roleConfigs;
if (defaultRoleId !== void 0) {
if (typeof defaultRoleId !== "number" || defaultRoleId < 1) {
throw new Error(
`Invalid roleConfigs.defaultRoleId: ${defaultRoleId}. Must be a positive integer.`
);
}
}
if (excludedRoles !== void 0) {
if (!Array.isArray(excludedRoles)) {
throw new Error(
"Invalid roleConfigs.excludedRoles. Must be an array of strings."
);
}
const invalidRoles = excludedRoles.filter(
(role) => typeof role !== "string" || role.trim().length === 0
);
if (invalidRoles.length > 0) {
throw new Error(
"Invalid entries in roleConfigs.excludedRoles. All entries must be non-empty strings."
);
}
}
}
if (config2.KEYCLOAK_LEGACY_MODE !== void 0) {
if (typeof config2.KEYCLOAK_LEGACY_MODE !== "boolean") {
throw new Error(
`Invalid KEYCLOAK_LEGACY_MODE: ${config2.KEYCLOAK_LEGACY_MODE}. Must be a boolean (true for Keycloak < 17, false for Keycloak 17+).`
);
}
}
config2.KEYCLOAK_AUTH_URL = normalizeUrl(config2.KEYCLOAK_AUTH_URL);
console.log("[strapi-keycloak-passport] Configuration validated successfully");
console.log(`[strapi-keycloak-passport] Server: ${config2.KEYCLOAK_AUTH_URL}`);
console.log(`[strapi-keycloak-passport] Realm: ${config2.KEYCLOAK_REALM}`);
console.log(`[strapi-keycloak-passport] Legacy mode: ${config2.KEYCLOAK_LEGACY_MODE ? "Yes (Keycloak < 17)" : "No (Keycloak 17+)"}`);
}
};
const kind$1 = "collectionType";
const uid = "plugin::strapi-keycloak-passport.role-mapping";
const info$1 = {
singularName: "role-mapping",
pluralName: "role-mappings",
displayName: "Role Mapping",
description: "Maps Keycloak roles to Strapi roles."
};
const attributes$1 = {
keycloakRole: {
type: "string",
minLength: 3,
maxLength: 100,
required: true
},
strapiRole: {
type: "integer",
required: true
}
};
const schema$1 = {
kind: kind$1,
uid,
info: info$1,
attributes: attributes$1
};
const roleMapping = {
schema: schema$1
};
const kind = "collectionType";
const collectionName = "keycloak_audit_logs";
const info = {
singularName: "audit-log",
pluralName: "audit-logs",
displayName: "Keycloak Audit Log",
description: "Audit trail for Keycloak Passport authentication events and actions."
};
const options = {
draftAndPublish: false
};
const pluginOptions = {
"content-manager": {
visible: true
},
"content-type-builder": {
visible: false
}
};
const attributes = {
action: {
type: "enumeration",
"enum": [
"LOGIN_SUCCESS",
"LOGIN_FAILURE",
"LOGOUT",
"USER_CREATED",
"USER_UPDATED",
"ROLE_ASSIGNED",
"ROLE_REMOVED",
"MAPPING_CREATED",
"MAPPING_UPDATED",
"MAPPING_DELETED",
"TOKEN_REFRESHED"
],
required: true
},
userEmail: {
type: "string",
maxLength: 255
},
userId: {
type: "string",
maxLength: 100
},
keycloakUserId: {
type: "string",
maxLength: 100
},
ipAddress: {
type: "string",
maxLength: 45
},
userAgent: {
type: "string",
maxLength: 500
},
details: {
type: "json"
},
performedById: {
type: "integer"
},
performedByEmail: {
type: "string",
maxLength: 255
},
success: {
type: "boolean",
"default": true
},
errorMessage: {
type: "text"
}
};
const schema = {
kind,
collectionName,
info,
options,
pluginOptions,
attributes
};
const auditLog = {
schema
};
const contentTypes = {
"role-mapping": roleMapping,
"audit-log": auditLog
};
const getKeycloakService = () => {
return strapi.plugin(PLUGIN_ID).service("keycloakService");
};
const getRoleMappingService = () => {
return strapi.service(SERVICES.ROLE_MAPPING);
};
const getConfig = () => {
return strapi.config.get(PLUGIN_NAMESPACE);
};
const authController = {
/**
* Fetches all Keycloak roles and Strapi admin roles.
* Filters out excluded roles based on plugin configuration.
*
* @async
* @param {KoaContext} ctx - Koa context
* @returns {Promise<void>}
*
* @example
* // GET /strapi-keycloak-passport/keycloak-roles
* // Response: { keycloakRoles: [...], strapiRoles: [...] }
*/
async getRoles(ctx) {
try {
const config2 = getConfig();
const keycloakService2 = getKeycloakService();
const allRoles = await keycloakService2.fetchRealmRoles();
const excludedRoles = config2.roleConfigs?.excludedRoles || [];
const keycloakRoles = allRoles.filter(
(role) => !excludedRoles.includes(role.name)
);
const strapiRoles = await strapi.entityService.findMany("admin::role", {
sort: { name: "asc" }
});
strapi.log.debug(`Fetched ${keycloakRoles.length} Keycloak roles and ${strapiRoles.length} Strapi roles`);
return ctx.send({
keycloakRoles,
strapiRoles
});
} catch (error) {
strapi.log.error("Failed to fetch roles:", error.message);
const safeError = extractSafeError(error);
return ctx.badRequest(safeError.message, { error: safeError });
}
},
/**
* Retrieves Keycloak-to-Strapi role mappings.
* Returns mappings as an object for easy lookup.
*
* @async
* @param {KoaContext} ctx - Koa context
* @returns {Promise<void>}
*
* @example
* // GET /strapi-keycloak-passport/get-keycloak-role-mappings
* // Response: { "ADMIN": 1, "EDITOR": 2 }
*/
async getRoleMappings(ctx) {
try {
const roleMappingService2 = getRoleMappingService();
const mappings = await roleMappingService2.getMappingsAsObject();
strapi.log.debug(`Retrieved ${Object.keys(mappings).length} role mappings`);
return ctx.send(mappings);
} catch (error) {
strapi.log.error("Failed to retrieve role mappings:", error.message);
const safeError = extractSafeError(error);
return ctx.badRequest(safeError.message, { error: safeError });
}
},
/**
* Saves Keycloak-to-Strapi role mappings.
* Replaces all existing mappings with the provided ones.
*
* @async
* @param {KoaContext} ctx - Koa context
* @returns {Promise<void>}
*
* @example
* // POST /strapi-keycloak-passport/save-keycloak-role-mappings
* // Body: { mappings: { "ADMIN": 1, "EDITOR": 2 } }
* // Response: { message: "Mappings saved successfully." }
*/
async saveRoleMappings(ctx) {
try {
const { mappings } = ctx.request.body || {};
if (!mappings || typeof mappings !== "object") {
return ctx.badRequest("Invalid request body. Expected { mappings: {...} }");
}
const performedBy = ctx.state?.user ? { id: ctx.state.user.id, email: ctx.state.user.email } : null;
const roleMappingService2 = getRoleMappingService();
await roleMappingService2.saveMappings(mappings, performedBy);
return ctx.send({
message: "Mappings saved successfully.",
count: Object.keys(mappings).length
});
} catch (error) {
strapi.log.error("Failed to save role mappings:", error.message);
const safeError = extractSafeError(error);
return ctx.badRequest(safeError.message, { error: safeError });
}
},
/**
* Tests the Keycloak connection.
* Attempts to fetch an admin token to verify connectivity.
*
* @async
* @param {KoaContext} ctx - Koa context
* @returns {Promise<void>}
*
* @example
* // GET /strapi-keycloak-passport/test-connection
* // Response: { success: true, message: "Connected", serverUrl: "...", realm: "..." }
*/
async testConnection(ctx) {
try {
const keycloakService2 = getKeycloakService();
const result = await keycloakService2.testConnection();
return ctx.send(result);
} catch (error) {
strapi.log.error("Connection test failed:", error.message);
return ctx.send({
success: false,
message: error.safeMessage || error.message
});
}
},
/**
* Gets token cache statistics for monitoring.
*
* @async
* @param {KoaContext} ctx - Koa context
* @returns {Promise<void>}
*
* @example
* // GET /strapi-keycloak-passport/token-cache-stats
* // Response: { size: 1, entries: [...] }
*/
async getTokenCacheStats(ctx) {
try {
const keycloakService2 = getKeycloakService();
const stats = keycloakService2.getTokenCacheStats();
return ctx.send(stats);
} catch (error) {
strapi.log.error("Failed to get token cache stats:", error.message);
const safeError = extractSafeError(error);
return ctx.badRequest(safeError.message, { error: safeError });
}
},
/**
* Invalidates the cached Keycloak admin token.
* Use when credentials have changed or for testing.
*
* @async
* @param {KoaContext} ctx - Koa context
* @returns {Promise<void>}
*
* @example
* // POST /strapi-keycloak-passport/invalidate-token
* // Response: { success: true, message: "Token invalidated" }
*/
async invalidateToken(ctx) {
try {
const keycloakService2 = getKeycloakService();
const invalidated = keycloakService2.invalidateToken();
return ctx.send({
success: true,
message: invalidated ? "Token invalidated" : "No token to invalidate",
tokenWasPresent: invalidated
});
} catch (error) {
strapi.log.error("Failed to invalidate token:", error.message);
const safeError = extractSafeError(error);
return ctx.badRequest(safeError.message, { error: safeError });
}
}
};
const controllers = {
authController,
authOverrideController: AuthOverrideController
};
const policies = {};
const routes = [
/**
* Admin Login Override
* Replaces Strapi's default /admin/login with Keycloak authentication.
*/
{
method: "POST",
path: "/admin/login",
handler: "authOverrideController.login",
config: {
auth: false,
description: "Authenticate admin user via Keycloak OAuth2"
}
},
/**
* Get Keycloak and Strapi Roles
* Fetches available roles for mapping configuration.
*/
{
method: "GET",
path: "/keycloak-roles",
handler: "authController.getRoles",
config: {
auth: false,
policies: [],
middlewares: [checkAdminPermission(PERMISSIONS.ACCESS)],
description: "Fetch available Keycloak and Strapi admin roles"
}
},
/**
* Get Role Mappings
* Retrieves saved Keycloak-to-Strapi role mappings.
*/
{
method: "GET",
path: "/get-keycloak-role-mappings",
handler: "authController.getRoleMappings",
config: {
auth: false,
policies: [],
middlewares: [checkAdminPermission(PERMISSIONS.VIEW_MAPPINGS)],
description: "Get saved Keycloak to Strapi role mappings"
}
},
/**
* Save Role Mappings
* Persists Keycloak-to-Strapi role mappings.
*/
{
method: "POST",
path: "/save-keycloak-role-mappings",
handler: "authController.saveRoleMappings",
config: {
auth: false,
policies: [],
middlewares: [checkAdminPermission(PERMISSIONS.MANAGE_MAPPINGS)],
description: "Save Keycloak to Strapi role mappings"
}
},
/**
* Test Keycloak Connection
* Verifies connectivity to Keycloak server.
*/
{
method: "GET",
path: "/test-connection",
handler: "authController.testConnection",
config: {
auth: false,
policies: [],
middlewares: [checkAdminPermission(PERMISSIONS.ACCESS)],
description: "Test connection to Keycloak server"
}
},
/**
* Get Token Cache Statistics
* Returns information about cached admin tokens.
*/
{
method: "GET",
path: "/token-cache-stats",
handler: "authController.getTokenCacheStats",
config: {
auth: false,
policies: [],
middlewares: [checkAdminPermission(PERMISSIONS.ACCESS)],
description: "Get token cache statistics for monitoring"
}
},
/**
* Invalidate Cached Token
* Forces refresh of the cached admin token.
*/
{
method: "POST",
path: "/invalidate-token",
handler: "authController.invalidateToken",
config: {
auth: false,
policies: [],
middlewares: [checkAdminPermission(PERMISSIONS.MANAGE_MAPPINGS)],
description: "Invalidate the cached Keycloak admin token"
}
}
];
const adminUserService = ({ strapi: strapi2 }) => ({
/**
* Gets the Keycloak service instance.
*
* @returns {Object} Keycloak service
* @private
*/
_getKeycloakService() {
return strapi2.plugin(PLUGIN_ID).service("keycloakService");
},
/**
* Gets the role mapping service instance.
*
* @returns {Object} Role mapping service
* @private
*/
_getRoleMappingService() {
return strapi2.service(SERVICES.ROLE_MAPPING);
},
/**
* Gets the audit log service instance.
*
* @returns {Object} Audit log service
* @private
*/
_getAuditLogService() {
return strapi2.plugin(PLUGIN_ID).service("auditLogService");
},
/**
* Gets the plugin configuration.
*
* @returns {Object} Plugin configuration
* @private
*/
_getConfig() {
return strapi2.config.get(PLUGIN_NAMESPACE);
},
/**
* Extracts normalized user data from Keycloak userinfo response.
*
* @param {KeycloakUserInfo} userInfo - Keycloak user info
* @returns {Object} Normalized user data
* @private
*/
_normalizeUserData(userInfo) {
return {
email: userInfo.email?.toLowerCase()?.trim(),
username: userInfo.preferred_username || "",
firstname: userInfo.given_name || "",
lastname: userInfo.family_name || "",
keycloakUserId: userInfo.sub
};
},
/**
* Finds an existing admin user by email.
*
* @param {string} email - User's email address
* @returns {Promise<StrapiAdminUser|null>} Found user or null
* @private
*/
async _findUserByEmail(email) {
const [user] = await strapi2.entityService.findMany("admin::user", {
filters: { email },
populate: { roles: true },
limit: 1
});
return user || null;
},
/**
* Maps Keycloak roles to Strapi role IDs using stored mappings.
*
* @param {string[]} keycloakRoles - Array of Keycloak role names
* @returns {Promise<number[]>} Array of Strapi role IDs
* @private
*/
async _mapRolesToStrapi(keycloakRoles) {
const roleMappingService2 = this._getRoleMappingService();
const config2 = this._getConfig();
const mappings = await roleMappingService2.getMappings();
const strapiRoleIds = /* @__PURE__ */ new Set();
for (const keycloakRole of keycloakRoles) {
const mapping = mappings.find((m) => m.keycloakRole === keycloakRole);
if (mapping) {
strapiRoleIds.add(mapping.strapiRole);
}
}
if (strapiRoleIds.size === 0) {
strapiRoleIds.add(config2.roleConfigs.defaultRoleId);
}
return Array.from(strapiRoleIds);
},
/**
* Determines if user roles need to be updated.
*
* @param {number[]} currentRoleIds - Current Strapi role IDs
* @param {number[]} newRoleIds - New role IDs from mapping
* @returns {boolean} True if roles need updating
* @private
*/
_rolesNeedUpdate(currentRoleIds, newRoleIds) {
if (currentRoleIds.length !== newRoleIds.length) {
return true;
}
const currentSet = new Set(currentRoleIds);
return newRoleIds.some((id) => !currentSet.has(id));
},
/**
* Creates a new admin user with the given data.
*
* @param {Object} userData - User data to create
* @param {number[]} roleIds - Role IDs to assign
* @param {Object} [ctx] - Koa context for audit logging
* @returns {Promise<StrapiAdminUser>} Created user
* @private
*/
async _createUser(userData, roleIds, ctx = null) {
const auditLogService2 = this._getAuditLogService();
const newUser = await strapi2.entityService.create("admin::user", {
data: {
email: userData.email,
firstname: userData.firstname,
lastname: userData.lastname,
username: userData.username,
isActive: true,
roles: roleIds
}
});
strapi2.log.info(`Created new admin user: ${userData.email}`);
auditLogService2.logUserCreated({
email: userData.email,
userId: String(newUser.id),
keycloakUserId: userData.keycloakUserId,
roles: roleIds
}, ctx);
return newUser;
},
/**
* Updates an existing admin user.
*
* @param {StrapiAdminUser} existingUser - User to update
* @param {Object} userData - New user data
* @param {number[]} roleIds - New role IDs
* @param {Object} [ctx] - Koa context for audit logging
* @returns {Promise<StrapiAdminUser>} Updated user
* @private
*/
async _updateUser(existingUser, userData, roleIds, ctx = null) {
const auditLogService2 = this._getAuditLogService();
const currentRoleIds = (existingUser.roles || []).map((r) => r.id);
const changes = {};
if (userData.firstname !== existingUser.firstname) {
changes.firstname = { from: existingUser.firstname, to: userData.firstname };
}
if (userData.lastname !== existingUser.lastname) {
changes.lastname = { from: existingUser.lastname, to: userData.lastname };
}
if (this._rolesNeedUpdate(currentRoleIds, roleIds)) {
changes.roles = { from: currentRoleIds, to: roleIds };
}
if (Object.keys(changes).length === 0) {
strapi2.log.debug(`No changes detected for user: ${userData.email}`);
return existingUser;
}
const updatedUser = await strapi2.documents("admin::user").update({
documentId: existingUser.documentId,
data: {
firstname: userData.firstname,
lastname: userData.lastname,
roles: roleIds
}
});
strapi2.log.info(`Updated admin user: ${userData.email}`, { changes });
auditLogService2.logUserUpdated({
email: userData.email,
userId: String(existingUser.id),
changes
}, ctx);
return updatedUser;
},
/**
* Finds or creates an admin user in Strapi and assigns the correct role.
* This is the main entry point for user synchronization during login.
*
* @async
* @param {KeycloakUserInfo} userInfo - User data from Keycloak
* @param {Object} [ctx] - Koa context for audit logging and client info
* @returns {Promise<StrapiAdminUser>} The created or updated Strapi admin user
* @throws {PluginError} If user sync fails
*
* @example
* const adminUser = await adminUserService.findOrCreate({
* email: 'user@example.com',
* sub: 'keycloak-user-id',
* given_name: 'John',
* family_name: 'Doe'
* }, ctx);
*/
async findOrCreate(userInfo, ctx = null) {
if (!userInfo?.email) {
throw createSanitizedError(
ERROR_CODES.INVALID_PAYLOAD,
"User info missing required email field",
"Invalid user data received from authentication provider."
);
}
if (!userInfo?.sub) {
throw createSanitizedError(
ERROR_CODES.INVALID_PAYLOAD,
"User info missing required sub (Keycloak user ID) field",
"Invalid user data received from authentication provider."
);
}
try {
const userData = this._normalizeUserData(userInfo);
const existingUser = await this._findUserByEmail(userData.email);
let keycloakRoles = [];
try {
const keycloakService2 = this._getKeycloakService();
keycloakRoles = await keycloakService2.fetchUserRoles(userData.keycloakUserId);
} catch (error) {
strapi2.log.warn(`Failed to fetch Keycloak roles for ${userData.email}:`, error.message);
}
const strapiRoleIds = await this._mapRolesToStrapi(keycloakRoles);
if (!existingUser) {
return await this._createUser(userData, strapiRoleIds, ctx);
}
return await this._updateUser(existingUser, userData, strapiRoleIds, ctx);
} catch (error) {
if (error.code && error.safeMessage) {
throw error;
}
strapi2.log.error(`Failed to sync user ${userInfo.email}:`, error.message);
throw createSanitizedError(
ERROR_CODES.USER_SYNC_ERROR,
`User synchronization failed: ${error.message}`,
"Failed to synchronize user account."
);
}
},
/**
* Finds an admin user by their email address.
*
* @async
* @param {string} email - User's email address
* @returns {Promise<StrapiAdminUser|null>} Found user or null
*
* @example
* const user = await adminUserService.findByEmail('user@example.com');
*/
async findByEmail(email) {
if (!email || typeof email !== "string") {
return null;
}
return this._findUserByEmail(email.toLowerCase().trim());
},
/**
* Deactivates an admin user account.
*
* @async
* @param {string} email - User's email address
* @param {Object} [performedBy] - Admin performing the action
* @returns {Promise<boolean>} True if user was deactivated
*/
async deactivateUser(email, performedBy = null) {
const user = await this._findUserByEmail(email);
if (!user) {
strapi2.log.warn(`Cannot deactivate non-existent user: ${email}`);
return false;
}
await strapi2.documents("admin::user").update({
documentId: user.documentId,
data: { isActive: false }
});
strapi2.log.info(`Deactivated admin user: ${email}`);
const auditLogService2 = this._getAuditLogService();
auditLogService2.logUserUpdated({
email,
userId: String(user.id),
changes: { isActive: { from: true, to: false } }
});
return true;
},
/**
* Gets the default role ID from configuration.
*
* @returns {number} Default role ID
*/
getDefaultRoleId() {
const config2 = this._getConfig();
return config2.roleConfigs.defaultRoleId;
},
/**
* Checks if a user exists by email.
*
* @async
* @param {string} email - User's email address
* @returns {Promise<boolean>} True if user exists
*/
async userExists(email) {
const user = await this._findUserByEmail(email);
return user !== null;
}
});
const roleMappingService = ({ strapi: strapi2 }) => ({
/**
* Gets the audit log service instance.
*
* @returns {Object} Audit log service
* @private
*/
_getAuditLogService() {
return strapi2.plugin(PLUGIN_ID).service("auditLogService");
},
/**
* Validates a role mapping entry.
*
* @param {string} keycloakRole - Keycloak role name
* @param {number} strapiRole - Strapi role ID
* @throws {PluginError} If validation fails
* @private
*/
_validateMapping(keycloakRole, strapiRole) {
if (!keycloakRole || typeof keycloakRole !== "string") {
throw createSanitizedError(
ERROR_CODES.INVALID_PAYLOAD,
`Invalid Keycloak role: ${keycloakRole}`,
"Invalid Keycloak role provided."
);
}
if (keycloakRole.trim().length === 0) {
throw createSanitizedError(
ERROR_CODES.INVALID_PAYLOAD,
"Keycloak role cannot be empty",
"Keycloak role name is required."
);
}
const roleId = Number(strapiRole);
if (!Number.isInteger(roleId) || roleId < 1) {
throw createSanitizedError(
ERROR_CODES.INVALID_PAYLOAD,
`Invalid Strapi role ID: ${strapiRole} (coerced: ${roleId})`,
"Invalid Strapi role ID provided."
);
}
},
/**
* Coerces a strapiRole value (string or number) to a safe integer.
* Guards the service layer against type inconsistencies from HTTP payloads.
*
* @param {string|number} strapiRole - Raw role ID from request
* @returns {number} Parsed integer role ID
* @private
*/
_coerceRoleId(strapiRole) {
return Number(strapiRole);
},
/**
* Saves multiple role mappings, replacing all existing mappings.
* This is an atomic operation - all mappings are replaced at once.
*
* @async
* @param {Object<string, number>} mappings - Object mapping Keycloak roles to Strapi role IDs
* @param {Object} [performedBy] - Admin user performing the action
* @param {number} [performedBy.id] - Admin user ID
* @param {string} [performedBy.email] - Admin user email
* @returns {Promise<RoleMapping[]>} Array of created mappings
* @throws {PluginError} If save operation fails
*
* @example
* await roleMappingService.saveMappings({
* 'ADMIN': 1,
* 'EDITOR': 2,
* 'VIEWER': 3
* }, { id: 1, email: 'admin@example.com' });
*/
async saveMappings(mappings, performedBy = null) {
const auditLogService2 = this._getAuditLogService();
const entries = Object.entries(mappings);
for (const [keycloakRole, strapiRole] of entries) {
this._validateMapping(keycloakRole, strapiRole);
}
try {
await strapi2.db.query(CONTENT_TYPES.ROLE_MAPPING).deleteMany({
where: {
id: { $notNull: true }
}
});
const createdMappings = [];
for (const [keycloakRole, strapiRole] of entries) {
const normalizedRole = keycloakRole.trim();
const roleId = this._coerceRoleId(strapiRole);
const mapping = await strapi2.entityService.create(CONTENT_TYPES.ROLE_MAPPING, {
data: {
keycloakRole: normalizedRole,
strapiRole: roleId
}
});
createdMappings.push(mapping);
auditLogService2.logMappingChange({
action: AUDIT_ACTIONS.MAPPING_CREATED,
keycloakRole: normalizedRole,
strapiRole: roleId,
performedBy
});
}
strapi2.log.info(`Saved ${createdMappings.length} role mappings`);
return createdMappings;
} catch (error) {
if (error.code && error.safeMessage) {
throw error;
}
strapi2.log.error("Failed to save role mappings:", error.message);
throw createSanitizedError(
ERROR_CODES.ROLE_MAPPING_ERROR,
`Failed to save role mappings: ${error.message}`,
"Failed to save role mappings."
);
}
},
/**
* Retrieves all role mappings from the database.
*
* @async
* @returns {Promise<RoleMapping[]>} Array of role mappings
* @throws {PluginError} If retrieval fails
*
* @example
* const mappings = await roleMappingService.getMappings();
* // [{ keycloakRole: 'ADMIN', strapiRole: 1 }, ...]
*/
async getMappings() {
try {
const mappings = await strapi2.entityService.findMany(CONTENT_TYPES.ROLE_MAPPING, {
sort: { keycloakRole: "asc" }
});
return mappings || [];
} catch (e