UNPKG

@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
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