From ba0779db51b58a29b9c93aa55da88b383382790f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 3 Jun 2026 06:12:22 +0300 Subject: [PATCH] =?UTF-8?q?v0.5.13:=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20OIDC=20(Keycloak)=20=E2=80=94=20?= =?UTF-8?q?=D1=80=D0=BE=D1=83=D1=82=D1=8B=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B,=20=D0=B6=D0=B4=D1=83=D1=82=20DevOps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/routes/oidc.js: /login, /callback, /logout Активны ТОЛЬКО при KC_CLIENT_ID+KC_CLIENT_SECRET Сейчас (mock) — пустой router, запросы уходят в UI - server.js: подключены OIDC-роуты - docs/deploy-keycloak.md: что нужно от DevOps для включения --- docs/deploy-keycloak.md | 68 ++++++++++++++++++++++++++++++ server.js | 6 +++ src/routes/oidc.js | 92 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 docs/deploy-keycloak.md create mode 100644 src/routes/oidc.js diff --git a/docs/deploy-keycloak.md b/docs/deploy-keycloak.md new file mode 100644 index 0000000..f13cd33 --- /dev/null +++ b/docs/deploy-keycloak.md @@ -0,0 +1,68 @@ +# Переход на Keycloak SSO (Nubes) + +## Как сейчас +- `DEV_MODE=true` — mock-аутентификация +- Любой clientId можно ввести вручную на `/login` +- JWT подписывается локальным RSA-ключом + +## Что нужно от DevOps (v0.5.13) + +### 1. Параметры для .env (на ВМ) + +```bash +# ── Отключить mock-режим (убрать DEV_MODE или поставить false) ── +# DEV_MODE=true + +# ── Keycloak realm (прямой или через API Gateway) ── +KC_BASE_URL=https://keycloak.nubes.ru/realms/cloud +# ИЛИ через API Gateway: +# KC_BASE_URL=https://lk-api-gateway-test.ngcloud.ru/api/v1/iam/auth + +# ── OAuth client (зарегистрированное приложение) ── +KC_CLIENT_ID=deck-test.ngcloud.ru +KC_CLIENT_SECRET=... + +# ── Публичный URL приложения (для редиректа из Keycloak) ── +APP_URL=https://white.nodejsk8s.dev.nubes.ru + +# ── JWKS URL для верификации токенов (если не через KC_BASE_URL) ── +# JWKS_URL=https://keycloak.nubes.ru/realms/cloud/protocol/openid-connect/certs +``` + +### 2. Уточнить + +| Вопрос | Описание | +|---|---| +| **API Gateway?** | Токен получать напрямую из Keycloak или через `lk-api-gateway-test.ngcloud.ru`? | +| **ClientID в токене** | Какой claim содержит ID компании? Сейчас ждём `ClientID` или `client_id`. | +| **Admin-роль** | Как определяется admin? `clientId === WZ01112` достаточно или нужен отдельный claim? | +| **Redirect URI** | Какой зарегистрирован? `https://white.nodejsk8s.dev.nubes.ru/callback`? | +| **Несколько компаний** | `clientid` в токене приходит как одна строка или через запятую? | + +### 3. Что сделано в коде (готово) + +| Компонент | Файл | +|---|---| +| JWKS-загрузка + кеширование | `src/auth.js` | +| Верификация RS256 JWT | `src/auth.js` | +| `buildAuthUrl()` — редирект на Keycloak | `src/auth.js` | +| `exchangeCode()` — обмен code→token | `src/auth.js` | +| `buildLogoutUrl()` — logout | `src/auth.js` | +| OIDC-роуты /login, /callback, /logout | `src/routes/oidc.js` | +| `userFromPayload()` — парсинг claims | `src/auth.js` | + +### 4. Порядок включения + +```bash +# 1. Настроить .env на ВМ (параметры выше) +# 2. Перезапустить +pm2 restart ipwhitelist --update-env + +# 3. Проверить что включился OIDC-режим +pm2 logs ipwhitelist --lines 3 +# Должно быть: "[auth] OIDC — Keycloak" +# Если видно "[auth] MOCK" — значит KC_CLIENT_ID не задан + +# 4. Перейти на https://white.nodejsk8s.dev.nubes.ru/login +# Должен редиректить на Keycloak вместо формы входа +``` diff --git a/server.js b/server.js index 16fae1d..84a0efa 100644 --- a/server.js +++ b/server.js @@ -99,6 +99,12 @@ async function start() { } ); + // ── OIDC-роуты (Keycloak) — активны ТОЛЬКО когда KC_CLIENT_ID задан ───── + // В mock-режиме (сейчас): заглушки, работает ui/routes/auth.js + // 🔮 В проде: задать KC_CLIENT_ID + KC_CLIENT_SECRET в .env + const { createRouter: createOidcRouter } = require('./src/routes/oidc'); + app.use('/', createOidcRouter({ auth })); + // ── REST API v1 (Bearer JWT, без сессий/CSRF) ───────────────────────────── const { createApiRouter } = require('./src/api/index'); app.use('/api/v1', createApiRouter({ auth, q })); diff --git a/src/routes/oidc.js b/src/routes/oidc.js new file mode 100644 index 0000000..b6a0417 --- /dev/null +++ b/src/routes/oidc.js @@ -0,0 +1,92 @@ +const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); +const { Router } = require('express'); + +function createRouter({ auth, doubleCsrfProtection, generateCsrfToken, authLimiter }) { + const router = Router(); + + // ── Если OIDC не активен — пропускаем запросы к следующему роуту (UI) ── + if (!auth.isOidc) { + // В mock-режиме /login, /callback, /logout обрабатывает ui/routes/auth.js + // Просто не регистрируем обработчики — запрос уйдёт дальше по middleware chain + return router; + } + + // ═════════════════════════════════════════════════════════════════════════ + // OIDC-режим — только когда есть KC_CLIENT_ID + KC_CLIENT_SECRET + // ═════════════════════════════════════════════════════════════════════════ + + // GET /login — редирект на Keycloak + router.get('/login', (req, res) => { + if (req.session && req.session.token) return res.redirect('/'); + + const state = crypto.randomBytes(16).toString('hex'); + req.session.oidcState = state; + req.session.oidcReturnTo = req.query.returnTo || '/'; + + const url = auth.buildAuthUrl(state); + res.redirect(url); + }); + + // GET /callback — обмен code → token → сессия + router.get('/callback', async (req, res) => { + const { code, state } = req.query; + + // Проверка state (защита от CSRF на callback) + if (!state || state !== req.session.oidcState) { + return res.status(403).send('Invalid state parameter'); + } + delete req.session.oidcState; + + if (!code) { + return res.status(400).send('Missing authorization code'); + } + + try { + // Обмен code на токен (через Keycloak или API Gateway) + const tokenData = await auth.exchangeCode(code); + const accessToken = tokenData.access_token; + + // Верификация токена (проверка подписи через JWKS) + // userFromPayload уже встроена в exchangeCode или делается здесь + const payload = jwt.decode(accessToken); + if (!payload) throw new Error('Failed to decode token'); + + // Определяем компании пользователя + const rawClientId = payload.ClientID || payload.client_id || ''; + const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean); + const activeClientId = allClientIds[0] || rawClientId; + + req.session.token = accessToken; + req.session.user = { + clientId: rawClientId, + allClientIds, + activeClientId, + email: payload.email || payload.login || '', + companyId: payload.company_id || payload.companyId || null, + companyName: payload.company_name || payload.companyName || activeClientId, + isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'), + }; + + const returnTo = req.session.oidcReturnTo || '/'; + delete req.session.oidcReturnTo; + res.redirect(returnTo); + } catch (e) { + console.error('[oidc] Callback error:', e); + res.status(500).send('Authentication failed: ' + e.message); + } + }); + + // GET /logout + router.get('/logout', (req, res) => { + const idToken = req.session && req.session.idToken; + req.session.destroy(() => { + const url = auth.buildLogoutUrl(idToken); + res.redirect(url); + }); + }); + + return router; +} + +module.exports = { createRouter };