diff --git a/.replit b/.replit new file mode 100644 index 0000000..37671c9 --- /dev/null +++ b/.replit @@ -0,0 +1,12 @@ +modules = ["web", "bun-1.2"] +run = "bun test" + +[nix] +channel = "stable-25_05" + +[deployment] +run = ["sh", "-c", "bun test"] + +[[ports]] +localPort = 8080 +externalPort = 80 diff --git a/bun.lock b/bun.lock index 952df58..204420d 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,9 @@ "devDependencies": { "@modelcontextprotocol/sdk": "^1.17.3", "@types/bun": "^1.2.20", + "@types/deno": "^2.3.0", + "@types/node": "^24.3.0", + "@types/open": "^6.2.1", "mermaid": "^11.9.0", "prettier": "^3.6.2", "publint": "^0.3.12", @@ -275,6 +278,8 @@ "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/deno": ["@types/deno@2.3.0", "", {}, "sha512-/4SyefQpKjwNKGkq9qG3Ln7MazfbWKvydyVFBnXzP5OQA4u1paoFtaOe1iHKycIWHHkhYag0lPxyheThV1ijzw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], @@ -291,6 +296,8 @@ "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "@types/open": ["@types/open@6.2.1", "", { "dependencies": { "open": "*" } }, "sha512-CzV16LToFaKwm1FfplVTF08E3pznw4fQNCQ87N+A1RU00zu/se7npvb6IC9db3/emnSThQ6R8qFKgrei2M4EYQ=="], + "@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], diff --git a/package.json b/package.json index fc3247f..4331812 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oauth-callback", - "version": "1.2.0", + "version": "1.2.1", "description": "Lightweight OAuth 2.0 callback handler for Node.js, Deno, and Bun with built-in browser flow, MCP support, and zero dependencies", "keywords": [ "oauth", @@ -80,6 +80,9 @@ }, "devDependencies": { "@modelcontextprotocol/sdk": "^1.17.3", + "@types/deno": "^2.3.0", + "@types/node": "^24.3.0", + "@types/open": "^6.2.1", "@types/bun": "^1.2.20", "mermaid": "^11.9.0", "prettier": "^3.6.2", diff --git a/src/server.ts b/src/server.ts index c5d6fd0..413a21f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,10 +5,12 @@ * SPDX-License-Identifier: MIT */ +import type { Server as HttpServer } from "node:http"; +import type { IncomingMessage } from "node:http"; import { successTemplate, renderError } from "./templates"; /** - * Result object returned from OAuth callback containing authorization code or error details + * Result object returned from OAuth callback containing authorization code or error details. */ export interface CallbackResult { /** Authorization code returned by OAuth provider */ @@ -26,7 +28,7 @@ export interface CallbackResult { } /** - * Configuration options for the OAuth callback server + * Configuration options for the OAuth callback server. */ export interface ServerOptions { /** Port number to bind the server to */ @@ -44,7 +46,7 @@ export interface ServerOptions { } /** - * Interface for OAuth callback server implementations across different runtimes + * Interface for OAuth callback server implementations across different runtimes. */ export interface CallbackServer { /** Start the HTTP server with the given options */ @@ -56,7 +58,7 @@ export interface CallbackServer { } /** - * Generate HTML response for OAuth callback + * Generate HTML response for OAuth callback. * @param params - OAuth callback parameters (code, error, etc.) * @param successHtml - Custom success HTML template * @param errorHtml - Custom error HTML template with placeholder support @@ -67,383 +69,213 @@ function generateCallbackHTML( successHtml?: string, errorHtml?: string, ): string { - if (params.error) { - // Use custom error HTML if provided - if (errorHtml) { - return errorHtml - .replace(/{{error}}/g, params.error || "") - .replace(/{{error_description}}/g, params.error_description || "") - .replace(/{{error_uri}}/g, params.error_uri || ""); - } - return renderError({ - error: params.error, - error_description: params.error_description, - error_uri: params.error_uri, - }); - } - // Use custom success HTML if provided - return successHtml || successTemplate; + if (!params.error) return successHtml || successTemplate; + + if (errorHtml) + return errorHtml + .replace(/{{error}}/g, params.error || "") + .replace(/{{error_description}}/g, params.error_description || "") + .replace(/{{error_uri}}/g, params.error_uri || ""); + + return renderError({ + error: params.error, + error_description: params.error_description, + error_uri: params.error_uri, + }); } /** - * Bun runtime implementation using Bun.serve() + * Base class with shared logic for all runtime implementations. */ -class BunCallbackServer implements CallbackServer { - private server: any; // Runtime-specific server type (Bun.Server) - private callbackPromise?: { - resolve: (result: CallbackResult) => void; - reject: (error: Error) => void; - }; - private callbackPath: string = "/callback"; - private successHtml?: string; - private errorHtml?: string; - private onRequest?: (req: Request) => void; +abstract class BaseCallbackServer implements CallbackServer { + // Use a Map to safely handle listeners for different paths. + // This is more robust than a single property, preventing potential race conditions. + protected callbackListeners = new Map< + string, + { + resolve: (result: CallbackResult) => void; + reject: (error: Error) => void; + } + >(); + protected successHtml?: string; + protected errorHtml?: string; + protected onRequest?: (req: Request) => void; private abortHandler?: () => void; + private signal?: AbortSignal; - async start(options: ServerOptions): Promise { - const { - port, - hostname = "localhost", - successHtml, - errorHtml, - signal, - onRequest, - } = options; + // Abstract methods to be implemented by subclasses for runtime-specific logic. + public abstract start(options: ServerOptions): Promise; + protected abstract stopServer(): Promise; + /** + * Sets up common properties and handles the abort signal. + */ + protected setup(options: ServerOptions): void { + const { successHtml, errorHtml, signal, onRequest } = options; this.successHtml = successHtml; this.errorHtml = errorHtml; this.onRequest = onRequest; + this.signal = signal; - // Handle abort signal - if (signal) { - if (signal.aborted) { - throw new Error("Operation aborted"); - } - this.abortHandler = () => { - this.stop(); - if (this.callbackPromise) { - this.callbackPromise.reject(new Error("Operation aborted")); - } - }; - signal.addEventListener("abort", this.abortHandler); - } + if (!signal) return; + if (signal.aborted) throw new Error("Operation aborted"); - // @ts-ignore - Bun global not available in TypeScript definitions - this.server = Bun.serve({ - port, - hostname, - fetch: (request: Request) => this.handleRequest(request), - }); + // The abort handler now just calls stop(), which handles cleanup. + this.abortHandler = () => this.stop(); + signal.addEventListener("abort", this.abortHandler); } - private handleRequest(request: Request): Response { - // Call onRequest callback if provided - if (this.onRequest) { - this.onRequest(request); - } + /** + * Handles incoming HTTP requests using Web Standards APIs. + * This logic is the same for all runtimes. + */ + protected handleRequest(request: Request): Response { + this.onRequest?.(request); const url = new URL(request.url); + const listener = this.callbackListeners.get(url.pathname); - if (url.pathname === this.callbackPath) { - const params: CallbackResult = {}; - - // Parse all query parameters - for (const [key, value] of url.searchParams) { - params[key] = value; - } - - // Resolve the callback promise - if (this.callbackPromise) { - this.callbackPromise.resolve(params); - } - - // Return success or error HTML page - return new Response( - generateCallbackHTML(params, this.successHtml, this.errorHtml), - { - status: 200, - headers: { "Content-Type": "text/html" }, - }, - ); - } + if (!listener) return new Response("Not Found", { status: 404 }); + + const params: CallbackResult = {}; + for (const [key, value] of url.searchParams) params[key] = value; + + // Resolve the promise for the waiting listener. + listener.resolve(params); - return new Response("Not Found", { status: 404 }); + return new Response( + generateCallbackHTML(params, this.successHtml, this.errorHtml), + { + status: 200, + headers: { "Content-Type": "text/html" }, + }, + ); } - async waitForCallback( + /** + * Waits for the OAuth callback on a specific path. + */ + public async waitForCallback( path: string, timeout: number, ): Promise { - this.callbackPath = path; - - return new Promise((resolve, reject) => { - let isResolved = false; - - const timer = setTimeout(() => { - if (!isResolved) { - isResolved = true; - this.callbackPromise = undefined; - reject( - new Error( - `OAuth callback timeout after ${timeout}ms waiting for ${path}`, - ), - ); - } - }, timeout); - - const wrappedResolve = (result: CallbackResult) => { - if (!isResolved) { - isResolved = true; - clearTimeout(timer); - this.callbackPromise = undefined; - resolve(result); - } - }; - - const wrappedReject = (error: Error) => { - if (!isResolved) { - isResolved = true; - clearTimeout(timer); - this.callbackPromise = undefined; - reject(error); - } - }; + if (this.callbackListeners.has(path)) + return Promise.reject( + new Error(`A listener for the path "${path}" is already active.`), + ); - this.callbackPromise = { resolve: wrappedResolve, reject: wrappedReject }; - }); + try { + // Race a promise that waits for the callback against a promise that rejects on timeout. + return await Promise.race([ + // This promise is resolved or rejected by the handleRequest method. + new Promise((resolve, reject) => { + this.callbackListeners.set(path, { resolve, reject }); + }), + // This promise rejects after the specified timeout. + new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `OAuth callback timeout after ${timeout}ms waiting for ${path}`, + ), + ); + }, timeout); + }), + ]); + } finally { + // CRITICAL: Always clean up the listener to prevent memory leaks, + // regardless of whether the promise resolved or rejected. + this.callbackListeners.delete(path); + } } - async stop(): Promise { - if (this.abortHandler) { - // Remove abort handler if it was set - const signal = this.server?.signal; - if (signal) { - signal.removeEventListener("abort", this.abortHandler); - } + /** + * Stops the server and cleans up resources. + */ + public async stop(): Promise { + if (this.abortHandler && this.signal) { + this.signal.removeEventListener("abort", this.abortHandler); this.abortHandler = undefined; } - if (this.callbackPromise) { - this.callbackPromise.reject( - new Error("Server stopped before callback received"), - ); - this.callbackPromise = undefined; - } - if (this.server) { - this.server.stop(); - this.server = undefined; - } + // Reject any pending promises before stopping the server. + for (const listener of this.callbackListeners.values()) + listener.reject(new Error("Server stopped before callback received")); + + this.callbackListeners.clear(); + await this.stopServer(); } } /** - * Deno runtime implementation using Deno.serve() + * Bun runtime implementation using Bun.serve(). */ -class DenoCallbackServer implements CallbackServer { - private server: any; // Runtime-specific server type (Deno.HttpServer) - private callbackPromise?: { - resolve: (result: CallbackResult) => void; - reject: (error: Error) => void; - }; - private callbackPath: string = "/callback"; - private abortController?: AbortController; - private successHtml?: string; - private errorHtml?: string; - private onRequest?: (req: Request) => void; - private abortHandler?: () => void; +class BunCallbackServer extends BaseCallbackServer { + private server?: Bun.Server; - async start(options: ServerOptions): Promise { - const { + public async start(options: ServerOptions): Promise { + this.setup(options); + const { port, hostname = "localhost" } = options; + + this.server = Bun.serve({ port, - hostname = "localhost", - successHtml, - errorHtml, - signal, - onRequest, - } = options; + hostname, + fetch: (request: Request) => this.handleRequest(request), + }); + } - this.successHtml = successHtml; - this.errorHtml = errorHtml; - this.onRequest = onRequest; + protected async stopServer(): Promise { + if (!this.server) return; + this.server.stop(); + this.server = undefined; + } +} +/** + * Deno runtime implementation using Deno.serve(). + */ +class DenoCallbackServer extends BaseCallbackServer { + private abortController?: AbortController; + + public async start(options: ServerOptions): Promise { + this.setup(options); + const { port, hostname = "localhost" } = options; this.abortController = new AbortController(); - // Handle user's abort signal - if (signal) { - if (signal.aborted) { - throw new Error("Operation aborted"); - } - this.abortHandler = () => { - this.abortController?.abort(); - if (this.callbackPromise) { - this.callbackPromise.reject(new Error("Operation aborted")); - } - }; - signal.addEventListener("abort", this.abortHandler); - } + // The user's signal will abort our internal controller. + options.signal?.addEventListener("abort", () => + this.abortController?.abort(), + ); - // @ts-ignore - Deno global not available in TypeScript definitions - this.server = Deno.serve( + Deno.serve( { port, hostname, signal: this.abortController.signal }, (request: Request) => this.handleRequest(request), ); } - private handleRequest(request: Request): Response { - // Call onRequest callback if provided - if (this.onRequest) { - this.onRequest(request); - } - - const url = new URL(request.url); - - if (url.pathname === this.callbackPath) { - const params: CallbackResult = {}; - - // Parse all query parameters - for (const [key, value] of url.searchParams) { - params[key] = value; - } - - // Resolve the callback promise - if (this.callbackPromise) { - this.callbackPromise.resolve(params); - } - - // Return success or error HTML page - return new Response( - generateCallbackHTML(params, this.successHtml, this.errorHtml), - { - status: 200, - headers: { "Content-Type": "text/html" }, - }, - ); - } - - return new Response("Not Found", { status: 404 }); - } - - async waitForCallback( - path: string, - timeout: number, - ): Promise { - this.callbackPath = path; - - return new Promise((resolve, reject) => { - let isResolved = false; - - const timer = setTimeout(() => { - if (!isResolved) { - isResolved = true; - this.callbackPromise = undefined; - reject( - new Error( - `OAuth callback timeout after ${timeout}ms waiting for ${path}`, - ), - ); - } - }, timeout); - - const wrappedResolve = (result: CallbackResult) => { - if (!isResolved) { - isResolved = true; - clearTimeout(timer); - this.callbackPromise = undefined; - resolve(result); - } - }; - - const wrappedReject = (error: Error) => { - if (!isResolved) { - isResolved = true; - clearTimeout(timer); - this.callbackPromise = undefined; - reject(error); - } - }; - - this.callbackPromise = { resolve: wrappedResolve, reject: wrappedReject }; - }); - } - - async stop(): Promise { - if (this.abortHandler) { - // Remove abort handler if it was set - const signal = this.server?.signal; - if (signal) { - signal.removeEventListener("abort", this.abortHandler); - } - this.abortHandler = undefined; - } - if (this.callbackPromise) { - this.callbackPromise.reject( - new Error("Server stopped before callback received"), - ); - this.callbackPromise = undefined; - } - if (this.abortController) { - this.abortController.abort(); - this.abortController = undefined; - } - this.server = undefined; + protected async stopServer(): Promise { + if (!this.abortController) return; + this.abortController.abort(); + this.abortController = undefined; } } /** - * Node.js implementation using node:http with Web Standards APIs - * Works with Node.js 18+ which has native Request/Response support + * Node.js implementation using node:http with Web Standards APIs. */ -class NodeCallbackServer implements CallbackServer { - private server?: any; // Runtime-specific server type (http.Server) - private callbackPromise?: { - resolve: (result: CallbackResult) => void; - reject: (error: Error) => void; - }; - private callbackPath: string = "/callback"; - private successHtml?: string; - private errorHtml?: string; - private onRequest?: (req: Request) => void; - private abortHandler?: () => void; - - async start(options: ServerOptions): Promise { - const { - port, - hostname = "localhost", - successHtml, - errorHtml, - signal, - onRequest, - } = options; - - this.successHtml = successHtml; - this.errorHtml = errorHtml; - this.onRequest = onRequest; - - // Handle abort signal - if (signal) { - if (signal.aborted) { - throw new Error("Operation aborted"); - } - this.abortHandler = () => { - this.stop(); - if (this.callbackPromise) { - this.callbackPromise.reject(new Error("Operation aborted")); - } - }; - signal.addEventListener("abort", this.abortHandler); - } +class NodeCallbackServer extends BaseCallbackServer { + private server?: HttpServer; + public async start(options: ServerOptions): Promise { + this.setup(options); + const { port, hostname = "localhost" } = options; const { createServer } = await import("node:http"); return new Promise((resolve, reject) => { this.server = createServer(async (req, res) => { try { - // Convert Node.js IncomingMessage to Web Standards Request - const request = this.nodeToWebRequest(req, port); + const request = this.nodeToWebRequest(req, port, hostname); + const response = this.handleRequest(request); - // Handle request using Web Standards - const response = await this.handleRequest(request); - - // Write Web Standards Response back to Node.js ServerResponse res.writeHead( response.status, Object.fromEntries(response.headers.entries()), @@ -456,154 +288,57 @@ class NodeCallbackServer implements CallbackServer { } }); + // Tie server closing to the abort signal if provided. + if (options.signal) + options.signal.addEventListener("abort", () => this.server?.close()); + this.server.listen(port, hostname, () => resolve()); this.server.on("error", reject); }); } + protected async stopServer(): Promise { + if (!this.server) return; + return new Promise((resolve) => { + this.server?.close(() => { + this.server = undefined; + resolve(); + }); + }); + } + /** - * Convert Node.js IncomingMessage to Web Standards Request + * Converts a Node.js IncomingMessage to a Web Standards Request. */ - private nodeToWebRequest(req: any, port: number): Request { - const url = new URL(req.url!, `http://localhost:${port}`); + private nodeToWebRequest( + req: IncomingMessage, + port: number, + hostname?: string, + ): Request { + const host = req.headers.host || `${hostname}:${port}`; + const url = new URL(req.url!, `http://${host}`); - // Convert Node.js headers to Headers object const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { - if (typeof value === "string") { - headers.set(key, value); - } else if (Array.isArray(value)) { - headers.set(key, value.join(", ")); - } + if (typeof value === "string") headers.set(key, value); + else if (Array.isArray(value)) headers.set(key, value.join(", ")); } - // OAuth callbacks use GET requests without body return new Request(url.toString(), { method: req.method, headers, }); } - - /** - * Handle request using Web Standards APIs (same as Bun/Deno implementations) - */ - private async handleRequest(request: Request): Promise { - // Call onRequest callback if provided - if (this.onRequest) { - this.onRequest(request); - } - - const url = new URL(request.url); - - if (url.pathname === this.callbackPath) { - const params: CallbackResult = {}; - - // Parse all query parameters - for (const [key, value] of url.searchParams) { - params[key] = value; - } - - // Resolve the callback promise - if (this.callbackPromise) { - this.callbackPromise.resolve(params); - } - - // Return success or error HTML page - return new Response( - generateCallbackHTML(params, this.successHtml, this.errorHtml), - { - status: 200, - headers: { "Content-Type": "text/html" }, - }, - ); - } - - return new Response("Not Found", { status: 404 }); - } - - async waitForCallback( - path: string, - timeout: number, - ): Promise { - this.callbackPath = path; - - return new Promise((resolve, reject) => { - let isResolved = false; - - const timer = setTimeout(() => { - if (!isResolved) { - isResolved = true; - this.callbackPromise = undefined; - reject( - new Error( - `OAuth callback timeout after ${timeout}ms waiting for ${path}`, - ), - ); - } - }, timeout); - - const wrappedResolve = (result: CallbackResult) => { - if (!isResolved) { - isResolved = true; - clearTimeout(timer); - this.callbackPromise = undefined; - resolve(result); - } - }; - - const wrappedReject = (error: Error) => { - if (!isResolved) { - isResolved = true; - clearTimeout(timer); - this.callbackPromise = undefined; - reject(error); - } - }; - - this.callbackPromise = { resolve: wrappedResolve, reject: wrappedReject }; - }); - } - - async stop(): Promise { - if (this.abortHandler) { - // Remove abort handler if it was set - const signal = this.server?.signal; - if (signal) { - signal.removeEventListener("abort", this.abortHandler); - } - this.abortHandler = undefined; - } - if (this.callbackPromise) { - this.callbackPromise.reject( - new Error("Server stopped before callback received"), - ); - this.callbackPromise = undefined; - } - if (this.server) { - return new Promise((resolve) => { - this.server.close(() => resolve()); - this.server = undefined; - }); - } - } } /** - * Create a callback server for the current runtime (Bun, Deno, or Node.js) - * Automatically detects the runtime and returns appropriate server implementation - * @returns CallbackServer instance optimized for the current runtime + * Create a callback server for the current runtime (Bun, Deno, or Node.js). + * Automatically detects the runtime and returns the appropriate server implementation. + * @returns CallbackServer instance optimized for the current runtime. */ export function createCallbackServer(): CallbackServer { - // @ts-ignore - Bun global not available in TypeScript definitions - if (typeof Bun !== "undefined") { - return new BunCallbackServer(); - } - - // @ts-ignore - Deno global not available in TypeScript definitions - if (typeof Deno !== "undefined") { - return new DenoCallbackServer(); - } + if (typeof Bun !== "undefined") return new BunCallbackServer(); + if (typeof Deno !== "undefined") return new DenoCallbackServer(); - // Default to Node.js return new NodeCallbackServer(); } diff --git a/tsconfig.json b/tsconfig.json index 7bae88d..704e179 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, - "types": ["bun-types"], + "types": ["bun-types", "deno", "node"], // Bundler mode "moduleResolution": "bundler",