diff --git a/server.js b/server.js new file mode 100644 index 0000000..a765a0b --- /dev/null +++ b/server.js @@ -0,0 +1,175 @@ +import http from 'node:http'; +import crypto from 'node:crypto'; + +const PORT = Number(process.env.PORT || 3000); +const ISSUER = (process.env.KEYCLOAK_ISSUER || '').replace(/\/$/, ''); +const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || ''; +const CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || ''; +const BASE_URL = (process.env.APP_BASE_URL || '').replace(/\/$/, ''); +const MCP_URL = process.env.MCP_URL || 'https://mcp.arrowstack.dev/mcp'; + +const sessions = new Map(); +const pending = new Map(); + +function b64url(buf) { return Buffer.from(buf).toString('base64url'); } +function random() { return b64url(crypto.randomBytes(32)); } +function cookies(req) { + return Object.fromEntries((req.headers.cookie || '').split(';').filter(Boolean).map(v => { + const i = v.indexOf('='); return [v.slice(0, i).trim(), decodeURIComponent(v.slice(i + 1))]; + })); +} +function setCookie(res, name, value, maxAge = 3600) { + res.setHeader('Set-Cookie', `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`); +} +function redirect(res, url) { res.writeHead(302, { Location: url }); res.end(); } +function json(res, code, value) { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(value, null, 2)); } +function decodeJwt(token) { + try { return JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); } catch { return {}; } +} +function esc(v) { return String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } +function safeClaims(c) { + return { + sub: c.sub, + preferred_username: c.preferred_username, + email: c.email, + aud: c.aud, + azp: c.azp, + scope: c.scope, + resource_access: c.resource_access, + realm_access: c.realm_access, + groups: c.groups, + exp: c.exp + }; +} +function parseMcpBody(text) { + try { return JSON.parse(text); } catch {} + const events = text.split('\n').filter(l => l.startsWith('data:')).map(l => l.slice(5).trim()).filter(Boolean); + for (let i = events.length - 1; i >= 0; i--) { + try { return JSON.parse(events[i]); } catch {} + } + return { raw: text.slice(0, 3000) }; +} +async function mcpPost(token, payload, sessionId = '') { + const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }; + if (sessionId) headers['Mcp-Session-Id'] = sessionId; + const r = await fetch(MCP_URL, { method: 'POST', headers, body: JSON.stringify(payload) }); + const text = await r.text(); + return { + status: r.status, + body: parseMcpBody(text), + sessionId: r.headers.get('mcp-session-id') || '', + headers: { + 'content-type': r.headers.get('content-type'), + 'www-authenticate': r.headers.get('www-authenticate'), + 'mcp-session-id': r.headers.get('mcp-session-id') + } + }; +} +async function probeMcp(token) { + const direct = await mcpPost(token, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); + if (direct.status >= 200 && direct.status < 300 && direct.body?.result?.tools) { + return { mode: 'direct tools/list', direct, tools: direct.body.result.tools }; + } + const init = await mcpPost(token, { + jsonrpc: '2.0', id: 2, method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'arrowstack-mcp-info', version: '1.0.0' } } + }); + if (!(init.status >= 200 && init.status < 300)) return { mode: 'direct + initialize failed', direct, init, tools: [] }; + const sid = init.sessionId; + await mcpPost(token, { jsonrpc: '2.0', method: 'notifications/initialized', params: {} }, sid); + const listed = await mcpPost(token, { jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }, sid); + return { mode: 'initialized session', direct, init, listed, tools: listed.body?.result?.tools || [] }; +} +function requireConfig() { + return ISSUER && CLIENT_ID && CLIENT_SECRET && BASE_URL; +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, BASE_URL || `http://${req.headers.host}`); + if (url.pathname === '/health') return json(res, 200, { ok: true, issuerConfigured: !!ISSUER, mcp: MCP_URL }); + if (!requireConfig()) return json(res, 503, { error: 'OIDC configuration incomplete', issuer: !!ISSUER, clientId: !!CLIENT_ID, clientSecret: !!CLIENT_SECRET, baseUrl: !!BASE_URL }); + + if (url.pathname === '/login') { + const state = random(); + const verifier = random(); + const challenge = b64url(crypto.createHash('sha256').update(verifier).digest()); + pending.set(state, { verifier, expires: Date.now() + 5 * 60_000 }); + setCookie(res, 'oidc_state', state, 300); + const auth = new URL(`${ISSUER}/protocol/openid-connect/auth`); + auth.searchParams.set('client_id', CLIENT_ID); + auth.searchParams.set('redirect_uri', `${BASE_URL}/callback`); + auth.searchParams.set('response_type', 'code'); + auth.searchParams.set('scope', 'openid profile email'); + auth.searchParams.set('state', state); + auth.searchParams.set('code_challenge', challenge); + auth.searchParams.set('code_challenge_method', 'S256'); + return redirect(res, auth.toString()); + } + + if (url.pathname === '/callback') { + const state = url.searchParams.get('state') || ''; + const code = url.searchParams.get('code') || ''; + const expected = cookies(req).oidc_state; + const p = pending.get(state); + pending.delete(state); + if (!p || expected !== state || p.expires < Date.now() || !code) return json(res, 400, { error: 'Invalid or expired OIDC callback state' }); + const body = new URLSearchParams({ + grant_type: 'authorization_code', code, + redirect_uri: `${BASE_URL}/callback`, + client_id: CLIENT_ID, client_secret: CLIENT_SECRET, + code_verifier: p.verifier + }); + const tr = await fetch(`${ISSUER}/protocol/openid-connect/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body }); + const token = await tr.json(); + if (!tr.ok || !token.access_token) return json(res, 502, { error: 'Keycloak token exchange failed', status: tr.status, response: token }); + const sid = random(); + sessions.set(sid, { accessToken: token.access_token, claims: decodeJwt(token.access_token), created: Date.now() }); + setCookie(res, 'mcp_info_session', sid, Math.min(Number(token.expires_in || 3600), 3600)); + return redirect(res, '/'); + } + + if (url.pathname === '/logout') { + const sid = cookies(req).mcp_info_session; + if (sid) sessions.delete(sid); + setCookie(res, 'mcp_info_session', '', 0); + return redirect(res, '/login'); + } + + const sid = cookies(req).mcp_info_session; + const session = sid && sessions.get(sid); + if (!session) return redirect(res, '/login'); + + const probe = await probeMcp(session.accessToken); + const claims = safeClaims(session.claims); + const tools = probe.tools || []; + const directStatus = probe.direct?.status ?? '-'; + const finalStatus = probe.listed?.status ?? probe.init?.status ?? probe.direct?.status ?? '-'; + const toolRows = tools.map(t => `
${esc(JSON.stringify(claims,null,2))}| Tool | Description |
|---|
${esc(JSON.stringify(debug,null,2))}