/**
* MCP OAuth Callback Server
*
* HTTP server that handles OAuth callbacks from the authorization server.
* Uses Node.js http module for compatibility.
*/
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http"
import {
DEFAULT_OAUTH_CALLBACK_PATH,
getConfiguredOAuthCallbackPort,
getOAuthCallbackPath,
getOAuthCallbackPort,
setOAuthCallbackPath,
setOAuthCallbackPort,
} from "./mcp-oauth-provider.ts"
// HTML templates for callback responses
const HTML_SUCCESS = `
Pi - Authorization Successful
Authorization Successful
You can close this window and return to Pi.
`
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
}
const HTML_ERROR = (error: string) => `
Pi - Authorization Failed
Authorization Failed
An error occurred during authorization.
${escapeHtml(error)}
`
/** Pending authorization request */
interface PendingAuth {
resolve: (code: string) => void
reject: (error: Error) => void
timeout: ReturnType
}
/** Server singleton state */
let server: Server | undefined
let bindingPromise: Promise | undefined
const pendingAuths = new Map()
const reservedAuthStates = new Set()
/** Timeout for callback completion (5 minutes) */
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
interface EnsureCallbackServerOptions {
strictPort?: boolean
port?: number
callbackHost?: string
callbackPath?: string
oauthState?: string
reserveState?: boolean
}
const DEFAULT_OAUTH_CALLBACK_HOST = "localhost"
let callbackServerHost = DEFAULT_OAUTH_CALLBACK_HOST
/**
* Handle incoming HTTP requests to the callback server.
*/
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
const url = new URL(req.url || "/", `http://${req.headers.host}`)
// Only handle the callback path
if (url.pathname !== getOAuthCallbackPath()) {
res.writeHead(404, { "Content-Type": "text/plain" })
res.end("Not found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
// Enforce state parameter presence for CSRF protection
if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const pending = pendingAuths.get(state)
const isReserved = reservedAuthStates.has(state)
// Handle OAuth errors only for a state that belongs to an active flow.
if (error) {
if (!pending && !isReserved) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
const errorMsg = errorDescription || error
// Send HTTP response first before rejecting promise
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
reservedAuthStates.delete(state)
// Reject promise after response is sent (defer to allow test to attach handler)
if (pending) {
clearTimeout(pending.timeout)
pendingAuths.delete(state)
setTimeout(() => pending.reject(new Error(errorMsg)), 0)
}
return
}
// Validate state parameter
if (!pending) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg))
return
}
// Require authorization code
if (!code) {
res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR("No authorization code provided"))
return
}
// Clear timeout and resolve the pending promise
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS)
}
/**
* Ensure the callback server is running.
* If strictPort is true, requires binding on the configured callback port.
* If strictPort is false, asks the OS for an available local port.
*/
export async function ensureCallbackServer(options: EnsureCallbackServerOptions = {}): Promise {
while (bindingPromise) {
await bindingPromise
}
const operation = ensureCallbackServerLocked(options)
bindingPromise = operation
try {
await operation
} finally {
if (bindingPromise === operation) {
bindingPromise = undefined
}
}
}
async function ensureCallbackServerLocked(options: EnsureCallbackServerOptions = {}): Promise {
const requiredPort = options.port ?? getConfiguredOAuthCallbackPort()
const strictPort = options.strictPort === true
const requestedHost = options.callbackHost ?? DEFAULT_OAUTH_CALLBACK_HOST
const rawRequestedPath = options.callbackPath ?? DEFAULT_OAUTH_CALLBACK_PATH
const requestedPath = rawRequestedPath.startsWith("/") ? rawRequestedPath : `/${rawRequestedPath}`
if (options.reserveState && !options.oauthState) {
throw new Error("OAuth callback reservation requires an oauthState")
}
let reservedState: string | undefined
const previousServer = server
const needsStrictRebind = Boolean(previousServer && strictPort && getOAuthCallbackPort() !== requiredPort)
const needsHostSwitch = Boolean(previousServer && callbackServerHost !== requestedHost)
const needsPathSwitch = Boolean(previousServer && getOAuthCallbackPath() !== requestedPath)
if (previousServer) {
if (!needsStrictRebind && !needsHostSwitch) {
if (needsPathSwitch) {
if (pendingAuths.size > 0 || reservedAuthStates.size > 0) {
throw new Error(
`OAuth callback server is using path ${getOAuthCallbackPath()}, but callback path ${requestedPath} is required and cannot be switched while authorizations are pending`
)
}
setOAuthCallbackPath(requestedPath)
}
if (options.reserveState && options.oauthState) {
reservedAuthStates.add(options.oauthState)
reservedState = options.oauthState
}
return
}
if (pendingAuths.size > 0 || reservedAuthStates.size > 0) {
throw new Error(
`OAuth callback server is running on ${callbackServerHost}:${getOAuthCallbackPort()}, but strict callback endpoint ${requestedHost}:${requiredPort} is required and cannot be switched while authorizations are pending`
)
}
}
const candidateServer = createServer(handleRequest)
const listenPort = strictPort ? requiredPort : 0
try {
await new Promise((resolve, reject) => {
candidateServer.once("error", (err) => {
reject(err)
})
candidateServer.listen(listenPort, requestedHost, () => {
resolve()
})
})
if (strictPort) {
setOAuthCallbackPort(requiredPort)
} else {
const address = candidateServer.address()
if (!address || typeof address === "string" || typeof address.port !== "number") {
throw new Error("OAuth callback server did not report an assigned port")
}
setOAuthCallbackPort(address.port)
}
if (previousServer && (needsStrictRebind || needsHostSwitch)) {
await new Promise((resolve) => {
previousServer.close(() => resolve())
})
}
callbackServerHost = requestedHost
setOAuthCallbackPath(requestedPath)
server = candidateServer
if (options.reserveState && options.oauthState) {
reservedAuthStates.add(options.oauthState)
reservedState = options.oauthState
}
server.unref()
} catch (error) {
if (reservedState) {
reservedAuthStates.delete(reservedState)
}
const nodeError = error as NodeJS.ErrnoException
await new Promise((resolve) => {
candidateServer.close(() => resolve())
})
if (strictPort && nodeError.code === "EADDRINUSE") {
throw new Error(
`OAuth callback port ${requiredPort} is already in use. Pre-registered OAuth clients require an exact redirect URI; set MCP_OAUTH_CALLBACK_PORT to your registered port or free port ${requiredPort}`,
{ cause: error }
)
}
throw error
}
}
export function reserveCallbackServer(oauthState: string): void {
reservedAuthStates.add(oauthState)
}
export function releaseCallbackServer(oauthState: string): void {
reservedAuthStates.delete(oauthState)
}
/**
* Wait for a callback with the given OAuth state.
* Returns a promise that resolves with the authorization code.
*/
export function waitForCallback(oauthState: string): Promise {
reservedAuthStates.delete(oauthState)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (pendingAuths.has(oauthState)) {
pendingAuths.delete(oauthState)
reject(new Error("OAuth callback timeout - authorization took too long"))
}
}, CALLBACK_TIMEOUT_MS)
pendingAuths.set(oauthState, { resolve, reject, timeout })
})
}
/**
* Cancel a pending authorization by state.
*/
export function cancelPendingCallback(oauthState: string): void {
reservedAuthStates.delete(oauthState)
const pending = pendingAuths.get(oauthState)
if (pending) {
clearTimeout(pending.timeout)
pendingAuths.delete(oauthState)
pending.reject(new Error("Authorization cancelled"))
}
}
/**
* Stop the callback server and reject all pending authorizations.
*/
export async function stopCallbackServer(): Promise {
if (server) {
await new Promise((resolve) => {
server!.close(() => {
resolve()
})
})
server = undefined
}
setOAuthCallbackPort(getConfiguredOAuthCallbackPort())
callbackServerHost = DEFAULT_OAUTH_CALLBACK_HOST
setOAuthCallbackPath(DEFAULT_OAUTH_CALLBACK_PATH)
// Reject all pending auths (defer to allow any pending operations to complete)
const pendingList = Array.from(pendingAuths.entries())
pendingAuths.clear()
reservedAuthStates.clear()
setTimeout(() => {
for (const [, pending] of pendingList) {
clearTimeout(pending.timeout)
pending.reject(new Error("OAuth callback server stopped"))
}
}, 0)
}
/**
* Check if the callback server is running.
*/
export function isCallbackServerRunning(): boolean {
return server !== undefined
}
/**
* Get the number of pending authorizations.
*/
export function getPendingAuthCount(): number {
return pendingAuths.size
}