537 lines
23 KiB
JavaScript
537 lines
23 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* src/auth.js — аутентификация и авторизация.
|
||
*
|
||
* Два режима работы (определяются по наличию KC_CLIENT_ID + KC_CLIENT_SECRET):
|
||
*
|
||
* OIDC-режим — Authorization Code Flow через Keycloak.
|
||
* Токен обменивается на /callback, пользователь сохраняется в сессии.
|
||
*
|
||
* Mock-режим — локальная RSA-пара, JWT выдаётся при логине.
|
||
* Пользователь выбирается на /login из MOCK_USERS и тоже хранится в сессии.
|
||
*
|
||
* Dev-backdoor (DEV_MODE=true или DEV_SECRET задан):
|
||
* /dev-login — форма выбора произвольного пользователя, в обход OIDC/mock.
|
||
* DEV_MODE=true → доступен без пароля (локальная разработка).
|
||
* DEV_SECRET=xyz → доступен с ?key=xyz (staging/prod тестирование).
|
||
*
|
||
* Экспортируемые функции:
|
||
* initAuth() → объект auth с middleware, OIDC-хелперами и флагами
|
||
* requireAdmin() → middleware для admin-only маршрутов
|
||
*/
|
||
|
||
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';
|
||
const ADMIN_CLIENT_ID = process.env.ADMIN_CLIENT_ID || 'WZ01112';
|
||
|
||
const KC_BASE_URL = (process.env.KC_BASE_URL || 'https://keycloak.nubes.ru/realms/cloud').replace(/\/$/, '');
|
||
const KC_CLIENT_ID = process.env.KC_CLIENT_ID || '';
|
||
const KC_CLIENT_SECRET = process.env.KC_CLIENT_SECRET || '';
|
||
const APP_URL = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||
|
||
// DEV_MODE и DEV_SECRET читаются внутри initAuth() — иначе при тестах
|
||
// значение фиксируется при первом require() и последующее присвоение process.env
|
||
// не влияет на уже загруженный модуль (кеш Node.js).
|
||
|
||
// OIDC endpoints (Keycloak realm cloud)
|
||
const OIDC = {
|
||
auth: KC_BASE_URL + '/protocol/openid-connect/auth',
|
||
token: KC_BASE_URL + '/protocol/openid-connect/token',
|
||
certs: KC_BASE_URL + '/protocol/openid-connect/certs',
|
||
logout: KC_BASE_URL + '/protocol/openid-connect/logout',
|
||
};
|
||
|
||
// ── Кеши (инициализируются в initAuth) ─────────────────────────────────────
|
||
let mockKeyPair = null;
|
||
let mockJwks = null;
|
||
let cachedJwks = null; // OIDC public keys
|
||
|
||
// ── initAuth ────────────────────────────────────────────────────────────────
|
||
async function initAuth() {
|
||
// Читаем здесь — не на уровне модуля, чтобы тесты могли менять process.env
|
||
// до вызова initAuth() и видеть актуальные значения.
|
||
const devMode = process.env.DEV_MODE === 'true';
|
||
const devSecret = process.env.DEV_SECRET || '';
|
||
|
||
// DEV_MODE=true недопустим в production — жёсткая защита от случайного деплоя.
|
||
if (process.env.NODE_ENV === 'production' && devMode) {
|
||
throw new Error('DEV_MODE=true запрещён в production (NODE_ENV=production)');
|
||
}
|
||
|
||
const isOidc = !!(KC_CLIENT_ID && KC_CLIENT_SECRET);
|
||
|
||
// Всегда предзагружаем JWKS (даже в mock-режиме), чтобы можно было
|
||
// проверить реальный Bearer токен от Keycloak без KC_CLIENT_ID/SECRET.
|
||
try {
|
||
cachedJwks = JSON.parse(await httpGet(OIDC.certs));
|
||
console.log('[auth] JWKS loaded:', cachedJwks.keys.length, 'ключей');
|
||
} catch (e) {
|
||
console.warn('[auth] JWKS preload failed:', e.message, '— только mock-режим');
|
||
}
|
||
|
||
if (isOidc) {
|
||
console.log('[auth] OIDC — Keycloak', KC_BASE_URL);
|
||
} else {
|
||
console.log('[auth] MOCK — local RS256 key');
|
||
mockKeyPair = crypto.generateKeyPairSync('rsa', {
|
||
modulusLength: 2048,
|
||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||
});
|
||
const jwkPub = crypto.createPublicKey(mockKeyPair.publicKey).export({ format: 'jwk' });
|
||
Object.assign(jwkPub, { alg: 'RS256', use: 'sig', kid: 'mock-1' });
|
||
mockJwks = { keys: [jwkPub] };
|
||
}
|
||
|
||
if (devMode) console.log('[auth] DEV_MODE — /dev-login доступен без пароля');
|
||
if (devSecret) console.log('[auth] DEV_SECRET задан — /dev-login с ключом');
|
||
|
||
return {
|
||
isOidc,
|
||
middleware: createMiddleware(isOidc, devMode),
|
||
bearerMiddleware: createBearerMiddleware(isOidc, devMode),
|
||
// OIDC-хелперы (используются в src/routes/auth.js)
|
||
buildAuthUrl,
|
||
exchangeCode,
|
||
buildLogoutUrl,
|
||
// Mock JWT (для API-клиентов если нужен bearer-токен)
|
||
issueMockToken,
|
||
// Флаги для routes/auth.js
|
||
devLoginEnabled: devMode || !!devSecret,
|
||
DEV_SECRET: devSecret,
|
||
// JWKS endpoint (только в mock-режиме)
|
||
jwksHandler: !isOidc ? (req, res) => res.json(mockJwks) : null,
|
||
// IAM API хелперы
|
||
fetchIamUser,
|
||
switchProfile,
|
||
};
|
||
}
|
||
|
||
// ── Middleware — проверяет req.session.user или Bearer-токен ─────────────────
|
||
function createMiddleware(isOidc, devMode) {
|
||
return function authMiddleware(req, res, next) {
|
||
// 1. Сессия — основной источник пользователя (OIDC и mock)
|
||
if (req.session && req.session.user) {
|
||
req.user = req.session.user;
|
||
return next();
|
||
}
|
||
|
||
// 2. Bearer-токен из заголовка (для API-клиентов без браузерной сессии)
|
||
const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
||
if (bearer) {
|
||
try {
|
||
const payload = verifyAnyToken(bearer, devMode);
|
||
req.user = userFromPayload(payload);
|
||
return next();
|
||
} catch (e) {
|
||
return res.status(401).json({ error: 'Invalid token: ' + e.message });
|
||
}
|
||
}
|
||
|
||
// 3. Устаревший jwt-cookie — плавная миграция: переносим в сессию
|
||
if (!isOidc && req.cookies && req.cookies.jwt) {
|
||
try {
|
||
const payload = verifyMockToken(req.cookies.jwt);
|
||
req.user = userFromPayload(payload);
|
||
if (req.session) {
|
||
req.session.user = req.user;
|
||
res.clearCookie('jwt');
|
||
}
|
||
return next();
|
||
} catch (_) {
|
||
res.clearCookie('jwt');
|
||
}
|
||
}
|
||
|
||
// 4. Не аутентифицирован → редирект (GET) или 401 (остальное)
|
||
if (req.method === 'GET') {
|
||
return res.redirect('/login?returnTo=' + encodeURIComponent(req.originalUrl));
|
||
}
|
||
return res.status(401).send('Unauthorized');
|
||
};
|
||
}
|
||
|
||
// ── OIDC — redirect URL для Keycloak ────────────────────────────────────────
|
||
function buildAuthUrl(state) {
|
||
const params = new URLSearchParams({
|
||
response_type: 'code',
|
||
client_id: KC_CLIENT_ID,
|
||
redirect_uri: APP_URL + '/callback',
|
||
scope: 'openid profile email',
|
||
state,
|
||
});
|
||
return OIDC.auth + '?' + params;
|
||
}
|
||
|
||
// ── OIDC — обмен authorization code → токены, возвращает { user, idToken, refreshToken }
|
||
async function exchangeCode(code) {
|
||
const body = new URLSearchParams({
|
||
grant_type: 'authorization_code',
|
||
client_id: KC_CLIENT_ID,
|
||
client_secret: KC_CLIENT_SECRET,
|
||
redirect_uri: APP_URL + '/callback',
|
||
code,
|
||
}).toString();
|
||
|
||
const raw = await httpPost(OIDC.token, body, { 'Content-Type': 'application/x-www-form-urlencoded' });
|
||
const data = JSON.parse(raw);
|
||
if (!data.access_token) throw new Error('No access_token: ' + raw.slice(0, 200));
|
||
|
||
// cachedJwks загружается в initAuth(); при провале preload — подгружаем здесь.
|
||
if (!cachedJwks) cachedJwks = JSON.parse(await httpGet(OIDC.certs));
|
||
const payload = verifyOidcToken(data.access_token);
|
||
|
||
return {
|
||
user: userFromPayload(payload),
|
||
accessToken: data.access_token,
|
||
idToken: data.id_token || null,
|
||
refreshToken: data.refresh_token || null,
|
||
};
|
||
}
|
||
|
||
// ── OIDC — URL для logout на Keycloak ───────────────────────────────────────
|
||
function buildLogoutUrl(idToken) {
|
||
const params = new URLSearchParams({ post_logout_redirect_uri: APP_URL + '/login' });
|
||
if (idToken) params.set('id_token_hint', idToken);
|
||
return OIDC.logout + '?' + params;
|
||
}
|
||
|
||
// ── Mock — выпустить JWT (для API-клиентов) ──────────────────────────────────
|
||
function issueMockToken(claims) {
|
||
if (!mockKeyPair) throw new Error('Mock keypair not initialized');
|
||
return jwt.sign(
|
||
{ ClientID: claims.clientId, company_id: claims.companyId, company_name: claims.companyName, email: claims.email, login: claims.email },
|
||
mockKeyPair.privateKey,
|
||
{ algorithm: 'RS256', issuer: ISSUER, subject: claims.companyId || 'mock', expiresIn: '24h', keyid: 'mock-1' }
|
||
);
|
||
}
|
||
|
||
// ── Внутренние утилиты ───────────────────────────────────────────────────────
|
||
function verifyMockToken(token) {
|
||
if (!mockKeyPair) throw new Error('Mock keypair not initialized');
|
||
return jwt.verify(token, mockKeyPair.publicKey, { algorithms: ['RS256'], issuer: ISSUER });
|
||
}
|
||
|
||
function verifyOidcToken(token) {
|
||
if (!cachedJwks) throw new Error('JWKS not loaded yet');
|
||
|
||
// Декодируем заголовок токена для выбора ключа по kid.
|
||
// base64url безопаснее base64: обрабатывает '-' и '_' без паддинга.
|
||
const headerRaw = token.split('.')[0];
|
||
let header;
|
||
try {
|
||
header = JSON.parse(Buffer.from(headerRaw, 'base64url').toString());
|
||
} catch (e) {
|
||
throw new Error('Malformed JWT header');
|
||
}
|
||
const kid = header.kid;
|
||
|
||
// Ищем ключ по kid (точный match) или первый RSA-sig ключ (если kid не указан).
|
||
const key = kid
|
||
? cachedJwks.keys.find(k => k.kid === kid && k.kty === 'RSA' && k.use === 'sig')
|
||
: cachedJwks.keys.find(k => k.kty === 'RSA' && k.use === 'sig');
|
||
if (!key) throw new Error('No matching RSA sig key in JWKS' + (kid ? ` (kid=${kid})` : ''));
|
||
|
||
const pem = crypto.createPublicKey({ key, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
|
||
// Проверяем issuer — защита от токенов других realm.
|
||
// audience не проверяем — не все KC-клиенты корректно выставляют aud claim.
|
||
const opts = { algorithms: ['RS256'], issuer: KC_BASE_URL };
|
||
return jwt.verify(token, pem, opts);
|
||
}
|
||
|
||
/**
|
||
* Универсальная верификация: пробует mock, затем OIDC (JWKS).
|
||
* Позволяет принимать и mock-токены (тесты, dev), и реальные токены от Keycloak.
|
||
*/
|
||
function verifyAnyToken(token, devMode) {
|
||
let lastErr = null;
|
||
// 1. Mock (быстрый, локальный)
|
||
if (mockKeyPair) {
|
||
try {
|
||
return jwt.verify(token, mockKeyPair.publicKey, { algorithms: ['RS256'], issuer: ISSUER });
|
||
} catch (e) {
|
||
lastErr = e;
|
||
}
|
||
}
|
||
// 2. OIDC (JWKS от Keycloak)
|
||
if (cachedJwks) {
|
||
try {
|
||
return verifyOidcToken(token);
|
||
} catch (e) {
|
||
lastErr = e;
|
||
}
|
||
}
|
||
// 3. DEV_MODE — просто декодировать без проверки подписи
|
||
if (devMode) {
|
||
console.log('[auth] DEV_MODE — декодирую токен без проверки подписи:', lastErr ? lastErr.message : 'no verifier');
|
||
const decoded = jwt.decode(token);
|
||
if (!decoded) throw new Error('Malformed JWT');
|
||
return decoded;
|
||
}
|
||
throw lastErr || new Error('Token verification failed: no verifier available');
|
||
}
|
||
|
||
// ── 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; if (data.length > 100_000) { req.destroy(); reject(new Error('Response too large')); } });
|
||
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 || {};
|
||
const imp = raw.impersonation || {};
|
||
|
||
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,
|
||
isImpersonated: !!(imp.is_impersonated),
|
||
impersonationType: imp.type || null,
|
||
originalUserEmail: imp.originalUserEmail || '',
|
||
originalUserFullName: imp.originalUserFullName || '',
|
||
originalUserCompany: imp.originalUserCompany || '',
|
||
impersonatedCompanyId: imp.impersonatedCompanyId || '',
|
||
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; if (data.length > 100_000) { req.destroy(); reject(new Error('Response too large')); } });
|
||
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"
|
||
const rawClientId = payload.ClientID || payload.client_id || '';
|
||
if (rawClientId) {
|
||
allClientIds = rawClientId.split(',').map(s => s.trim()).filter(Boolean);
|
||
}
|
||
|
||
// 2. Keycloak: claims = ["wz03709-shturval-...", "wz03709-...-test-3"]
|
||
if (!allClientIds.length && Array.isArray(payload.claims)) {
|
||
const wzRe = /wz(\d+)/i;
|
||
const seen = new Set();
|
||
for (const c of payload.claims) {
|
||
const m = String(c).match(wzRe);
|
||
if (m) {
|
||
const wz = 'WZ' + m[1];
|
||
if (!seen.has(wz)) { seen.add(wz); allClientIds.push(wz); }
|
||
}
|
||
}
|
||
}
|
||
|
||
const activeClientId = allClientIds[0] || 'UNKNOWN';
|
||
|
||
// ── Email ─────────────────────────────────────────────────────────────
|
||
const isEmail = s => typeof s === 'string' && s.includes('@');
|
||
const email = isEmail(payload.email) ? payload.email
|
||
: isEmail(payload.login) ? payload.login
|
||
: isEmail(payload.sub) ? payload.sub
|
||
: isEmail(payload.preferred_username) ? payload.preferred_username
|
||
: payload.email || payload.login || payload.sub || 'unknown';
|
||
|
||
return {
|
||
email,
|
||
clientId: activeClientId,
|
||
allClientIds,
|
||
activeClientId,
|
||
companyId: payload.company_id || null,
|
||
companyName: payload.company_name || activeClientId,
|
||
isAdmin: activeClientId === ADMIN_CLIENT_ID,
|
||
};
|
||
}
|
||
|
||
// ── Bearer-only middleware (для /api/v1) ────────────────────────────────────
|
||
// Не смотрит на сессию — всегда проверяет Bearer JWT явно.
|
||
// Используется в src/api/index.js вместо auth.middleware.
|
||
function createBearerMiddleware(isOidc, devMode) {
|
||
return function bearerAuthMiddleware(req, res, next) {
|
||
const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
||
if (!bearer) {
|
||
return res.status(401).json({ error: 'Bearer token required' });
|
||
}
|
||
try {
|
||
const payload = verifyAnyToken(bearer, devMode);
|
||
req.user = userFromPayload(payload);
|
||
|
||
// Проброс контекста имперсонации из UI-слоя (X-Imp-* заголовки)
|
||
// Устанавливаются ui/api-client.js → api.impHeaders()
|
||
const impEmail = req.headers['x-imp-email'] || '';
|
||
const impOrig = req.headers['x-imp-originalemail'] || '';
|
||
const impClientId = req.headers['x-imp-clientid'] || '';
|
||
if (impEmail) {
|
||
req.user.email = impEmail;
|
||
req.user.originalUserEmail = impOrig;
|
||
req.user.isImpersonated = true;
|
||
if (impClientId) {
|
||
req.user.clientId = impClientId;
|
||
req.user.activeClientId = impClientId;
|
||
}
|
||
}
|
||
|
||
return next();
|
||
} catch (e) {
|
||
return res.status(401).json({ error: 'Invalid token: ' + e.message });
|
||
}
|
||
};
|
||
}
|
||
|
||
// ── requireAdmin middleware ──────────────────────────────────────────────────
|
||
/**
|
||
* Middleware-защита для admin-only маршрутов.
|
||
* Использовать после app.use(auth.middleware):
|
||
* router.get('/admin', requireAdmin, handler)
|
||
*/
|
||
function requireAdmin(req, res, next) {
|
||
if (!req.user || !req.user.isAdmin) {
|
||
return res.status(403).send('403 Forbidden: требуется роль администратора');
|
||
}
|
||
next();
|
||
}
|
||
|
||
// ── HTTP-утилиты (без внешних зависимостей) ─────────────────────────────────
|
||
function httpGet(url) {
|
||
return new Promise((resolve, reject) => {
|
||
const lib = url.startsWith('https') ? https : http;
|
||
const req = lib.get(url, { timeout: 5000 }, (res) => {
|
||
let data = '';
|
||
res.on('data', c => { data += c; });
|
||
res.on('end', () => res.statusCode === 200 ? resolve(data) : reject(new Error('HTTP ' + res.statusCode)));
|
||
});
|
||
req.on('error', reject);
|
||
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
||
});
|
||
}
|
||
|
||
function httpPost(url, body, headers) {
|
||
return new Promise((resolve, reject) => {
|
||
const u = new URL(url);
|
||
const lib = u.protocol === 'https:' ? https : http;
|
||
const opts = {
|
||
hostname: u.hostname,
|
||
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
||
path: u.pathname + u.search,
|
||
method: 'POST',
|
||
headers: { ...headers, 'Content-Length': Buffer.byteLength(body) },
|
||
timeout: 5000,
|
||
};
|
||
const req = lib.request(opts, (res) => {
|
||
let data = '';
|
||
res.on('data', c => { data += c; });
|
||
res.on('end', () => {
|
||
if (res.statusCode >= 200 && res.statusCode < 300) resolve(data);
|
||
else reject(new Error('HTTP ' + res.statusCode + ': ' + data.slice(0, 200)));
|
||
});
|
||
});
|
||
req.on('error', reject);
|
||
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
||
req.write(body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Валидирует redirect-цель: разрешает только локальные пути.
|
||
* Защита от open redirect: //evil.com и https://evil.com → '/'.
|
||
*/
|
||
function safeReturn(target) {
|
||
if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {
|
||
return target;
|
||
}
|
||
return '/';
|
||
}
|
||
|
||
module.exports = { initAuth, requireAdmin, safeReturn, fetchIamUser, switchProfile };
|