// ═══════════════════════════════════════════════════════════════════════════════ // Модуль аутентификации // ═══════════════════════════════════════════════════════════════════════════════ // // Два источника данных: // 1. Keycloak OIDC — токены (access_token, id_token) // 2. IAM API — профиль пользователя (компании, роли, имперсонация) // // После вызова login() ни Keycloak, ни IAM больше не нужны. // Всё что требуется для сессии — в возвращаемом объекте sessionUser. // // Экспорт: // login(code, oidcConfig, iamUrl) → { sessionUser, token, idToken } // — полный цикл: обмен code → токены → IAM → готовый объект. // Вызывается ОДИН раз при логине. // // buildAuthUrl(config) → string // — URL для редиректа пользователя на Keycloak. // // exchangeCode(code, config) → { accessToken, idToken, ... } // — обмен authorization_code на токены (обратный вызов Keycloak). // // fetchIamUser(token, iamUrl) → { email, clientId, profiles, ... } // — запрос профиля пользователя у IAM. // // generateState() → string // — случайная строка для OAuth state (CSRF-защита). // ═══════════════════════════════════════════════════════════════════════════════ const crypto = require('crypto'); const https = require('https'); const http = require('http'); const jwt = require('jsonwebtoken'); // ═══════════════════════════════════════════════════════════════════════════════ // 1. Keycloak OIDC — получение токенов // ═══════════════════════════════════════════════════════════════════════════════ /** * buildAuthUrl — строит URL для редиректа пользователя на Keycloak. * * Это ПЕРВЫЙ шаг OIDC-потока: * 1. Генерируем state (через generateState) * 2. Сохраняем state в сессии (для проверки в callback) * 3. Редиректим пользователя на этот URL * * Keycloak показывает форму входа, после успешного входа редиректит * обратно на redirectUri с параметрами ?code=...&state=... * * @param {object} config * .baseUrl — URL Keycloak realm, например "https://keycloak.nubes.ru/realms/cloud" * .clientId — client_id приложения в Keycloak * .redirectUri — callback URL нашего приложения (должен совпадать с настройками Keycloak) * .state — случайная строка для CSRF-защиты (результат generateState()) * @returns {string} — полный URL для редиректа * * Пример возврата: * "https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/auth * ?client_id=ipwhitelist&redirect_uri=https://app.ru/callback&response_type=code * &scope=openid+profile+email&state=a1b2c3..." */ function buildAuthUrl(config) { const { baseUrl, clientId, redirectUri, state } = config; const authUrl = new URL(baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/auth'); authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('scope', 'openid profile email'); authUrl.searchParams.set('state', state); return authUrl.toString(); } /** * exchangeCode — обменивает authorization_code на токены. * * Это ВТОРОЙ шаг OIDC-потока: * Keycloak редиректит пользователя обратно с ?code=... * Мы отправляем этот code на token endpoint и получаем токены. * * Запрос: POST /protocol/openid-connect/token * Content-Type: application/x-www-form-urlencoded * grant_type=authorization_code&code=...&client_id=...&client_secret=...&redirect_uri=... * * @param {string} code — authorization_code из query-параметра callback-URL * @param {object} config * .baseUrl — URL Keycloak realm * .clientId — client_id приложения * .clientSecret — client_secret приложения (секретный!) * .redirectUri — тот же redirect_uri что и в buildAuthUrl * @returns {object} — ответ Keycloak: * .accessToken — JWT для доступа к API (передаём в IAM) * .idToken — JWT с информацией о пользователе (опционально) * .refreshToken — для обновления (не используется) * .expiresIn — срок действия access_token * @throws {Error} — если Keycloak вернул не 200 (неверный code, secret, redirect_uri) */ async function exchangeCode(code, config) { const { baseUrl, clientId, clientSecret, redirectUri } = config; const tokenUrl = baseUrl.replace(/\/$/, '') + '/protocol/openid-connect/token'; const body = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, }); return postForm(tokenUrl, body); } /** * generateState — генерирует случайную строку для OAuth state. * * Зачем: защита от CSRF в OAuth-потоке. * 1. Генерируем → сохраняем в сессию * 2. Передаём в buildAuthUrl * 3. Keycloak возвращает state в callback * 4. Сверяем: req.query.state === req.session.oidcState * * @returns {string} — 32 hex-символа (16 байт) */ function generateState() { return crypto.randomBytes(16).toString('hex'); } // ═══════════════════════════════════════════════════════════════════════════════ // 2. IAM API — профиль пользователя // ═══════════════════════════════════════════════════════════════════════════════ /** * fetchIamUser — запрашивает профиль пользователя у IAM. * * Это ТРЕТИЙ шаг аутентификации: * Имея access_token от Keycloak, запрашиваем /api/v1/auth/user * IAM возвращает: профили, роли, имперсонацию. * * Запрос: GET {iamUrl}/api/v1/auth/user * Authorization: Bearer {access_token} * Accept: application/json * * Ответ IAM (JSON): * userInfo: { email, clientID, companyId, company, isAdmin, fio } * profiles[]: [{ id, client_id, company_name, is_active_profile }] * impersonation: { is_impersonated, type, originalUserEmail, ... } * * Все поля плоские — не нужно лазить в userInfo.email, просто email. * * @param {string} token — access_token от Keycloak (из exchangeCode) * @param {string} iamUrl — IAM_API_URL, например "https://auth-api-dev.ngcloud.ru" * @returns {object} — плоский объект со всеми полями: * .email — email пользователя * .clientId — W-номер активной компании (WZ01112) * .companyId — внутренний ID IAM (не W-формат, почти не используется) * .companyName — название активной компании * .isAdmin — флаг администратора (из userInfo.isAdmin) * .fio — полное имя * .profiles — массив всех профилей (все компании пользователя) * .allClientIds — все W-номера (собираем из profiles[].client_id) * .activeProfileId — id активного профиля * .isImpersonated — активна ли имперсонация * .impersonationType — тип имперсонации * .originalUserEmail — кто реально (если имперсонация) * .originalUserFullName — ФИО реального * .originalUserCompany — компания реального * .impersonatedCompanyId — в чью компанию вошли * .raw — сырой JSON ответ IAM (для отладки) * @throws {Error} — если IAM недоступен, вернул не 200, или ответ не JSON */ async function fetchIamUser(token, iamUrl) { const url = new URL(iamUrl.replace(/\/$/, '') + '/api/v1/auth/user'); const data = await httpGet(url, token); const raw = JSON.parse(data); const profiles = raw.profiles || []; const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {}; const ui = raw.userInfo || {}; const imp = raw.impersonation || {}; return { // ── из userInfo ── email: ui.email || '', clientId: ui.clientID || activeProfile.client_id || '', // W-номер — активная компания companyId: ui.companyId || '', // внутр. ID IAM (не W-формат) companyName: ui.company || activeProfile.company_name || '', isAdmin: !!ui.isAdmin, fio: (ui.fio && ui.fio.fullName) || ui.fio || null, // ── из profiles ── profiles, // все компании (массив) allClientIds: profiles.map(p => p.client_id).filter(Boolean), // все W-номера (плоский массив) activeProfileId: activeProfile.id || null, // ── из impersonation ── isImpersonated: !!(imp.is_impersonated), impersonationType: imp.type || null, originalUserEmail: imp.originalUserEmail || '', originalUserFullName: imp.originalUserFullName || '', originalUserCompany: imp.originalUserCompany || '', impersonatedCompanyId: imp.impersonatedCompanyId || '', // ── сырой ответ (для отладки) ── raw, }; } // ═══════════════════════════════════════════════════════════════════════════════ // 3. Оркестратор — единая точка входа // ═══════════════════════════════════════════════════════════════════════════════ /** * login — полный цикл аутентификации. * * Выполняет последовательно: * 1. exchangeCode(code, oidcConfig) → accessToken * 2. fetchIamUser(accessToken, iamUrl) → профиль пользователя * 3. Собирает готовый sessionUser для записи в req.session.user * * После этого вызова: * — НЕ НУЖЕН Keycloak (токен получен) * — НЕ НУЖЕН IAM (профиль получен, switchProfile не нужен — компании переключаем в сессии) * * Вызывается ОДИН раз — в callback-роуте OIDC (/callback). * * @param {string} code — authorization_code из query-параметра callback-URL * @param {object} oidcConfig * .baseUrl — URL Keycloak realm * .clientId — client_id приложения * .clientSecret — client_secret приложения * .redirectUri — callback URL приложения * @param {string} iamUrl — IAM_API_URL * @returns {object} * .sessionUser — готовый объект для req.session.user: * { email, clientId, allClientIds, activeClientId, companyName, * isAdmin, fio, profiles, isImpersonated, originalUserEmail, ... } * .token — access_token (JWT для API-запросов) * .idToken — id_token (опционально) * @throws {Error} — на любом этапе (Keycloak не ответил, IAM недоступен, ...) */ async function login(code, oidcConfig, iamUrl) { // 1. Обмен code на токены Keycloak const tokenData = await exchangeCode(code, oidcConfig); const accessToken = tokenData.accessToken; if (!accessToken) throw new Error('No access_token in Keycloak response'); // 2. Профиль пользователя из IAM const iamData = await fetchIamUser(accessToken, iamUrl); // 3. Собираем sessionUser — всё что нужно для сессии const sessionUser = { email: iamData.email, clientId: iamData.clientId, // W-номер активной компании allClientIds: iamData.allClientIds, // все W-номера пользователя activeClientId: iamData.clientId, // активная компания (можно менять в сессии) activeProfileId: iamData.activeProfileId, companyId: iamData.companyId, companyName: iamData.companyName, isAdmin: iamData.isAdmin, fio: iamData.fio, profiles: iamData.profiles, // все профили (для переключения компаний) // имперсонация isImpersonated: iamData.isImpersonated, impersonationType: iamData.impersonationType, originalUserEmail: iamData.originalUserEmail, originalUserFullName: iamData.originalUserFullName, originalUserCompany: iamData.originalUserCompany, }; return { sessionUser, token: accessToken, idToken: tokenData.idToken || null, }; } // ═══════════════════════════════════════════════════════════════════════════════ // 4. HTTP-хелперы (внутренние, не экспортируются) // ═══════════════════════════════════════════════════════════════════════════════ /** * httpGet — GET-запрос с Bearer-токеном. * * Особенности: * — Только JSON-ответы (Accept: application/json) * — Таймаут 10 секунд * — Лимит ответа 100 KB (защита от переполнения памяти) * — Авто-выбор http/https по URL * * @param {URL} url — parsed URL (new URL(...)) * @param {string} token — Bearer-токен * @returns {string} — тело ответа * @throws {Error} — HTTP не 200, таймаут, или ответ > 100 KB */ function httpGet(url, token) { const lib = url.protocol === 'https:' ? https : http; return new Promise((resolve, reject) => { const req = lib.request({ hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method: 'GET', headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' }, }, (res) => { let data = ''; res.on('data', c => { data += c; if (data.length > 100_000) { req.destroy(); reject(new Error('Response too large (>100KB)')); } }); res.on('end', () => { if (res.statusCode !== 200) { return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`)); } resolve(data); }); }); req.on('error', reject); req.setTimeout(10000, () => { req.destroy(); reject(new Error('HTTP request timeout (10s)')); }); req.end(); }); } /** * postForm — POST-запрос с form-urlencoded телом. * * Используется для Keycloak token endpoint. * * Особенности: * — Content-Type: application/x-www-form-urlencoded * — Accept: application/json (ожидаем JSON в ответе) * — Таймаут 10 секунд * * @param {string} urlStr — полный URL * @param {URLSearchParams} body — параметры формы * @returns {object} — распарсенный JSON-ответ * @throws {Error} — HTTP не 200, таймаут, или ответ не JSON */ function postForm(urlStr, body) { const url = new URL(urlStr); const lib = url.protocol === 'https:' ? https : http; const postData = body.toString(); return new Promise((resolve, reject) => { const req = lib.request({ hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData), 'Accept': 'application/json', }, }, (res) => { let data = ''; res.on('data', c => data += c); res.on('end', () => { if (res.statusCode !== 200) { return reject(new Error(`POST ${res.statusCode}: ${data.slice(0, 200)}`)); } try { resolve(JSON.parse(data)); } catch (e) { reject(e); } }); }); req.on('error', reject); req.setTimeout(10000, () => { req.destroy(); reject(new Error('POST request timeout (10s)')); }); req.write(postData); req.end(); }); } // ═══════════════════════════════════════════════════════════════════════════════ // Экспорт // ═══════════════════════════════════════════════════════════════════════════════ module.exports = { // основной — один вызов на сессию login, // атомарные (для гибкости / тестов) buildAuthUrl, exchangeCode, fetchIamUser, generateState, };