feat: IAM API интеграция + обновление ТЗ + тесты

- src/auth.js: fetchIamUser(), switchProfile(), userFromPayload(iamData)
- src/config.js: IAM_API_URL из env
- src/routes/oidc.js: обогащение сессии через IAM (с fallback на JWT)
- ui/routes/auth.js: обогащение при токен-логине
- ui/routes/entries.js: switchTo через POST /switch-profile
- src/api/routes/entries.js: resolveCompany через profiles[]
- views/index.ejs: переключатель компаний с названиями из profiles
- .env.example: IAM_API_URL
- docs: обновлены ТЗ-реализация.md, ТЗ-плюс.md, добавлен iam-integration.md
- tests: api-crud.sh (14 тестов CRUD + валидации)
- .gitignore: исключены .env.test, DEPLOY-TESTING.md
This commit is contained in:
2026-06-04 13:53:44 +03:00
parent 878e46287b
commit a8a07fe019
15 changed files with 1404 additions and 164 deletions
+109 -3
View File
@@ -25,6 +25,7 @@ const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const https = require('https');
const http = require('http');
const { IAM_API_URL } = require('./config');
// ── Конфигурация из окружения ────────────────────────────────────────────────
const ISSUER = process.env.JWT_ISSUER || 'mock-auth-api';
@@ -186,6 +187,7 @@ async function exchangeCode(code) {
return {
user: userFromPayload(payload),
accessToken: data.access_token,
idToken: data.id_token || null,
refreshToken: data.refresh_token || null,
};
@@ -274,8 +276,112 @@ function verifyAnyToken(token, devMode) {
throw lastErr || new Error('Token verification failed: no verifier available');
}
function userFromPayload(payload) {
// ── Компании ──────────────────────────────────────────────────────────
// ── IAM API — получение профилей пользователя ─────────────────────────────────
/**
* Выполняет HTTP-запрос к IAM API и возвращает обогащённые данные пользователя.
* @param {string} token — access_token от IAM
* @returns {object} { email, clientId, allClientIds, activeProfileId, companyId, companyName, isAdmin, fio, profiles, raw }
*/
async function fetchIamUser(token) {
const raw = await new Promise((resolve, reject) => {
const url = new URL(IAM_API_URL + '/api/v1/auth/user');
const lib = url.protocol === 'https:' ? https : http;
const req = lib.request({
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'GET',
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
}, (res) => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`IAM API returned ${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('IAM API timeout')); });
req.end();
});
const profiles = raw.profiles || [];
const activeProfile = profiles.find(p => p.is_active_profile) || profiles[0] || {};
const ui = raw.userInfo || {};
return {
email: ui.email || '',
clientId: ui.clientID || activeProfile.client_id || '',
allClientIds: profiles.map(p => p.client_id).filter(Boolean),
activeProfileId: activeProfile.id || null,
companyId: ui.companyId || '',
companyName: ui.company || activeProfile.company_name || '',
isAdmin: !!ui.isAdmin,
fio: ui.fio || null,
profiles, // полный массив для UI
raw, // сырой ответ (для отладки)
};
}
/**
* Переключает активный профиль пользователя через IAM API.
* @param {string} token — access_token от IAM
* @param {number} profileId — profiles[].id нового активного профиля
* @returns {object} { success, active_profile }
*/
async function switchProfile(token, profileId) {
const body = JSON.stringify({ profile_id: profileId });
const raw = await new Promise((resolve, reject) => {
const url = new URL(IAM_API_URL + '/api/v1/user/switch-profile');
const lib = url.protocol === 'https:' ? https : http;
const req = lib.request({
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
}, (res) => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`IAM switch-profile failed: ${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('IAM switch-profile timeout')); });
req.write(body);
req.end();
});
return raw;
}
function userFromPayload(payload, iamData) {
// ── Если есть данные из IAM API — используем их как основной источник ────
if (iamData) {
return {
email: iamData.email,
clientId: iamData.clientId,
allClientIds: iamData.allClientIds,
activeClientId: iamData.clientId, // активная по IAM
activeProfileId: iamData.activeProfileId,
companyId: iamData.companyId,
companyName: iamData.companyName,
isAdmin: iamData.isAdmin,
fio: iamData.fio,
profiles: iamData.profiles,
};
}
// ── Без IAM — fallback на JWT (mock-режим) ──────────────────────────────
let allClientIds = [];
// 1. auth-api: ClientID = "WZ03709" или "WZ03709, WZ54321"
@@ -402,4 +508,4 @@ function safeReturn(target) {
return '/';
}
module.exports = { initAuth, requireAdmin, safeReturn };
module.exports = { initAuth, requireAdmin, safeReturn, fetchIamUser, switchProfile };