@inso_web/els-mcp
Version:
MCP-сервер поверх INSO Error Logs Service. Read-only tools (search, analytics, fingerprinting, correlations) для подключения Claude Desktop/Code и ChatGPT к логам ошибок. Streamable HTTP transport + stdio для npx-запуска.
241 lines • 10.3 kB
JavaScript
import { recordAuthRejection } from '../../observability/metrics.js';
/**
* Auth middleware для HTTP transport.
*
* Поддерживает два path:
* A. ELS-key Bearer (`Bearer els_(live|test)_...`) — passthrough в ELS как есть.
* B. OIDC JWT Bearer — локально валидируем через JWKS (INSO Auth),
* резолвим sub → доступные apps через LK API (с кэшем + fallback).
*
* Если оба заголовка не подходят (или их нет) — 401 с `WWW-Authenticate`,
* указывающим на RFC 9728 resource metadata.
*
* Маршруты `/healthz`, `/readyz` и `/.well-known/*` исключены из этого
* middleware на уровне роутинга — здесь мы их не проверяем.
*/
const ELS_KEY_REGEX = /^els_(live|test)_[\w-]{30,}$/;
export function createAuthMiddleware(opts) {
const { config, jwks, log } = opts;
const lkResolver = opts.lkResolver ?? null;
const wwwAuthHeader = buildWwwAuthenticate(config.publicUrl);
return async function authMiddleware(req, res, next) {
const authHeader = readAuthorization(req);
if (!authHeader) {
reject(res, log, wwwAuthHeader, 'missing_authorization', 401, {
error: 'unauthorized',
error_description: 'Missing Authorization header',
resource_metadata: `${config.publicUrl}/.well-known/oauth-protected-resource`,
});
return;
}
const token = authHeader.slice('Bearer '.length).trim();
if (token.length === 0) {
reject(res, log, wwwAuthHeader, 'empty_bearer', 401, {
error: 'invalid_token',
error_description: 'Empty Bearer token',
});
return;
}
// Path A: ELS-key (priority).
if (ELS_KEY_REGEX.test(token)) {
const ctx = buildElsKeyContext(req, token, config);
req.context = ctx;
log?.debug?.({ keyId: ctx.keyId, requestId: ctx.requestId }, 'auth: els-key');
next();
return;
}
// Path B: OIDC JWT
if (looksLikeJwt(token)) {
try {
const payload = await jwks.verify(token);
const sub = typeof payload.sub === 'string' ? payload.sub : '';
if (!sub) {
recordAuthRejection('oidc_missing_sub');
log?.warn?.({ requestId: pickRequestId(req) }, 'OIDC reject: missing sub');
reject(res, log, wwwAuthHeader, null, 401, {
error: 'invalid_token',
error_description: 'JWT missing sub claim',
});
return;
}
// Scope-claim обязателен (не просто пустой).
if (!hasScopeClaim(payload)) {
recordAuthRejection('oidc_missing_scope_claim');
log?.warn?.({ sub, requestId: pickRequestId(req) }, 'OIDC reject: scope claim absent');
reject(res, log, wwwAuthHeader, null, 403, {
error: 'insufficient_scope',
error_description: 'Token has no scope claim (scope / scp)',
required_scope: 'errors:mcp-read',
});
return;
}
const scopes = extractScopes(payload);
if (!scopes.includes('errors:mcp-read')) {
recordAuthRejection('oidc_insufficient_scope');
log?.warn?.({ sub, scopes, requestId: pickRequestId(req) }, 'OIDC reject: insufficient scope');
reject(res, log, wwwAuthHeader, null, 403, {
error: 'insufficient_scope',
error_description: 'Token lacks required scope "errors:mcp-read"',
required_scope: 'errors:mcp-read',
});
return;
}
// Проверка audience — JWT должен содержать ожидаемую audience.
if (!hasAudience(payload, config.oidcAudience)) {
recordAuthRejection('oidc_invalid_audience');
log?.warn?.({ sub, aud: payload.aud, expected: config.oidcAudience, requestId: pickRequestId(req) }, 'OIDC reject: invalid audience');
reject(res, log, wwwAuthHeader, null, 401, {
error: 'invalid_token',
error_description: `Token audience does not include "${config.oidcAudience}"`,
});
return;
}
// Резолвим OIDC sub → доступные пользователю apps через LK API (с
// кэшем 5 мин в Redis); fallback на MCP_OIDC_DEMO_APP_SLUG.
let apps = [];
if (lkResolver) {
try {
const r = await lkResolver.resolveOidcSubApps(sub);
apps = r.apps;
}
catch (err) {
log?.debug?.({ err, sub }, 'lkResolver: resolveOidcSubApps threw; using fallback');
}
}
if (apps.length === 0 && config.oidcDemoAppSlug) {
apps = [config.oidcDemoAppSlug];
}
if (apps.length === 0) {
recordAuthRejection('oidc_no_app_resolver');
res.status(503).json({
error: 'oidc_app_resolver_unavailable',
error_description: 'OIDC sub → appSlug resolver returned no apps and no fallback (MCP_OIDC_DEMO_APP_SLUG) is set.',
});
return;
}
const appSlug = apps[0];
const ctx = buildOidcContext(req, sub, scopes, appSlug, apps);
req.context = ctx;
log?.debug?.({ sub, requestId: ctx.requestId, appSlug, appsCount: apps.length }, 'auth: oidc');
next();
return;
}
catch (err) {
recordAuthRejection('oidc_verify_failed');
log?.warn?.({ err: errMsg(err) }, 'OIDC token verification failed');
reject(res, log, wwwAuthHeader, null, 401, {
error: 'invalid_token',
error_description: 'Failed to verify OIDC token',
});
return;
}
}
// Не похоже ни на ELS-key, ни на JWT
reject(res, log, wwwAuthHeader, 'unrecognized_token_shape', 401, {
error: 'invalid_token',
error_description: 'Token format not recognized',
});
};
}
function reject(res, _log, wwwAuthHeader, metricReason, status, body) {
if (metricReason !== null) {
try {
recordAuthRejection(metricReason);
}
catch {
// ignore
}
}
res.setHeader('WWW-Authenticate', wwwAuthHeader);
res.status(status).json(body);
}
function hasScopeClaim(payload) {
return payload.scope !== undefined || payload.scp !== undefined;
}
function hasAudience(payload, expected) {
const aud = payload.aud;
if (typeof aud === 'string')
return aud === expected;
if (Array.isArray(aud))
return aud.includes(expected);
return false;
}
function readAuthorization(req) {
const raw = req.headers.authorization;
if (!raw || typeof raw !== 'string')
return null;
if (!raw.toLowerCase().startsWith('bearer '))
return null;
return raw;
}
function looksLikeJwt(s) {
// header.payload.signature — три base64url-сегмента через точку.
return /^[\w-]+\.[\w-]+\.[\w-]+$/.test(s);
}
function extractScopes(payload) {
const raw = payload.scope ?? payload.scp;
if (typeof raw === 'string')
return raw.split(/\s+/).filter(Boolean);
if (Array.isArray(raw))
return raw.filter((x) => typeof x === 'string');
return [];
}
function buildElsKeyContext(req, token, _config) {
return {
authMethod: 'els-key',
elsApiKey: token,
appSlug: '', // не известен на этом этапе — резолвится в elsClient через ELS upstream
keyId: token.slice(0, 12), // els_live_xxxx — visibility для логов
ip: pickIp(req),
userAgent: pickUserAgent(req),
sessionId: pickSessionId(req),
requestId: pickRequestId(req),
};
}
function buildOidcContext(req, sub, scopes, appSlug, availableApps) {
return {
authMethod: 'oidc',
oidcSub: sub,
scopes,
appSlug,
availableApps,
keyId: `oidc:${sub.slice(0, 8)}`,
ip: pickIp(req),
userAgent: pickUserAgent(req),
sessionId: pickSessionId(req),
requestId: pickRequestId(req),
};
}
function pickIp(req) {
const xff = req.headers['x-forwarded-for'];
if (typeof xff === 'string') {
const first = xff.split(',')[0]?.trim();
if (first)
return first;
}
return req.socket.remoteAddress ?? '';
}
function pickUserAgent(req) {
const ua = req.headers['user-agent'];
return typeof ua === 'string' ? ua : '';
}
function pickSessionId(req) {
const sid = req.headers['mcp-session-id'];
if (typeof sid === 'string' && sid.length > 0)
return sid;
return undefined;
}
function pickRequestId(req) {
const existing = req.headers['x-request-id'];
if (typeof existing === 'string' && existing.length > 0)
return existing;
// Запасной id, если request-id middleware не отработал.
return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}
function buildWwwAuthenticate(publicUrl) {
return `Bearer realm="els-mcp", resource_metadata="${publicUrl}/.well-known/oauth-protected-resource"`;
}
function errMsg(err) {
return err instanceof Error ? err.message : String(err);
}
//# sourceMappingURL=auth.js.map