From 32cb0f4ac6a3ff6c1b71a21ea4a8f63b5e722765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 10 Jun 2026 11:42:50 +0400 Subject: [PATCH] fix: block clientId login in OIDC mode (crash fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui/routes/auth.js: auth.isOidc → redirect to /login with error instead of calling issueMockToken which throws 'Mock keypair not initialized' - This was crashing the process via unhandled promise rejection --- package.json | 2 +- tests/sso-flow.js | 193 ++++++++++++++++++++++++++++++++++++++++++++++ ui/routes/auth.js | 5 ++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 tests/sso-flow.js diff --git a/package.json b/package.json index 0e88400..330bc0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipwhitelist", - "version": "0.5.34", + "version": "0.5.35", "description": "IP WhiteList microservice for cloud provider", "main": "server.js", "scripts": { diff --git a/tests/sso-flow.js b/tests/sso-flow.js new file mode 100644 index 0000000..fe00caa --- /dev/null +++ b/tests/sso-flow.js @@ -0,0 +1,193 @@ +/** + * tests/sso-flow.js — E2E-тест SSO через Keycloak. + * + * Проверяет полный поток: + * 1. GET /login → редирект на Keycloak + * 2. Keycloak login page → ввод логина/пароля + * 3. POST login → редирект с code обратно в приложение + * 4. /callback → обмен code на токен, сессия + * 5. GET / → список записей + * 6. CRUD в сессии + * 7. GET /logout → редирект на Keycloak logout → приложение + * + * Запуск: + * KC_LOGIN=testuser KC_PASS=test123 node tests/sso-flow.js + * + * Переменные окружения: + * APP_URL — https://whitelist.nodejsk8s.services.ngcloud.ru (по умолчанию) + * KC_LOGIN — логин для Keycloak (обязательно) + * KC_PASS — пароль для Keycloak (обязательно) + */ + +'use strict'; + +const https = require('https'); +const http = require('http'); +const url = require('url'); + +const APP_URL = (process.env.APP_URL || 'https://whitelist.nodejsk8s.services.ngcloud.ru').replace(/\/$/, ''); + +let passed = 0, failed = 0; + +function test(name, fn) { + return Promise.resolve().then(fn).then(() => { passed++; console.log(' ✅ ' + name); }) + .catch(e => { failed++; console.log(' ❌ ' + name + ' — ' + (e.message || e).slice(0, 200)); }); +} + +function httpReq(uri, opts = {}) { + return new Promise((resolve, reject) => { + const u = new URL(uri.startsWith('http') ? uri : APP_URL + uri); + const lib = u.protocol === 'https:' ? https : http; + const req = lib.request({ + hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80), + path: u.pathname + u.search, method: opts.method || 'GET', + headers: opts.headers || {}, + }, res => { + let body = ''; + res.on('data', c => body += c); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body, + setCookie: res.headers['set-cookie'] })); + }); + req.on('error', reject); + req.setTimeout(15000, () => { req.destroy(); reject(new Error('Timeout')); }); + if (opts.body) req.write(opts.body); + req.end(); + }); +} + +function cookieHeader(setCookie) { + if (!setCookie) return {}; + const c = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie; + return { Cookie: c.split(';').map(x => x.split(';')[0]).join('; ') }; +} + +async function main() { + const KC_LOGIN = process.env.KC_LOGIN; + const KC_PASS = process.env.KC_PASS; + + if (!KC_LOGIN || !KC_PASS) { + console.log('❌ Задай KC_LOGIN и KC_PASS'); + console.log(' KC_LOGIN=testuser KC_PASS=test123 node tests/sso-flow.js'); + process.exit(1); + } + + console.log('\n🧪 SSO E2E — полный поток'); + console.log(' App:', APP_URL); + console.log(' User:', KC_LOGIN); + console.log(); + + let cookies = {}; + + // ── 1. GET /login → редирект на Keycloak ─────────────────────────── + await test('1. GET /login → 302 redirect to Keycloak', async () => { + const r = await httpReq('/login'); + if (r.status !== 302) throw new Error('Expected 302, got ' + r.status); + const loc = r.headers.location || ''; + if (!loc.includes('openid-connect/auth')) throw new Error('Not a KC redirect'); + if (!loc.includes('client_id=')) throw new Error('No client_id'); + cookies = cookieHeader(r.setCookie); + }); + + // ── 2. GET Keycloak login page ────────────────────────────────────── + let kcAction = ''; + await test('2. Keycloak login page renders', async () => { + const r1 = await httpReq('/login'); + const kcUrl = r1.headers.location; + const r2 = await httpReq(kcUrl, { headers: { ...cookies, ...cookieHeader(r1.setCookie) } }); + cookies = { ...cookies, ...cookieHeader(r2.setCookie) }; + // Извлекаем action URL + const m = r2.body.match(/action="(https:\/\/[^"]*login-actions\/authenticate[^"]*)"/); + if (!m) throw new Error('Cannot find login form action'); + kcAction = m[1].replace(/&/g, '&'); + if (kcAction.includes('realms/ipwhitelist')) return; // OK + throw new Error('Unexpected KC realm'); + }); + + // ── 3. POST логин в Keycloak ──────────────────────────────────────── + let callbackUrl = ''; + await test('3. POST login to Keycloak', async () => { + const body = `username=${encodeURIComponent(KC_LOGIN)}&password=${encodeURIComponent(KC_PASS)}&credentialId=`; + const r = await httpReq(kcAction, { + method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookies }, + body, + }); + if (r.status === 302 || r.status === 200) { + // Либо сразу редирект, либо страница с ошибкой + const loc = r.headers.location; + if (loc) { + if (!loc.includes(APP_URL)) throw new Error('Redirect not to app: ' + loc.slice(0, 80)); + callbackUrl = loc; + return; + } + // Может показать OTP/MFA или ошибку + if (r.body.includes('Invalid username or password')) throw new Error('Invalid credentials'); + if (r.body.includes('authenticate')) throw new Error('Form shown — maybe extra params needed'); + throw new Error('Unexpected KC response: ' + r.body.slice(0, 100)); + } + throw new Error('Expected 302, got ' + r.status); + cookies = { ...cookies, ...cookieHeader(r.setCookie) }; + }); + + // ── 4. /callback → сессия ─────────────────────────────────────────── + await test('4. GET /callback → session created', async () => { + const r = await httpReq(callbackUrl, { headers: cookies }); + if (r.status !== 302 && r.status !== 200) throw new Error('Expected 200/302, got ' + r.status); + cookies = { ...cookies, ...cookieHeader(r.setCookie) }; + }); + + // ── 5. GET / → страница загружена ─────────────────────────────────── + await test('5. GET / → 200 (после SSO)', async () => { + const r = await httpReq('/', { headers: cookies }); + if (r.status !== 200 && r.status !== 302) throw new Error('Expected 200/302, got ' + r.status); + }); + + // ── 6. Добавить запись ────────────────────────────────────────────── + await test('6. POST /add → 302 (успех)', async () => { + const r = await httpReq('/add', { + method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookies }, + body: 'value=1.2.3.4&comment=SSO-test', + }); + if (r.status !== 302) throw new Error('Expected 302, got ' + r.status); + const loc = r.headers.location || ''; + if (loc.includes('error=')) { + throw new Error('Error redirect: ' + decodeURIComponent(loc.match(/error=([^&]+)/)?.[1] || '')); + } + }); + + // ── 7. GET / → новая запись видна ─────────────────────────────────── + await test('7. GET / → запись 203.0.113.55/32 видна', async () => { + const r = await httpReq('/', { headers: cookies }); + if (!r.body.includes('1.2.3.4/32')) throw new Error('New entry not found'); + if (!r.body.includes('SSO-test')) throw new Error('Comment not found'); + }); + + // ── 8. GET /export ────────────────────────────────────────────────── + await test('8. GET /export → 200 text/plain', async () => { + const r = await httpReq('/export', { headers: cookies }); + if (r.status !== 200) throw new Error('Expected 200, got ' + r.status); + const ct = r.headers['content-type'] || ''; + if (!ct.includes('text/plain')) throw new Error('Not text/plain: ' + ct); + }); + + // ── 9. /logout ─────────────────────────────────────────────────────── + await test('9. GET /logout → 302', async () => { + const r = await httpReq('/logout', { headers: cookies }); + if (r.status !== 302) throw new Error('Expected 302, got ' + r.status); + }); + + // ── 10. GET / → редирект на /login ────────────────────────────────── + await test('10. GET / → 302 -> /login (сессия очищена)', async () => { + const r = await httpReq('/', { headers: cookies }); + const loc = r.headers.location || ''; + if (!loc.includes('/login')) throw new Error('Not redirected to /login: ' + loc); + }); + + // ── ИТОГ ──────────────────────────────────────────────────────────── + console.log(`\n══════════ ИТОГ ══════════`); + console.log(` ✅ Пройдено: ${passed}`); + console.log(` ❌ Провалено: ${failed}`); + console.log(` Всего: ${passed + failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(e => { console.error('FATAL:', e.message); process.exit(1); }); diff --git a/ui/routes/auth.js b/ui/routes/auth.js index 5fc32d6..e9cea81 100644 --- a/ui/routes/auth.js +++ b/ui/routes/auth.js @@ -137,6 +137,11 @@ function createRouter({ auth, MOCK_USERS, authLimiter }) { // ── Вход по clientId (отладка, mock) ─────────────────────────────────── if (!clientId || !clientId.trim()) return res.redirect('/login?error=Введите+clientId'); + // В OIDC-режиме mock-токен бессмысленен — редиректим на KC + if (auth.isOidc) { + return res.redirect('/login?error=Вход+по+clientId+недоступен+в+SSO-режиме.+Войдите+через+Keycloak.'); + } + const trimmed = clientId.trim(); const allClientIds = trimmed.split(',').map(s => s.trim()).filter(Boolean); const activeClientId = allClientIds[0];