How to Capture OAuth Callbacks in CLI and Desktop Apps with Localhost Servers

When building CLI tools or desktop applications that integrate with OAuth providers, you face a unique challenge: how do you capture the authorization code when there's no public-facing server to receive the callback? The answer lies in a clever technique that's been right under our noses — spinning up a temporary localhost server to catch the OAuth redirect.

This tutorial walks through building a production-ready OAuth callback server that works across Node.js, Deno, and Bun. We'll cover everything from the basic HTTP server setup to handling edge cases that trip up most implementations.

Understanding the OAuth Callback Flow

Before diving into code, let's clarify what we're building. In a typical OAuth 2.0 authorization code flow, your application redirects users to an authorization server (like GitHub or Google), where they grant permissions. The authorization server then redirects back to your application with an authorization code.

For web applications, this redirect goes to a public URL. But for CLI tools and desktop apps, we use a localhost UR — typically http://localhost:3000/callback. The OAuth provider redirects to this local address, and our temporary server captures the authorization code from the query parameters.

This approach is explicitly blessed by OAuth 2.0 for Native Apps (RFC 8252) and is used by major tools like the GitHub CLI and Google's OAuth libraries.

Setting Up the Basic HTTP Server

The first step is creating an HTTP server that can listen on localhost. Modern JavaScript runtimes provide different APIs for this, but we can abstract them behind a common interface using Web Standards Request and Response objects.

interface CallbackServer {   start(options: ServerOptions): Promise<void>;   waitForCallback(path: string, timeout: number): Promise<CallbackResult>;   stop(): Promise<void>; }  function createCallbackServer(): CallbackServer {   // Runtime detection   if (typeof Bun !== "undefined") return new BunCallbackServer();   if (typeof Deno !== "undefined") return new DenoCallbackServer();   return new NodeCallbackServer(); } 

Each runtime implementation follows the same pattern: create a server, listen for requests, and resolve a promise when the callback arrives. Here's the Node.js version that bridges between Node's http module and Web Standards:

class NodeCallbackServer implements CallbackServer {   private server?: http.Server;   private callbackPromise?: {     resolve: (result: CallbackResult) => void;     reject: (error: Error) => void;   };    async start(options: ServerOptions): Promise<void> {     const { createServer } = await import("node:http");      return new Promise((resolve, reject) => {       this.server = createServer(async (req, res) => {         const request = this.nodeToWebRequest(req, options.port);         const response = await this.handleRequest(request);          res.writeHead(           response.status,           Object.fromEntries(response.headers.entries()),         );         res.end(await response.text());       });        this.server.listen(options.port, options.hostname, resolve);       this.server.on("error", reject);     });   }    private nodeToWebRequest(req: http.IncomingMessage, port: number): Request {     const url = new URL(req.url!, `http://localhost:${port}`);     const headers = new Headers();      for (const [key, value] of Object.entries(req.headers)) {       if (typeof value === "string") {         headers.set(key, value);       }     }      return new Request(url.toString(), {       method: req.method,       headers,     });   } } 

}

The beauty of this approach is that once we convert to Web Standards, the actual request handling logic is identical across all runtimes.

Capturing the OAuth Callback

The heart of our server is the callback handler. When the OAuth provider redirects back, we need to extract the authorization code (or error) from the query parameters:

private async handleRequest(request: Request): Promise<Response> {   const url = new URL(request.url);    if (url.pathname === this.callbackPath) {     const params: CallbackResult = {};      // Extract all query parameters     for (const [key, value] of url.searchParams) {       params[key] = value;     }      // Resolve the waiting promise     if (this.callbackPromise) {       this.callbackPromise.resolve(params);     }      // Return success page to the browser     return new Response(this.generateSuccessHTML(), {       status: 200,       headers: { "Content-Type": "text/html" }     });   }    return new Response("Not Found", { status: 404 }); } 

Notice how we capture all query parameters, not just the authorization code. OAuth providers send additional information like state for CSRF protection, and error responses include error and error_description fields. Our implementation preserves everything for maximum flexibility.

Handling Timeouts and Cancellation

Real-world OAuth flows can fail in numerous ways. Users might close the browser, deny permissions, or simply walk away. Our server needs robust timeout and cancellation handling:

async waitForCallback(path: string, timeout: number): Promise<CallbackResult> {   this.callbackPath = path;    return new Promise((resolve, reject) => {     let isResolved = false;      // Set up timeout     const timer = setTimeout(() => {       if (!isResolved) {         isResolved = true;         reject(new Error(`OAuth callback timeout after ${timeout}ms`));       }     }, timeout);      // Wrap resolve/reject to handle cleanup     const wrappedResolve = (result: CallbackResult) => {       if (!isResolved) {         isResolved = true;         clearTimeout(timer);         resolve(result);       }     };      this.callbackPromise = {       resolve: wrappedResolve,       reject: (error) => {         if (!isResolved) {           isResolved = true;           clearTimeout(timer);           reject(error);         }       }     };   }); } 

Supporting AbortSignal enables programmatic cancellation, essential for GUI applications where users might close a window mid-flow:

if (signal) {   if (signal.aborted) {     throw new Error("Operation aborted");   }    const abortHandler = () => {     this.stop();     if (this.callbackPromise) {       this.callbackPromise.reject(new Error("Operation aborted"));     }   };    signal.addEventListener("abort", abortHandler); } 

Providing User Feedback

When users complete the OAuth flow, they see a browser page indicating success or failure. Instead of a blank page or cryptic message, provide clear feedback with custom HTML:

function generateCallbackHTML(   params: CallbackResult,   templates: Templates, ): string {   if (params.error) {     // OAuth error - show error page     return templates.errorHtml       .replace(/{{error}}/g, params.error)       .replace(/{{error_description}}/g, params.error_description || "");   }    // Success - show confirmation   return (     templates.successHtml ||     `     <html>       <body style="font-family: system-ui; padding: 2rem; text-align: center;">         <h1>✅ Authorization successful!</h1>         <p>You can now close this window and return to your terminal.</p>       </body>     </html>   `   ); } 

For production applications, consider adding CSS animations, auto-close functionality, or deep links back to your desktop application.

Security Considerations

While localhost servers are inherently more secure than public endpoints, several security measures are crucial:

  1. Bind to localhost only: Never bind to 0.0.0.0 or public interfaces. This prevents network-based attacks:
this.server.listen(port, "localhost"); // NOT "0.0.0.0" 

2. Validate the state parameter: OAuth's state parameter prevents CSRF attacks. Generate it before starting the flow and validate it in the callback:

const state = crypto.randomBytes(32).toString("base64url"); const authUrl = `${provider}/authorize?state=${state}&...`;  // In callback handler if (params.state !== expectedState) {   throw new Error("State mismatch - possible CSRF attack"); } 

3. Close the server immediately: Once you receive the callback, shut down the server to minimize the attack surface:

const result = await server.waitForCallback("/callback", 30000); await server.stop(); // Always cleanup 

4. Use unpredictable ports when possible: If your OAuth provider supports dynamic redirect URIs, use random high ports to prevent port-squatting attacks.

Putting It All Together

Here's a complete example that ties everything together:

import { createCallbackServer } from "./server"; import { spawn } from "child_process";  export async function getAuthCode(authUrl: string): Promise<string> {   const server = createCallbackServer();    try {     // Start the server     await server.start({       port: 3000,       hostname: "localhost",       successHtml: "<h1>Success! You can close this window.</h1>",       errorHtml: "<h1>Error: {{error_description}}</h1>",     });      // Open the browser     const opener =       process.platform === "darwin"         ? "open"         : process.platform === "win32"           ? "start"           : "xdg-open";     spawn(opener, [authUrl], { detached: true });      // Wait for callback     const result = await server.waitForCallback("/callback", 30000);      if (result.error) {       throw new Error(`OAuth error: ${result.error_description}`);     }      return result.code!;   } finally {     // Always cleanup     await server.stop();   } }  // Usage const code = await getAuthCode(   "https://github.com/login/oauth/authorize?" +     "client_id=xxx&redirect_uri=http://localhost:3000/callback", ); 

Best Practices and Next Steps

Building a robust OAuth callback server requires attention to detail, but the patterns are consistent across implementations. Key takeaways:

  • Use Web Standards APIs for cross-runtime compatibility
  • Handle all error cases including timeouts and user cancellation
  • Provide clear user feedback with custom success/error pages
  • Implement security measures like state validation and localhost binding
  • Clean up resources by always stopping the server after use

This localhost callback approach has become the de facto standard for OAuth in CLI tools. Libraries like oauth-callback provide production-ready implementations with additional features like automatic browser detection, token persistence, and PKCE support.

Modern OAuth is moving toward even better solutions like Device Code Flow for headless environments and Dynamic Client Registration for eliminating pre-shared secrets. But for now, the localhost callback server remains the most widely supported and user-friendly approach for bringing OAuth to command-line tools.


Ready to implement OAuth in your CLI tool? Check out the complete oauth-callback library for a battle-tested implementation that handles all the edge cases discussed here.

This tutorial is part of a series on modern authentication patterns. Follow @koistya for more insights on building secure, user-friendly developer tools.

سلب مسئولیت: مقالات بازنشر شده در این سایت از پلتفرم‌ های عمومی جمع‌ آوری شده‌ اند و صرفاً برای اهداف اطلاع‌ رسانی ارائه می‌ شوند. این مطالب لزوماً بیانگر دیدگاه‌ های MEXC نیستند. کلیه حقوق متعلق به نویسندگان اصلی محتوا است. اگر معتقدید که محتوایی حقوق اشخاص ثالث را نقض می‌ کند، لطفاً برای حذف آن با آدرس ایمیل service@support.mexc.com تماس بگیرید. MEXC هیچگونه تضمینی در مورد دقت، کامل بودن یا به‌ روز بودن محتوای ارائه‌ شده نمی‌ دهد و مسئولیتی در قبال هرگونه اقدام بر اساس این اطلاعات ندارد. این محتوا مشاوره مالی، حقوقی یا حرفه‌ ای محسوب نمی‌ شود و نباید آن را به‌ عنوان توصیه یا تأیید از سوی MEXC تلقی کرد.
اشتراک گذاری مقاله

محتوای پیشنهادی

Little Pepe Raises $22.1M for EVM Layer-2 as Frog-Themed Memecoins Hold a $5.6B Niche

Little Pepe Raises $22.1M for EVM Layer-2 as Frog-Themed Memecoins Hold a $5.6B Niche

Little Pepe ($LILPEPE) has launched a Layer-2 solution on the Ethereum virtual machine and is ready to welcome a new generation of frog meme coins. Low-cost, lightning-fast transactions on Little Pepe solve Ethereum’s well-known congestion and gas issues. And as the heir apparent to Pepe’s market dominance, Little Pepe could welcome an ever-expanding world of meme coins. It all comes at a time when the meme coin market is on the rise, and frog-related tokens have built their own niche worth $5.65B. $LILPEPE Presale Becomes Top Meme Coin to Buy Now LILPEPE’s presale closed its Stage 10 early as investors poured into the project, raising the total from the presale to well over $22M. The $LILPEPE project touts zero trading taxes, anti-bot protections, and a $777K giveaway. It arrives just as $PEPE, $BRETT, and other frog coins sustain sizable market share. Pepe ($PEPE) is among the most liquid meme coins, with a multibillion-dollar capitalization and frequent bursts of volume; it’s down over a quarter in August. Brett (Based) ($BRETT) broke out in 2024, a major player on Coinbase’s Base chain. It reached its all-time high of $0.23 at the beginning of December 2024. $BRETT is still a flagship for Base meme coins. Turbo ($TURBO) holds a $280M market cap, significant even for a meme coin, with a persistent presence in the frog subset. A Frog Sector with Real Weight Frog-themed meme coins remain a significant slice of the market: the category shows an aggregate market cap of roughly $5.65B. Within that cohort, $PEPE holds about $4.36B in value, while $BRETT (Base) trades near $0.05 with a market cap around $490M. One top-50 token and several mid-rank ones before the sector gives way to small-cap coins at the bottom of the list. Still, the overall market cap of the sector is impressive enough. And performance for many of the individual tokens, while down recently, has nevertheless surged in 2025. That follows broader market trends – Interest in even the best meme coins has ebbed and flowed throughout 2025 with periodic rotations into the segment and sentiment-driven spikes. It’s a market niche ripe for a contender to challenge $PEPE for his crown. Enter Little Pepe ($LILPEPE), a token offering more than Pepe ever could. What Little Pepe Is Building Unlike most meme tokens that launch on existing chains and absorb gas costs, Little Pepe is rolling out an EVM-compatible Layer-2. Little Pepe chain boasts zero buy/sell taxes on the $LILPEPE token. The project’s whitepaper outlines a 100B total supply with 26.5% allocated to presale, 30% to chain reserves, 13.5% to staking & rewards, and 10% each to liquidity, DEX allocation, and marketing. Ultra-fast, secure, and cheap – Little Pepe is the perfect chain for building a meme coin empire. The project even features anti-sniper (anti-bot) protections and a native launchpad intended to give new tokens a fairer start. Liquidity gets locked when tokens launch, preventing a common scam where devs snag all the tokens overnight. A CertiK smart-contract audit and a preliminary CoinMarketCap page help advance the sale. There’s also the significant $777K giveaway. The terms are simple – a minimum $100 presale entry plus social tasks – and winners are announced on the project site. 10 lucky winners from the community will each receive $77K in $LILPEPE. The Little Pepe Pitch Little Pepe’s pitch is that infrastructure (an L2), not just a likable mascot, can help the token compete when meme coin volumes surge. Lower fees, tax-free trading, and anti-bot rails may appeal to retail traders who were priced out by gas or burned by launch snipers in prior cycles. $LILPEPE has room to grow, big shoes to fill, and the ambition to do it. Do your own research; though, this isn’t financial advice.
اشتراک
NewsBTC2025/08/20 21:25
اشتراک