136 lines
5.5 KiB
JavaScript
136 lines
5.5 KiB
JavaScript
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 в OAuth-потоке
|
||
if (process.env.NODE_ENV === 'production') {
|
||
if (req.session) delete req.session.oidcState;
|
||
} else {
|
||
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 на токен (через IAM / Keycloak)
|
||
const tokenData = await auth.exchangeCode(code);
|
||
const accessToken = tokenData.accessToken;
|
||
|
||
if (!accessToken) throw new Error('No access_token in response');
|
||
|
||
// Базовый user из JWT (fallback)
|
||
const payload = jwt.decode(accessToken);
|
||
if (!payload) throw new Error('Failed to decode token');
|
||
|
||
req.session.token = accessToken;
|
||
req.session.idToken = tokenData.idToken || null;
|
||
|
||
// Пытаемся обогатить через IAM API
|
||
try {
|
||
const iamData = await auth.fetchIamUser(accessToken);
|
||
req.session.user = {
|
||
email: iamData.email,
|
||
clientId: iamData.clientId,
|
||
allClientIds: iamData.allClientIds,
|
||
activeClientId: iamData.clientId,
|
||
activeProfileId: iamData.activeProfileId,
|
||
companyId: iamData.companyId,
|
||
companyName: iamData.companyName,
|
||
isAdmin: iamData.isAdmin,
|
||
isImpersonated: iamData.isImpersonated,
|
||
impersonationType: iamData.impersonationType,
|
||
impersonatedCompanyId: iamData.impersonatedCompanyId,
|
||
originalUserEmail: iamData.originalUserEmail,
|
||
originalUserFullName: iamData.originalUserFullName,
|
||
originalUserCompany: iamData.originalUserCompany,
|
||
fio: iamData.fio,
|
||
profiles: iamData.profiles,
|
||
};
|
||
console.log('[oidc] IAM enrichment OK:', iamData.email, iamData.clientId, 'profiles:', iamData.allClientIds.length);
|
||
} catch (iamErr) {
|
||
// IAM недоступен — используем fallback из JWT
|
||
console.error('[oidc] ⚠️ IAM API UNAVAILABLE — falling back to JWT claims:', iamErr.message);
|
||
console.error('[oidc] User company/isAdmin may be incorrect — profiles[] not available');
|
||
const rawClientId = payload.ClientID || payload.client_id || '';
|
||
const allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
|
||
const activeClientId = allClientIds[0] || rawClientId || 'UNKNOWN';
|
||
req.session.user = {
|
||
clientId: activeClientId,
|
||
allClientIds,
|
||
activeClientId,
|
||
email: payload.email || payload.login || '',
|
||
companyId: payload.company_id || null,
|
||
companyName: payload.company_name || activeClientId,
|
||
isAdmin: activeClientId === (process.env.ADMIN_CLIENT_ID || 'WZ01112'),
|
||
};
|
||
}
|
||
|
||
const returnTo = '/v2/app';
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Валидирует redirect-цель: разрешает только локальные пути.
|
||
* Защита от open redirect: //evil.com и https://evil.com → '/'.
|
||
*/
|
||
function safeLocal(target) {
|
||
if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {
|
||
return target;
|
||
}
|
||
return '/';
|
||
}
|
||
|
||
module.exports = { createRouter };
|