From eacf90e4331cd118e0f6fbae83034f94b53100ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 12 Jun 2026 20:15:02 +0400 Subject: [PATCH] =?UTF-8?q?v2:=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=20auth/con?= =?UTF-8?q?fig/server=20=D0=BA=205c09844=20=E2=80=94=20=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=8B=D0=B9=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=B0=D1=83=D1=82=D0=B5=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- v2/server.js | 11 +- v2/src/auth/index.js | 302 +++++++++++++++++++++++++++++++++++++---- v2/src/config/index.js | 50 ++++--- 4 files changed, 305 insertions(+), 60 deletions(-) diff --git a/package.json b/package.json index 8923b04..4822188 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.71", + "version": "0.5.72", "description": "IP WhiteList microservice for cloud provider", "main": "server.js", "scripts": { diff --git a/v2/server.js b/v2/server.js index 1197cf3..1dc0495 100644 --- a/v2/server.js +++ b/v2/server.js @@ -8,7 +8,6 @@ const express = require('express'); const config = require('./src/config'); const auth = require('./src/auth'); -const crud = require('./src/crud'); const oidcConfig = { baseUrl: config.kcBaseUrl, @@ -22,11 +21,6 @@ const oidcConfig = { * Сессия используется общая, но ключи — с префиксом v2_ чтобы не пересекаться. */ function createV2Router() { - // Автосоздание таблиц при первом обращении - const { pool } = require('./src/db'); - const { ensureSchema } = require('./src/db/schema'); - ensureSchema(pool).catch(e => console.error('[v2:db] Schema error:', e.message)); - const router = express.Router(); // ── GET /v2/ — главная (вывод IAM-данных) ─────────────────────────────── @@ -46,7 +40,7 @@ function createV2Router() { .logout { color: #ef5350; } .label { color: #888; } -

✅ Вход выполнен v${config.version}

+

✅ Вход выполнен

Email: ${u.email}

clientId: ${u.clientId} (активная компания)

Компания: ${u.companyName}

@@ -108,9 +102,6 @@ ${u.isImpersonated ? `

⚠ } }); - // ── CRUD (/v2/app) ──────────────────────────────────────────── - router.use('/app', crud.createCrudRouter()); - // ── GET /v2/logout ─────────────────────────────────────────────────────── router.get('/logout', (req, res) => { delete req.session.v2_user; diff --git a/v2/src/auth/index.js b/v2/src/auth/index.js index 1aa236c..3718f05 100644 --- a/v2/src/auth/index.js +++ b/v2/src/auth/index.js @@ -1,8 +1,64 @@ -// Модуль аутентификации V2 +// ═══════════════════════════════════════════════════════════════════════════════ +// Модуль аутентификации +// ═══════════════════════════════════════════════════════════════════════════════ +// +// Два источника данных: +// 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'); @@ -14,6 +70,30 @@ function buildAuthUrl(config) { 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'; @@ -24,16 +104,73 @@ async function exchangeCode(code, config) { 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] || {}; @@ -41,43 +178,88 @@ async function fetchIamUser(token, iamUrl) { const imp = raw.impersonation || {}; return { + // ── из userInfo ── email: ui.email || '', - clientId: ui.clientID || activeProfile.client_id || '', - companyId: ui.companyId || '', + 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 || null, - profiles, - allClientIds: profiles.map(p => p.client_id).filter(Boolean), + + // ── из 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.access_token; + 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, - allClientIds: iamData.allClientIds, - activeClientId: iamData.clientId, + 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, + profiles: iamData.profiles, // все профили (для переключения компаний) + + // имперсонация isImpersonated: iamData.isImpersonated, impersonationType: iamData.impersonationType, originalUserEmail: iamData.originalUserEmail, @@ -85,52 +267,126 @@ async function login(code, oidcConfig, iamUrl) { originalUserCompany: iamData.originalUserCompany, }; - return { sessionUser, token: accessToken, idToken: tokenData.id_token || null }; + 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' }, + 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('Too large')); } }); + 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))); + 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('Timeout')); }); + 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' }, + 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))); + 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('Timeout')); }); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error('POST request timeout (10s)')); + }); req.write(postData); req.end(); }); } -module.exports = { login, buildAuthUrl, exchangeCode, fetchIamUser, generateState }; +// ═══════════════════════════════════════════════════════════════════════════════ +// Экспорт +// ═══════════════════════════════════════════════════════════════════════════════ + +module.exports = { + // основной — один вызов на сессию + login, + + // атомарные (для гибкости / тестов) + buildAuthUrl, + exchangeCode, + fetchIamUser, + generateState, +}; diff --git a/v2/src/config/index.js b/v2/src/config/index.js index 31c0149..4167f90 100644 --- a/v2/src/config/index.js +++ b/v2/src/config/index.js @@ -1,37 +1,35 @@ // ═══════════════════════════════════════════════════════════════════════════════ // Конфигурация V2 +// +// Приоритет: V2_* → основные → умолчания (локальный KC на ВМ) // ═══════════════════════════════════════════════════════════════════════════════ -// -// ⚠️ КОСТЫЛЬ — TEST_TOKEN (testToken): -// Временный токен WZ01112 / ntazetdinov@nubes.ru, в обход Keycloak. -// Используется для теста CRUD с реальными данными IAM (пока нет production-KC). -// -// КАК УБРАТЬ: задать V2_TEST_TOKEN='' на платформе → login() вернётся -// к нормальному OIDC-потоку через Keycloak. -// -// ПРИ ПЕРЕХОДЕ НА PRODUCTION KEYCLOAK: -// 1. Добавить redirect URI в keycloak.nubes.ru/realms/cloud -// 2. Задать V2_KC_* env на платформе -// 3. V2_TEST_TOKEN='' (отключить костыль) -// 4. Редеплой -// ═══════════════════════════════════════════════════════════════════════════════ + +require('dotenv').config({ path: require('path').join(__dirname, '..', '..', '..', '.env') }); module.exports = { - version: '0.5.71', + // Keycloak OIDC + kcBaseUrl: process.env.V2_KC_BASE_URL + || process.env.KC_BASE_URL + || 'https://italo.kube5s.ru/realms/ipwhitelist', - // ⚠️ КОСТЫЛЬ — убрать при production-KC - testToken: process.env.V2_TEST_TOKEN !== '' - ? (process.env.V2_TEST_TOKEN || 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWFwaSIsInN1YiI6IjAxOWNjMjY4LTZjNmEtNzgxZS04NjEzLTRiZWQ0ZWM3Y2QyMCIsImV4cCI6MTc5NjExODA2NywiaWF0IjoxNzgwNTY2MDY3LCJqdGkiOiJjMzJlNWIwMy05YjBlLTRjNGEtYTY0MS1lOTE0YWQ4MWRiMDQiLCJhdXRoX3RpbWUiOjAsInR5cCI6IiIsImF6cCI6IiIsInNlc3Npb25fc3RhdGUiOiIiLCJhY3IiOiIiLCJhbGxvd2VkLW9yaWdpbnMiOm51bGwsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6bnVsbH0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpudWxsfX0sInNjb3BlIjoiIiwic2lkIjoiIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuYW1lIjoiIiwiQ2xpZW50SUQiOiJXWjAzNzA5IiwiY29tcGFueV9pZCI6IjAxOWNjMjRhLTcyN2UtNzQwZi1iNDA3LWJlYzc5ZGFiNDE2MiIsImNvbXBhbnlfbmFtZSI6Im5hZWVsX3Rlc3QiLCJ0b2tlbl90eXBlIjoidGVjaCIsImlkcF91c3JfdWlkIjoiMDE5Y2MyNjgtNmM2YS03ODFlLTg2MTMtNGJlZDRlYzdjZDIwIiwibG9naW4iOiJ0YXpldGRpbm92bkBnbWFpbC5jb20iLCJmaXJzdG5hbWUiOiLQndCw0LjQu9GMIiwibWlkZGxlbmFtZSI6ItCk0LDRgNC40YLQvtCy0LjRhyIsImxhc3RuYW1lIjoi0KLQsNC30LXRgtC00LjQvdC-0LIiLCJncm91cHMiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6IiIsImdpdmVuX25hbWUiOiIiLCJmYW1pbHlfbmFtZSI6IiIsImVtYWlsIjoidGF6ZXRkaW5vdm5AZ21haWwuY29tIn0.Pc0NJsauTQQApbPgDjFZd9phceMvfN4usaa8Rw9wGeMUvKR6EOAC6F_9in6zhcgK0zFsdbpsSzeGaNLoJNpadbshAWmGKVxprbMZbCuuxvDFzraIHTw0okgvh-4XPN8NhzqI0taTWjN9Wdl5hWBpHBQFpgRlf9u-jMwqmSQQ15wZHiOc_x1Xh_IKhtBGC4duYlvnzVzzWgRVvCNzs73dnPhCAKNn17epZujq0QHlzkkg2WeCo24iiiKrIpssW6YUiM9aKezwbYxGvZZOePX-RjcapBKFjNmpjI2UjEOMroQUtS7ei1cgU9VN5_QjmGKmSSXF2JBtcSGb52qAhYDwQQ') - : '', + kcClientId: process.env.V2_KC_CLIENT_ID + || process.env.KC_CLIENT_ID + || 'ipwhitelist', - // Keycloak — тестовый (ВМ) - kcBaseUrl: process.env.V2_KC_BASE_URL || 'https://italo.kube5s.ru/realms/ipwhitelist', - kcClientId: process.env.V2_KC_CLIENT_ID || 'ipwhitelist', - kcClientSecret: process.env.V2_KC_CLIENT_SECRET || '40bpIZvumq6jttIB5QtTLcl9AJvAHxlw', + kcClientSecret: process.env.V2_KC_CLIENT_SECRET + || process.env.KC_CLIENT_SECRET + || '', // IAM API - iamUrl: process.env.V2_IAM_API_URL || 'https://auth-api.ngcloud.ru', + iamUrl: process.env.V2_IAM_API_URL + || process.env.IAM_API_URL + || 'https://auth-api-dev.ngcloud.ru', - // URL приложения - appUrl: process.env.V2_APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru', + // Наше приложение (НЕ Keycloak!) + appUrl: process.env.V2_APP_URL + || process.env.APP_URL + || 'https://italo.kube5s.ru', + + // Админ + adminClientId: process.env.ADMIN_CLIENT_ID || 'WZ01112', };