182 lines
5.1 KiB
TypeScript
182 lines
5.1 KiB
TypeScript
/**
|
|
* mcp-oauth-callback-ipc.ts
|
|
*
|
|
* Client-side loopback callback listener for MCP OAuth against a REMOTE
|
|
* backend. The gateway's own `mcp.servers.oauth.start` flow binds its
|
|
* callback listener on the BACKEND machine's 127.0.0.1 — unreachable from
|
|
* the user's browser when Desktop connects over SSH/Tailscale, so the
|
|
* provider redirect dies on the user's machine and the flow times out.
|
|
*
|
|
* This module gives the renderer the same primitive the native gateway
|
|
* login uses (native-oauth-login.ts): bind an ephemeral one-shot listener
|
|
* on the USER'S loopback, hand its URL to the gateway as the OAuth
|
|
* redirect_uri (`client_redirect_uri` on oauth.start), and resolve with the
|
|
* redirect's `code`/`state` so the renderer can relay them via
|
|
* `mcp.servers.oauth.callback`.
|
|
*
|
|
* Security posture:
|
|
* - binds 127.0.0.1 on an ephemeral port; closes on first callback,
|
|
* cancel, or timeout — no long-lived listener;
|
|
* - the listener only ever RECEIVES `code`/`state` query params and
|
|
* forwards them to the renderer; no tokens are exchanged here — the
|
|
* gateway verifies `state` (constant-time) before redeeming anything;
|
|
* - the browser sees only a minimal "return to Hermes" page.
|
|
*/
|
|
|
|
import http from 'node:http'
|
|
import type { AddressInfo } from 'node:net'
|
|
|
|
import { ipcMain } from 'electron'
|
|
|
|
const DEFAULT_WAIT_TIMEOUT_MS = 5 * 60 * 1000
|
|
const MAX_PENDING_LISTENERS = 8
|
|
|
|
const DONE_HTML =
|
|
'<!doctype html><meta charset="utf-8"><title>Authorization received</title>' +
|
|
'<body style="font:15px system-ui;margin:3rem;text-align:center">' +
|
|
'<h2>✓ Authorization received</h2>' +
|
|
'<p>You can close this window and return to Hermes.</p>' +
|
|
'<script>setTimeout(()=>window.close(),800)</script>'
|
|
|
|
interface CallbackResult {
|
|
code: null | string
|
|
error: null | string
|
|
state: null | string
|
|
}
|
|
|
|
interface PendingListener {
|
|
result: CallbackResult | null
|
|
server: http.Server
|
|
settled: boolean
|
|
waiters: Array<(result: CallbackResult) => void>
|
|
}
|
|
|
|
const pending = new Map<string, PendingListener>()
|
|
let nextId = 1
|
|
|
|
function settle(id: string, result: CallbackResult) {
|
|
const entry = pending.get(id)
|
|
|
|
if (!entry || entry.settled) {
|
|
return
|
|
}
|
|
|
|
entry.settled = true
|
|
entry.result = result
|
|
|
|
try {
|
|
entry.server.close()
|
|
} catch {
|
|
// already closed
|
|
}
|
|
|
|
for (const waiter of entry.waiters.splice(0)) {
|
|
waiter(result)
|
|
}
|
|
}
|
|
|
|
function dispose(id: string) {
|
|
const entry = pending.get(id)
|
|
|
|
if (!entry) {
|
|
return
|
|
}
|
|
|
|
if (!entry.settled) {
|
|
settle(id, { code: null, error: 'cancelled', state: null })
|
|
}
|
|
|
|
pending.delete(id)
|
|
}
|
|
|
|
export function registerMcpOauthCallbackIpc() {
|
|
// Bind a one-shot loopback listener; resolves { id, redirectUri }.
|
|
ipcMain.handle('hermes:mcp-oauth:listen', async () => {
|
|
if (pending.size >= MAX_PENDING_LISTENERS) {
|
|
throw new Error('Too many MCP OAuth listeners are already pending')
|
|
}
|
|
|
|
const id = String(nextId++)
|
|
|
|
const server = http.createServer((req, res) => {
|
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
res.end(DONE_HTML)
|
|
|
|
const url = req.url || '/'
|
|
|
|
// Ignore favicon and other noise — wait for the ?code= / ?error= hit.
|
|
if (!/[?&](code|error)=/.test(url)) {
|
|
return
|
|
}
|
|
|
|
let code: null | string = null
|
|
let state: null | string = null
|
|
let error: null | string = null
|
|
|
|
try {
|
|
const parsed = new URL(url, 'http://127.0.0.1')
|
|
|
|
code = parsed.searchParams.get('code')
|
|
state = parsed.searchParams.get('state')
|
|
error = parsed.searchParams.get('error')
|
|
} catch {
|
|
error = 'unparseable callback URL'
|
|
}
|
|
|
|
settle(id, { code, error, state })
|
|
})
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(0, '127.0.0.1', () => resolve())
|
|
})
|
|
|
|
const port = (server.address() as AddressInfo).port
|
|
|
|
pending.set(id, { result: null, server, settled: false, waiters: [] })
|
|
|
|
return { id, redirectUri: `http://127.0.0.1:${port}/callback` }
|
|
})
|
|
|
|
// Resolve when the redirect arrives (or timeout). Safe to call once per id.
|
|
ipcMain.handle('hermes:mcp-oauth:wait', async (_event, id, timeoutMs) => {
|
|
const entry = pending.get(String(id || ''))
|
|
|
|
if (!entry) {
|
|
return { code: null, error: 'listener not found', state: null }
|
|
}
|
|
|
|
if (entry.result) {
|
|
const result = entry.result
|
|
|
|
pending.delete(String(id))
|
|
|
|
return result
|
|
}
|
|
|
|
const timeout = Math.min(Math.max(Number(timeoutMs) || DEFAULT_WAIT_TIMEOUT_MS, 1000), 15 * 60 * 1000)
|
|
|
|
const result = await new Promise<CallbackResult>(resolve => {
|
|
const timer = setTimeout(() => {
|
|
settle(String(id), { code: null, error: 'timeout waiting for OAuth callback', state: null })
|
|
}, timeout)
|
|
|
|
entry.waiters.push(value => {
|
|
clearTimeout(timer)
|
|
resolve(value)
|
|
})
|
|
})
|
|
|
|
pending.delete(String(id))
|
|
|
|
return result
|
|
})
|
|
|
|
// Tear a listener down without waiting (user cancelled, flow errored).
|
|
ipcMain.handle('hermes:mcp-oauth:cancel', (_event, id) => {
|
|
dispose(String(id || ''))
|
|
|
|
return true
|
|
})
|
|
}
|