UNPKG

strapi-keycloak-passport

Version:

Keycloak authentication provider for the Strapi v5 administration panel.

1,557 lines (1,556 loc) 84.2 kB
import axios from "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.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.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 (error) { strapi2.log.error("Failed to retrieve role mappings:", error.message); throw createSanitizedError( ERROR_CODES.ROLE_MAPPING_ERROR, `Failed to retrieve